diff --git a/Android.mk b/Android.mk new file mode 100644 index 0000000..725c89d --- /dev/null +++ b/Android.mk @@ -0,0 +1,88 @@ +# Copyright (C) 2015 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +LOCAL_PATH:= $(call my-dir) + +include $(CLEAR_VARS) + +LOCAL_MODULE_TAGS := optional + +LOCAL_SRC_FILES := $(call all-java-files-under, src) + +LOCAL_RESOURCE_DIR := $(LOCAL_PATH)/res +ifeq ($(TARGET_BUILD_APPS),) + LOCAL_RESOURCE_DIR += frameworks/support/v7/appcompat/res + LOCAL_RESOURCE_DIR += frameworks/support/v7/recyclerview/res +else + LOCAL_RESOURCE_DIR += prebuilts/sdk/current/support/v7/appcompat/res + LOCAL_RESOURCE_DIR += prebuilts/sdk/current/support/v7/recyclerview/res +endif +LOCAL_RESOURCE_DIR += frameworks/opt/chips/res +LOCAL_RESOURCE_DIR += frameworks/opt/colorpicker/res +LOCAL_RESOURCE_DIR += frameworks/opt/photoviewer/res +LOCAL_RESOURCE_DIR += frameworks/opt/photoviewer/activity/res + +LOCAL_STATIC_JAVA_LIBRARIES := android-common +LOCAL_STATIC_JAVA_LIBRARIES += android-common-framesequence +LOCAL_STATIC_JAVA_LIBRARIES += android-support-v4 +LOCAL_STATIC_JAVA_LIBRARIES += android-support-v7-appcompat +LOCAL_STATIC_JAVA_LIBRARIES += android-support-v7-palette +LOCAL_STATIC_JAVA_LIBRARIES += android-support-v7-recyclerview +LOCAL_STATIC_JAVA_LIBRARIES += android-support-v13 +LOCAL_STATIC_JAVA_LIBRARIES += com.android.vcard +LOCAL_STATIC_JAVA_LIBRARIES += guava +LOCAL_STATIC_JAVA_LIBRARIES += libchips +LOCAL_STATIC_JAVA_LIBRARIES += libphotoviewer +LOCAL_STATIC_JAVA_LIBRARIES += libphonenumber +LOCAL_STATIC_JAVA_LIBRARIES += colorpicker + +include $(LOCAL_PATH)/version.mk + +LOCAL_AAPT_FLAGS := --auto-add-overlay +LOCAL_AAPT_FLAGS += --version-name "$(version_name_package)" +LOCAL_AAPT_FLAGS += --version-code $(version_code_package) +LOCAL_AAPT_FLAGS += --extra-packages android.support.v7.appcompat +LOCAL_AAPT_FLAGS += --extra-packages android.support.v7.recyclerview +LOCAL_AAPT_FLAGS += --extra-packages com.android.ex.chips +LOCAL_AAPT_FLAGS += --extra-packages com.android.vcard +LOCAL_AAPT_FLAGS += --extra-packages com.android.ex.photo +LOCAL_AAPT_FLAGS += --extra-packages com.android.colorpicker + +ifdef TARGET_BUILD_APPS + LOCAL_JNI_SHARED_LIBRARIES := libframesequence libgiftranscode +else + LOCAL_REQUIRED_MODULES:= libframesequence libgiftranscode +endif + +LOCAL_PROGUARD_FLAGS := -ignorewarnings -include build/core/proguard_basic_keeps.flags + +LOCAL_PROGUARD_ENABLED := nosystem + +LOCAL_PROGUARD_FLAG_FILES := proguard.flags +ifeq (eng,$(TARGET_BUILD_VARIANT)) + LOCAL_PROGUARD_FLAG_FILES += proguard-test.flags +else + LOCAL_PROGUARD_FLAG_FILES += proguard-release.flags +endif + +LOCAL_JACK_ENABLED := disabled + +LOCAL_PACKAGE_NAME := messaging + +LOCAL_CERTIFICATE := platform + +LOCAL_SDK_VERSION := current + +include $(BUILD_PACKAGE) + +include $(call all-makefiles-under, $(LOCAL_PATH)) diff --git a/AndroidManifest.xml b/AndroidManifest.xml new file mode 100644 index 0000000..8fe8fae --- /dev/null +++ b/AndroidManifest.xml @@ -0,0 +1,524 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ForceProguard.mk b/ForceProguard.mk new file mode 100755 index 0000000..e6f4fea --- /dev/null +++ b/ForceProguard.mk @@ -0,0 +1,40 @@ +# Copyright (C) 2015 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Check to see if we need to force proguard to re-run, typically after using tapas to +# switch to/from eng builds. This is determined by comparing the flag files used in the previous +# build. If the flag files have changed, proguard is rerun. + +# If the LOCAL_MODULE being setup isn't a build target, then don't run ForceProguard. +ifneq (,$(findstring $(LOCAL_MODULE), $(TARGET_BUILD_APPS))) + +PREVIOUS_FLAG_FILES_USED_DIR := $(call local-intermediates-dir,1) + +# If the local intermediates dir doesn't exist, ForceProguard won't work. +ifneq ($(wildcard $(PREVIOUS_FLAG_FILES_USED_DIR)),) +PREVIOUS_FLAG_FILES_USED_FILE := $(PREVIOUS_FLAG_FILES_USED_DIR)/previous_proguard_flag_files +PREVIOUS_FLAG_FILES_USED := $(if $(wildcard $(PREVIOUS_FLAG_FILES_USED_FILE)), \ + $(shell cat $(PREVIOUS_FLAG_FILES_USED_FILE))) + +ifneq ($(strip $(PREVIOUS_FLAG_FILES_USED)), $(strip $(LOCAL_PROGUARD_FLAG_FILES))) +$(info *** Flag files used for proguard have changed; forcing proguard to rerun.) +$(shell touch $(LOCAL_PATH)/proguard.flags) +$(shell echo $(LOCAL_PROGUARD_FLAG_FILES) > $(PREVIOUS_FLAG_FILES_USED_FILE)) +endif + +endif +# End local intermediates directory existence check + +endif +# End LOCAL_MODULE is a build target check diff --git a/assets/licenses.html b/assets/licenses.html new file mode 100644 index 0000000..aa9c3b7 --- /dev/null +++ b/assets/licenses.html @@ -0,0 +1,666 @@ + + + + + + + + +

Notice for AOSP:

+
+    Copyright (c) 2005-2008, The Android Open Source Project
+
+       Licensed under the Apache License, Version 2.0 (the "License");
+       you may not use this file except in compliance with the License.
+
+       Unless required by applicable law or agreed to in writing, software
+       distributed under the License is distributed on an "AS IS" BASIS,
+       WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+       See the License for the specific language governing permissions and
+       limitations under the License.
+
+       Apache License
+       Version 2.0, January 2004
+       http://www.apache.org/licenses/
+
+       TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+       1. Definitions.
+
+          "License" shall mean the terms and conditions for use, reproduction,
+          and distribution as defined by Sections 1 through 9 of this document.
+
+          "Licensor" shall mean the copyright owner or entity authorized by
+          the copyright owner that is granting the License.
+
+          "Legal Entity" shall mean the union of the acting entity and all
+          other entities that control, are controlled by, or are under common
+          control with that entity. For the purposes of this definition,
+          "control" means (i) the power, direct or indirect, to cause the
+          direction or management of such entity, whether by contract or
+          otherwise, or (ii) ownership of fifty percent (50%) or more of the
+          outstanding shares, or (iii) beneficial ownership of such entity.
+
+          "You" (or "Your") shall mean an individual or Legal Entity
+          exercising permissions granted by this License.
+
+          "Source" form shall mean the preferred form for making modifications,
+          including but not limited to software source code, documentation
+          source, and configuration files.
+
+          "Object" form shall mean any form resulting from mechanical
+          transformation or translation of a Source form, including but
+          not limited to compiled object code, generated documentation,
+          and conversions to other media types.
+
+          "Work" shall mean the work of authorship, whether in Source or
+          Object form, made available under the License, as indicated by a
+          copyright notice that is included in or attached to the work
+          (an example is provided in the Appendix below).
+
+          "Derivative Works" shall mean any work, whether in Source or Object
+          form, that is based on (or derived from) the Work and for which the
+          editorial revisions, annotations, elaborations, or other modifications
+          represent, as a whole, an original work of authorship. For the purposes
+          of this License, Derivative Works shall not include works that remain
+          separable from, or merely link (or bind by name) to the interfaces of,
+          the Work and Derivative Works thereof.
+
+          "Contribution" shall mean any work of authorship, including
+          the original version of the Work and any modifications or additions
+          to that Work or Derivative Works thereof, that is intentionally
+          submitted to Licensor for inclusion in the Work by the copyright owner
+          or by an individual or Legal Entity authorized to submit on behalf of
+          the copyright owner. For the purposes of this definition, "submitted"
+          means any form of electronic, verbal, or written communication sent
+          to the Licensor or its representatives, including but not limited to
+          communication on electronic mailing lists, source code control systems,
+          and issue tracking systems that are managed by, or on behalf of, the
+          Licensor for the purpose of discussing and improving the Work, but
+          excluding communication that is conspicuously marked or otherwise
+          designated in writing by the copyright owner as "Not a Contribution."
+
+          "Contributor" shall mean Licensor and any individual or Legal Entity
+          on behalf of whom a Contribution has been received by Licensor and
+          subsequently incorporated within the Work.
+
+       2. Grant of Copyright License. Subject to the terms and conditions of
+          this License, each Contributor hereby grants to You a perpetual,
+          worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+          copyright license to reproduce, prepare Derivative Works of,
+          publicly display, publicly perform, sublicense, and distribute the
+          Work and such Derivative Works in Source or Object form.
+
+       3. Grant of Patent License. Subject to the terms and conditions of
+          this License, each Contributor hereby grants to You a perpetual,
+          worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+          (except as stated in this section) patent license to make, have made,
+          use, offer to sell, sell, import, and otherwise transfer the Work,
+          where such license applies only to those patent claims licensable
+          by such Contributor that are necessarily infringed by their
+          Contribution(s) alone or by combination of their Contribution(s)
+          with the Work to which such Contribution(s) was submitted. If You
+          institute patent litigation against any entity (including a
+          cross-claim or counterclaim in a lawsuit) alleging that the Work
+          or a Contribution incorporated within the Work constitutes direct
+          or contributory patent infringement, then any patent licenses
+          granted to You under this License for that Work shall terminate
+          as of the date such litigation is filed.
+
+       4. Redistribution. You may reproduce and distribute copies of the
+          Work or Derivative Works thereof in any medium, with or without
+          modifications, and in Source or Object form, provided that You
+          meet the following conditions:
+
+          (a) You must give any other recipients of the Work or
+              Derivative Works a copy of this License; and
+
+          (b) You must cause any modified files to carry prominent notices
+              stating that You changed the files; and
+
+          (c) You must retain, in the Source form of any Derivative Works
+              that You distribute, all copyright, patent, trademark, and
+              attribution notices from the Source form of the Work,
+              excluding those notices that do not pertain to any part of
+              the Derivative Works; and
+
+          (d) If the Work includes a "NOTICE" text file as part of its
+              distribution, then any Derivative Works that You distribute must
+              include a readable copy of the attribution notices contained
+              within such NOTICE file, excluding those notices that do not
+              pertain to any part of the Derivative Works, in at least one
+              of the following places: within a NOTICE text file distributed
+              as part of the Derivative Works; within the Source form or
+              documentation, if provided along with the Derivative Works; or,
+              within a display generated by the Derivative Works, if and
+              wherever such third-party notices normally appear. The contents
+              of the NOTICE file are for informational purposes only and
+              do not modify the License. You may add Your own attribution
+              notices within Derivative Works that You distribute, alongside
+              or as an addendum to the NOTICE text from the Work, provided
+              that such additional attribution notices cannot be construed
+              as modifying the License.
+
+          You may add Your own copyright statement to Your modifications and
+          may provide additional or different license terms and conditions
+          for use, reproduction, or distribution of Your modifications, or
+          for any such Derivative Works as a whole, provided Your use,
+          reproduction, and distribution of the Work otherwise complies with
+          the conditions stated in this License.
+
+       5. Submission of Contributions. Unless You explicitly state otherwise,
+          any Contribution intentionally submitted for inclusion in the Work
+          by You to the Licensor shall be under the terms and conditions of
+          this License, without any additional terms or conditions.
+          Notwithstanding the above, nothing herein shall supersede or modify
+          the terms of any separate license agreement you may have executed
+          with Licensor regarding such Contributions.
+
+       6. Trademarks. This License does not grant permission to use the trade
+          names, trademarks, service marks, or product names of the Licensor,
+          except as required for reasonable and customary use in describing the
+          origin of the Work and reproducing the content of the NOTICE file.
+
+       7. Disclaimer of Warranty. Unless required by applicable law or
+          agreed to in writing, Licensor provides the Work (and each
+          Contributor provides its Contributions) on an "AS IS" BASIS,
+          WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+          implied, including, without limitation, any warranties or conditions
+          of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+          PARTICULAR PURPOSE. You are solely responsible for determining the
+          appropriateness of using or redistributing the Work and assume any
+          risks associated with Your exercise of permissions under this License.
+
+       8. Limitation of Liability. In no event and under no legal theory,
+          whether in tort (including negligence), contract, or otherwise,
+          unless required by applicable law (such as deliberate and grossly
+          negligent acts) or agreed to in writing, shall any Contributor be
+          liable to You for damages, including any direct, indirect, special,
+          incidental, or consequential damages of any character arising as a
+          result of this License or out of the use or inability to use the
+          Work (including but not limited to damages for loss of goodwill,
+          work stoppage, computer failure or malfunction, or any and all
+          other commercial damages or losses), even if such Contributor
+          has been advised of the possibility of such damages.
+
+       9. Accepting Warranty or Additional Liability. While redistributing
+          the Work or Derivative Works thereof, You may choose to offer,
+          and charge a fee for, acceptance of support, warranty, indemnity,
+          or other liability obligations and/or rights consistent with this
+          License. However, in accepting such obligations, You may act only
+          on Your own behalf and on Your sole responsibility, not on behalf
+          of any other Contributor, and only if You agree to indemnify,
+          defend, and hold each Contributor harmless for any liability
+          incurred by, or claims asserted against, such Contributor by reason
+          of your accepting any such warranty or additional liability.
+
+ +

Notices for Guava: Google Core Libraries for Java

+
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+ +

Notices for JSR 305

+
+Copyright (c) 2007-2009, JSR305 expert group
+All rights reserved.
+
+http://www.opensource.org/licenses/bsd-license.php
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright notice,
+      this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above copyright notice,
+      this list of conditions and the following disclaimer in the documentation
+      and/or other materials provided with the distribution.
+    * Neither the name of the JSR305 expert group nor the names of its
+      contributors may be used to endorse or promote products derived from
+      this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+
+ +

Notices for libphonenumber

+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+ +

Notices for GIFLIB

+
+The GIFLIB distribution is Copyright (c) 1997  Eric S. Raymond
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+ diff --git a/build/README b/build/README new file mode 100644 index 0000000..3398014 --- /dev/null +++ b/build/README @@ -0,0 +1,8 @@ +Files in this folder are build extensions specific to the Bugle project, but currently could be merged into the core Android build. + +gcheckstyle is a tool from google3 to validate java from google3. The config file is baked into the jar, but can be updated with a little hackery. + +Files last pulled around CL 66008148: +gcheckstyle/tools/java/checkstyle/googlestyle-5.0.xml is the config file which came from /home/build/nonconf/google3/tools/java/checkstyle and was modified to use Android specific styles. It must keep the same name and path in order to correctly update the jar + +google-style-checker_deploy.jar is pulled from the build share at /google/data/ro/teams/devtools/glint/linters/live/google-style-checker_deploy.jar and can be pulled from there as needed. diff --git a/build/android_lint.mk b/build/android_lint.mk new file mode 100644 index 0000000..3a9a3d7 --- /dev/null +++ b/build/android_lint.mk @@ -0,0 +1,59 @@ +# Copyright (C) 2015 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Android lint checks for common code/resource errors specific to Android + +# Lint tool expects the intermediates dir to match the project name Bugle rather +# than $(LOCAL_PACKAGE_NAME) which is messaging +# Create a symbolic link to redirect the tool +$(LOCAL_PACKAGE_NAME)BUGLE_RENAME := $(subst $(LOCAL_PACKAGE_NAME)_intermediates,Bugle_intermediates,$(intermediates.COMMON)) +$($(LOCAL_PACKAGE_NAME)BUGLE_RENAME): SOURCE_PATH := $(abspath $(intermediates.COMMON)/) +$($(LOCAL_PACKAGE_NAME)BUGLE_RENAME): DST_PATH := $($(LOCAL_PACKAGE_NAME)BUGLE_RENAME) +$($(LOCAL_PACKAGE_NAME)BUGLE_RENAME) : + ln -f -s $(SOURCE_PATH) $(DST_PATH) + +# Lint tool expects api-versions.xml from the SDK to be in development/sdk but +# it's not. Create a symbolic link to the android SDK to fix it +API_VERSIONS_XML := $(abspath development/sdk/api-versions.xml) +$(API_VERSIONS_XML): + ln -f -s $(abspath prebuilts/fullsdk/linux/platform-tools/api/api-versions.xml) $(API_VERSIONS_XML) + +# The output xml file from the lint tool +$(LOCAL_PACKAGE_NAME)LINT_XML := $(intermediates.COMMON)/$(LOCAL_PACKAGE_NAME)_android_lint.xml + +# The transformed text file from the output xml +$(LOCAL_PACKAGE_NAME)LINT_TXT := $(intermediates.COMMON)/$(LOCAL_PACKAGE_NAME)_android_lint.txt + +# Creates the output xml from the lint tool by running the linting tool if the +# package has been updated +$($(LOCAL_PACKAGE_NAME)LINT_XML): PRIVATE_PATH := $(LOCAL_PATH) +$($(LOCAL_PACKAGE_NAME)LINT_XML): LINT_CMD = $(LINT) --quiet -Wall --disable UnusedIds,UnusedResources,MissingTranslation $(PRIVATE_PATH) +$($(LOCAL_PACKAGE_NAME)LINT_XML) : $(LOCAL_BUILT_MODULE) $($(LOCAL_PACKAGE_NAME)BUGLE_RENAME) $(API_VERSIONS_XML) + $(LINT_CMD) --xml $@ > /dev/null + +# Creates the transformed text file from the output xml by running an xslt on it +# which filters out issues from the support library and formats it for console +# output +$($(LOCAL_PACKAGE_NAME)LINT_TXT): PRIVATE_PATH := $(LOCAL_PATH) +$($(LOCAL_PACKAGE_NAME)LINT_TXT): INPUT := $($(LOCAL_PACKAGE_NAME)LINT_XML) +$($(LOCAL_PACKAGE_NAME)LINT_TXT): XSLT_CMD = xsltproc $(PRIVATE_PATH)/build/android_lint.xslt $(INPUT) +$($(LOCAL_PACKAGE_NAME)LINT_TXT) : $($(LOCAL_PACKAGE_NAME)LINT_XML) + $(hide) $(XSLT_CMD) > $@ + +# The root of the lint rule which just prints the lint errors txt file to the +# console +$(LOCAL_PACKAGE_NAME)lint: PRIVATE_PATH := $(LOCAL_PATH) +$(LOCAL_PACKAGE_NAME)lint :: $($(LOCAL_PACKAGE_NAME)LINT_TXT) + $(hide) $(PRIVATE_PATH)/build/colorize_errors.py < $< + diff --git a/build/android_lint.xslt b/build/android_lint.xslt new file mode 100644 index 0000000..d0ddc1c --- /dev/null +++ b/build/android_lint.xslt @@ -0,0 +1,40 @@ + + + + + + + + : [] + + + + : + + , + + + + + + + + diff --git a/build/colorize_errors.py b/build/colorize_errors.py new file mode 100755 index 0000000..e33edc0 --- /dev/null +++ b/build/colorize_errors.py @@ -0,0 +1,42 @@ +#!/usr/bin/python +# Copyright (C) 2015 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Given an input stream look for Error: and Warning: lines and colorize those to +# the output + +import fileinput +import re +import sys + +RED = "\033[31m" +YELLOW = "\033[33m" +RESET = "\033[0m" + +ERROR = re.compile(r"^Error:") +WARNING = re.compile(r"^Warning:") +STARTS_WITH_WS = re.compile(r"^\s") + +for line in fileinput.input(): + if ERROR.match(line): + print RED + line, + elif WARNING.match(line): + print YELLOW + line, + elif STARTS_WITH_WS.match(line): + # If the line starts with a space use the same coloring as the previous line + print line, + else: + print RESET + line, +print RESET + diff --git a/build/gcheckstyle.mk b/build/gcheckstyle.mk new file mode 100644 index 0000000..61890ee --- /dev/null +++ b/build/gcheckstyle.mk @@ -0,0 +1,45 @@ +# Copyright (C) 2015 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# The config file to use when checking style issues +STYLE_CONFIG := $(LOCAL_PATH)/build/gcheckstyle/tools/java/checkstyle/googlestyle-5.0.xml + +# The jar file to use to perform the checking +STYLE_JAR := $(LOCAL_PATH)/build/gcheckstyle/google-style-checker_deploy.jar + +# The output file to cache the results of style error checking +$(LOCAL_PACKAGE_NAME)STYLE_ERRORS_TXT := $(intermediates.COMMON)/$(LOCAL_PACKAGE_NAME)_style_errors.txt + +# The set of input files to check +$(LOCAL_PACKAGE_NAME)JAVA_FILES = $(shell find $(LOCAL_PATH)/src/ -name '*.java') + +# Updates the JAR file with the new config if the config has changed +# The config file has to be packaged into the jar, and jar uf command expects the current working directory +# to match the jar structure +$(STYLE_JAR): PRIVATE_PATH := $(LOCAL_PATH) +$(STYLE_JAR) : $(STYLE_CONFIG) + $(hide) pushd $(PRIVATE_PATH)/build/gcheckstyle && \ + jar uf google-style-checker_deploy.jar tools/java/checkstyle/googlestyle-5.0.xml && \ + popd + +# Rebuilds the style errors text if the style checker or any of the java files have changed +# FLAG: It may be more efficient to cache individual file results rather than grouping them all together +$($(LOCAL_PACKAGE_NAME)STYLE_ERRORS_TXT): SOURCES := $($(LOCAL_PACKAGE_NAME)JAVA_FILES) +$($(LOCAL_PACKAGE_NAME)STYLE_ERRORS_TXT) : $($(LOCAL_PACKAGE_NAME)JAVA_FILES) $(STYLE_JAR) + $(hide) -/usr/local/buildtools/java/jdk/bin/java -jar $(STYLE_JAR) $(SOURCES) > $@ + +# The root of the lint rule which just prints the style errors txt file to the console +$(LOCAL_PACKAGE_NAME)lint :: $($(LOCAL_PACKAGE_NAME)STYLE_ERRORS_TXT) + $(hide) $(PRIVATE_PATH)/build/process_style_output.py -omit $(abspath $(PRIVATE_PATH))/ < $< | $(PRIVATE_PATH)/build/colorize_errors.py + diff --git a/build/gcheckstyle/google-style-checker_deploy.jar b/build/gcheckstyle/google-style-checker_deploy.jar new file mode 100644 index 0000000..86afcbc Binary files /dev/null and b/build/gcheckstyle/google-style-checker_deploy.jar differ diff --git a/build/gcheckstyle/tools/java/checkstyle/googlestyle-5.0.xml b/build/gcheckstyle/tools/java/checkstyle/googlestyle-5.0.xml new file mode 100644 index 0000000..6960849 --- /dev/null +++ b/build/gcheckstyle/tools/java/checkstyle/googlestyle-5.0.xml @@ -0,0 +1,411 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/process_style_output.py b/build/process_style_output.py new file mode 100755 index 0000000..63ca9a0 --- /dev/null +++ b/build/process_style_output.py @@ -0,0 +1,38 @@ +#!/usr/bin/python +# Copyright (C) 2015 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Take the output from gstylechecker and clean up into a more friendly format + +import argparse +import fileinput +import re + +parser = argparse.ArgumentParser() +parser.add_argument('-omit') +args = parser.parse_args() + +PATTERN = re.compile(r"(?P[^:]+):(?P\d+)(?P:\d+)?:\s(?P.*)") + +for line in fileinput.input([]): + match = PATTERN.match(line) + if match: + filename = match.group("file"); + if args.omit: + filename = filename.replace(args.omit, "") + message = match.group("message") + message = message[0].upper() + message[1:] + print message + print " " + filename + ':' + match.group("line") + (match.group("column") if match.group("column") else "") + diff --git a/jni/Android.mk b/jni/Android.mk new file mode 100644 index 0000000..cd89b8f --- /dev/null +++ b/jni/Android.mk @@ -0,0 +1,35 @@ +# Copyright (C) 2015 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +LOCAL_PATH := $(call my-dir) + +include $(CLEAR_VARS) + +LOCAL_STATIC_LIBRARIES += libgif + +# Link to Android logging (liblog.so) and dynamic linker (libdl.so) libraries +LOCAL_LDFLAGS := -llog -ldl + +LOCAL_C_INCLUDES := \ + external/giflib + +LOCAL_MODULE := libgiftranscode +LOCAL_SRC_FILES := GifTranscoder.cpp + +LOCAL_CFLAGS += -Wall -Wno-unused-parameter -Wno-switch + +LOCAL_SDK_VERSION := 8 +LOCAL_NDK_STL_VARIANT := c++_static # LLVM libc++ + +include $(BUILD_SHARED_LIBRARY) diff --git a/jni/GifTranscoder.cpp b/jni/GifTranscoder.cpp new file mode 100644 index 0000000..44fa30c --- /dev/null +++ b/jni/GifTranscoder.cpp @@ -0,0 +1,573 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include + +#include + +#include "GifTranscoder.h" + +#define SQUARE(a) (a)*(a) + +// GIF does not support partial transparency, so our alpha channels are always 0x0 or 0xff. +static const ColorARGB TRANSPARENT = 0x0; + +#define ALPHA(color) (((color) >> 24) & 0xff) +#define RED(color) (((color) >> 16) & 0xff) +#define GREEN(color) (((color) >> 8) & 0xff) +#define BLUE(color) (((color) >> 0) & 0xff) + +#define MAKE_COLOR_ARGB(a, r, g, b) \ + ((a) << 24 | (r) << 16 | (g) << 8 | (b)) + +#define MAX_COLOR_DISTANCE 255 * 255 * 255 + +#define TAG "GifTranscoder.cpp" +#define LOGD_ENABLED 0 +#if LOGD_ENABLED +#define LOGD(...) ((void)__android_log_print(ANDROID_LOG_DEBUG, TAG, __VA_ARGS__)) +#else +#define LOGD(...) ((void)0) +#endif +#define LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)) +#define LOGW(...) ((void)__android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__)) +#define LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)) + +// This macro expects the assertion to pass, but logs a FATAL if not. +#define ASSERT(cond, ...) \ + ( (__builtin_expect((cond) == 0, 0)) \ + ? ((void)__android_log_assert(#cond, TAG, ## __VA_ARGS__)) \ + : (void) 0 ) +#define ASSERT_ENABLED 1 + +namespace { + +// Current time in milliseconds since Unix epoch. +double now(void) { + struct timespec res; + clock_gettime(CLOCK_REALTIME, &res); + return 1000.0 * res.tv_sec + (double) res.tv_nsec / 1e6; +} + +// Gets the pixel at position (x,y) from a buffer that uses row-major order to store an image with +// the specified width. +template +T* getPixel(T* buffer, int width, int x, int y) { + return buffer + (y * width + x); +} + +} // namespace + +int GifTranscoder::transcode(const char* pathIn, const char* pathOut) { + int error; + double t0; + GifFileType* gifIn; + GifFileType* gifOut; + + // Automatically closes the GIF files when this method returns + GifFilesCloser closer; + + gifIn = DGifOpenFileName(pathIn, &error); + if (gifIn) { + closer.setGifIn(gifIn); + LOGD("Opened input GIF: %s", pathIn); + } else { + LOGE("Could not open input GIF: %s, error = %d", pathIn, error); + return GIF_ERROR; + } + + gifOut = EGifOpenFileName(pathOut, false, &error); + if (gifOut) { + closer.setGifOut(gifOut); + LOGD("Opened output GIF: %s", pathOut); + } else { + LOGE("Could not open output GIF: %s, error = %d", pathOut, error); + return GIF_ERROR; + } + + t0 = now(); + if (resizeBoxFilter(gifIn, gifOut)) { + LOGD("Resized GIF in %.2f ms", now() - t0); + } else { + LOGE("Could not resize GIF"); + return GIF_ERROR; + } + + return GIF_OK; +} + +bool GifTranscoder::resizeBoxFilter(GifFileType* gifIn, GifFileType* gifOut) { + ASSERT(gifIn != NULL, "gifIn cannot be NULL"); + ASSERT(gifOut != NULL, "gifOut cannot be NULL"); + + if (gifIn->SWidth < 0 || gifIn->SHeight < 0) { + LOGE("Input GIF has invalid size: %d x %d", gifIn->SWidth, gifIn->SHeight); + return false; + } + + // Output GIF will be 50% the size of the original. + if (EGifPutScreenDesc(gifOut, + gifIn->SWidth / 2, + gifIn->SHeight / 2, + gifIn->SColorResolution, + gifIn->SBackGroundColor, + gifIn->SColorMap) == GIF_ERROR) { + LOGE("Could not write screen descriptor"); + return false; + } + LOGD("Wrote screen descriptor"); + + // Index of the current image. + int imageIndex = 0; + + // Transparent color of the current image. + int transparentColor = NO_TRANSPARENT_COLOR; + + // Buffer for reading raw images from the input GIF. + std::vector srcBuffer(gifIn->SWidth * gifIn->SHeight); + + // Buffer for rendering images from the input GIF. + std::unique_ptr renderBuffer(new ColorARGB[gifIn->SWidth * gifIn->SHeight]); + + // Buffer for writing new images to output GIF (one row at a time). + std::unique_ptr dstRowBuffer(new GifByteType[gifOut->SWidth]); + + // Many GIFs use DISPOSE_DO_NOT to make images draw on top of previous images. They can also + // use DISPOSE_BACKGROUND to clear the last image region before drawing the next one. We need + // to keep track of the disposal mode as we go along to properly render the GIF. + int disposalMode = DISPOSAL_UNSPECIFIED; + int prevImageDisposalMode = DISPOSAL_UNSPECIFIED; + GifImageDesc prevImageDimens; + + // Background color (applies to entire GIF). + ColorARGB bgColor = TRANSPARENT; + + GifRecordType recordType; + do { + if (DGifGetRecordType(gifIn, &recordType) == GIF_ERROR) { + LOGE("Could not get record type"); + return false; + } + LOGD("Read record type: %d", recordType); + switch (recordType) { + case IMAGE_DESC_RECORD_TYPE: { + if (DGifGetImageDesc(gifIn) == GIF_ERROR) { + LOGE("Could not read image descriptor (%d)", imageIndex); + return false; + } + + // Sanity-check the current image position. + if (gifIn->Image.Left < 0 || + gifIn->Image.Top < 0 || + gifIn->Image.Left + gifIn->Image.Width > gifIn->SWidth || + gifIn->Image.Top + gifIn->Image.Height > gifIn->SHeight) { + LOGE("GIF image extends beyond logical screen"); + return false; + } + + // Write the new image descriptor. + if (EGifPutImageDesc(gifOut, + 0, // Left + 0, // Top + gifOut->SWidth, + gifOut->SHeight, + false, // Interlace + gifIn->Image.ColorMap) == GIF_ERROR) { + LOGE("Could not write image descriptor (%d)", imageIndex); + return false; + } + + // Read the image from the input GIF. The buffer is already initialized to the + // size of the GIF, which is usually equal to the size of all the images inside it. + // If not, the call to resize below ensures that the buffer is the right size. + srcBuffer.resize(gifIn->Image.Width * gifIn->Image.Height); + if (readImage(gifIn, srcBuffer.data()) == false) { + LOGE("Could not read image data (%d)", imageIndex); + return false; + } + LOGD("Read image data (%d)", imageIndex); + // Render the image from the input GIF. + if (renderImage(gifIn, + srcBuffer.data(), + imageIndex, + transparentColor, + renderBuffer.get(), + bgColor, + prevImageDimens, + prevImageDisposalMode) == false) { + LOGE("Could not render %d", imageIndex); + return false; + } + LOGD("Rendered image (%d)", imageIndex); + + // Generate the image in the output GIF. + for (int y = 0; y < gifOut->SHeight; y++) { + for (int x = 0; x < gifOut->SWidth; x++) { + const GifByteType dstColorIndex = computeNewColorIndex( + gifIn, transparentColor, renderBuffer.get(), x, y); + *(dstRowBuffer.get() + x) = dstColorIndex; + } + if (EGifPutLine(gifOut, dstRowBuffer.get(), gifOut->SWidth) == GIF_ERROR) { + LOGE("Could not write raster data (%d)", imageIndex); + return false; + } + } + LOGD("Wrote raster data (%d)", imageIndex); + + // Save the disposal mode for rendering the next image. + // We only support DISPOSE_DO_NOT and DISPOSE_BACKGROUND. + prevImageDisposalMode = disposalMode; + if (prevImageDisposalMode == DISPOSAL_UNSPECIFIED) { + prevImageDisposalMode = DISPOSE_DO_NOT; + } else if (prevImageDisposalMode == DISPOSE_PREVIOUS) { + prevImageDisposalMode = DISPOSE_BACKGROUND; + } + if (prevImageDisposalMode == DISPOSE_BACKGROUND) { + prevImageDimens.Left = gifIn->Image.Left; + prevImageDimens.Top = gifIn->Image.Top; + prevImageDimens.Width = gifIn->Image.Width; + prevImageDimens.Height = gifIn->Image.Height; + } + + if (gifOut->Image.ColorMap) { + GifFreeMapObject(gifOut->Image.ColorMap); + gifOut->Image.ColorMap = NULL; + } + + imageIndex++; + } break; + case EXTENSION_RECORD_TYPE: { + int extCode; + GifByteType* ext; + if (DGifGetExtension(gifIn, &extCode, &ext) == GIF_ERROR) { + LOGE("Could not read extension block"); + return false; + } + LOGD("Read extension block, code: %d", extCode); + if (extCode == GRAPHICS_EXT_FUNC_CODE) { + GraphicsControlBlock gcb; + if (DGifExtensionToGCB(ext[0], ext + 1, &gcb) == GIF_ERROR) { + LOGE("Could not interpret GCB extension"); + return false; + } + transparentColor = gcb.TransparentColor; + + // This logic for setting the background color based on the first GCB + // doesn't quite match the GIF spec, but empirically it seems to work and it + // matches what libframesequence (Rastermill) does. + if (imageIndex == 0 && gifIn->SColorMap) { + if (gcb.TransparentColor == NO_TRANSPARENT_COLOR) { + GifColorType bgColorIndex = + gifIn->SColorMap->Colors[gifIn->SBackGroundColor]; + bgColor = gifColorToColorARGB(bgColorIndex); + LOGD("Set background color based on first GCB"); + } + } + + // Record the original disposal mode and then update it. + disposalMode = gcb.DisposalMode; + gcb.DisposalMode = DISPOSE_BACKGROUND; + EGifGCBToExtension(&gcb, ext + 1); + } + if (EGifPutExtensionLeader(gifOut, extCode) == GIF_ERROR) { + LOGE("Could not write extension leader"); + return false; + } + if (EGifPutExtensionBlock(gifOut, ext[0], ext + 1) == GIF_ERROR) { + LOGE("Could not write extension block"); + return false; + } + LOGD("Wrote extension block"); + while (ext != NULL) { + if (DGifGetExtensionNext(gifIn, &ext) == GIF_ERROR) { + LOGE("Could not read extension continuation"); + return false; + } + if (ext != NULL) { + LOGD("Read extension continuation"); + if (EGifPutExtensionBlock(gifOut, ext[0], ext + 1) == GIF_ERROR) { + LOGE("Could not write extension continuation"); + return false; + } + LOGD("Wrote extension continuation"); + } + } + if (EGifPutExtensionTrailer(gifOut) == GIF_ERROR) { + LOGE("Could not write extension trailer"); + return false; + } + } break; + } + + } while (recordType != TERMINATE_RECORD_TYPE); + LOGD("No more records"); + + return true; +} + +bool GifTranscoder::readImage(GifFileType* gifIn, GifByteType* rasterBits) { + if (gifIn->Image.Interlace) { + int interlacedOffset[] = { 0, 4, 2, 1 }; + int interlacedJumps[] = { 8, 8, 4, 2 }; + + // Need to perform 4 passes on the image + for (int i = 0; i < 4; i++) { + for (int j = interlacedOffset[i]; j < gifIn->Image.Height; j += interlacedJumps[i]) { + if (DGifGetLine(gifIn, + rasterBits + j * gifIn->Image.Width, + gifIn->Image.Width) == GIF_ERROR) { + LOGE("Could not read interlaced raster data"); + return false; + } + } + } + } else { + if (DGifGetLine(gifIn, rasterBits, gifIn->Image.Width * gifIn->Image.Height) == GIF_ERROR) { + LOGE("Could not read raster data"); + return false; + } + } + return true; +} + +bool GifTranscoder::renderImage(GifFileType* gifIn, + GifByteType* rasterBits, + int imageIndex, + int transparentColorIndex, + ColorARGB* renderBuffer, + ColorARGB bgColor, + GifImageDesc prevImageDimens, + int prevImageDisposalMode) { + ASSERT(imageIndex < gifIn->ImageCount, + "Image index %d is out of bounds (count=%d)", imageIndex, gifIn->ImageCount); + + ColorMapObject* colorMap = getColorMap(gifIn); + if (colorMap == NULL) { + LOGE("No GIF color map found"); + return false; + } + + // Clear all or part of the background, before drawing the first image and maybe before drawing + // subsequent images (depending on the DisposalMode). + if (imageIndex == 0) { + fillRect(renderBuffer, gifIn->SWidth, gifIn->SHeight, + 0, 0, gifIn->SWidth, gifIn->SHeight, bgColor); + } else if (prevImageDisposalMode == DISPOSE_BACKGROUND) { + fillRect(renderBuffer, gifIn->SWidth, gifIn->SHeight, + prevImageDimens.Left, prevImageDimens.Top, + prevImageDimens.Width, prevImageDimens.Height, TRANSPARENT); + } + + // Paint this image onto the canvas + for (int y = 0; y < gifIn->Image.Height; y++) { + for (int x = 0; x < gifIn->Image.Width; x++) { + GifByteType colorIndex = *getPixel(rasterBits, gifIn->Image.Width, x, y); + + // This image may be smaller than the GIF's "logical screen" + int renderX = x + gifIn->Image.Left; + int renderY = y + gifIn->Image.Top; + + // Skip drawing transparent pixels if this image renders on top of the last one + if (imageIndex > 0 && prevImageDisposalMode == DISPOSE_DO_NOT && + colorIndex == transparentColorIndex) { + continue; + } + + ColorARGB* renderPixel = getPixel(renderBuffer, gifIn->SWidth, renderX, renderY); + *renderPixel = getColorARGB(colorMap, transparentColorIndex, colorIndex); + } + } + return true; +} + +void GifTranscoder::fillRect(ColorARGB* renderBuffer, + int imageWidth, + int imageHeight, + int left, + int top, + int width, + int height, + ColorARGB color) { + ASSERT(left + width <= imageWidth, "Rectangle is outside image bounds"); + ASSERT(top + height <= imageHeight, "Rectangle is outside image bounds"); + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + ColorARGB* renderPixel = getPixel(renderBuffer, imageWidth, x + left, y + top); + *renderPixel = color; + } + } +} + +GifByteType GifTranscoder::computeNewColorIndex(GifFileType* gifIn, + int transparentColorIndex, + ColorARGB* renderBuffer, + int x, + int y) { + ColorMapObject* colorMap = getColorMap(gifIn); + + // Compute the average color of 4 adjacent pixels from the input image. + ColorARGB c1 = *getPixel(renderBuffer, gifIn->SWidth, x * 2, y * 2); + ColorARGB c2 = *getPixel(renderBuffer, gifIn->SWidth, x * 2 + 1, y * 2); + ColorARGB c3 = *getPixel(renderBuffer, gifIn->SWidth, x * 2, y * 2 + 1); + ColorARGB c4 = *getPixel(renderBuffer, gifIn->SWidth, x * 2 + 1, y * 2 + 1); + ColorARGB avgColor = computeAverage(c1, c2, c3, c4); + + // Search the color map for the best match. + return findBestColor(colorMap, transparentColorIndex, avgColor); +} + +ColorARGB GifTranscoder::computeAverage(ColorARGB c1, ColorARGB c2, ColorARGB c3, ColorARGB c4) { + char avgAlpha = (char)(((int) ALPHA(c1) + (int) ALPHA(c2) + + (int) ALPHA(c3) + (int) ALPHA(c4)) / 4); + char avgRed = (char)(((int) RED(c1) + (int) RED(c2) + + (int) RED(c3) + (int) RED(c4)) / 4); + char avgGreen = (char)(((int) GREEN(c1) + (int) GREEN(c2) + + (int) GREEN(c3) + (int) GREEN(c4)) / 4); + char avgBlue = (char)(((int) BLUE(c1) + (int) BLUE(c2) + + (int) BLUE(c3) + (int) BLUE(c4)) / 4); + return MAKE_COLOR_ARGB(avgAlpha, avgRed, avgGreen, avgBlue); +} + +GifByteType GifTranscoder::findBestColor(ColorMapObject* colorMap, int transparentColorIndex, + ColorARGB targetColor) { + // Return the transparent color if the average alpha is zero. + char alpha = ALPHA(targetColor); + if (alpha == 0 && transparentColorIndex != NO_TRANSPARENT_COLOR) { + return transparentColorIndex; + } + + GifByteType closestColorIndex = 0; + int closestColorDistance = MAX_COLOR_DISTANCE; + for (int i = 0; i < colorMap->ColorCount; i++) { + // Skip the transparent color (we've already eliminated that option). + if (i == transparentColorIndex) { + continue; + } + ColorARGB indexedColor = gifColorToColorARGB(colorMap->Colors[i]); + int distance = computeDistance(targetColor, indexedColor); + if (distance < closestColorDistance) { + closestColorIndex = i; + closestColorDistance = distance; + } + } + return closestColorIndex; +} + +int GifTranscoder::computeDistance(ColorARGB c1, ColorARGB c2) { + return SQUARE(RED(c1) - RED(c2)) + + SQUARE(GREEN(c1) - GREEN(c2)) + + SQUARE(BLUE(c1) - BLUE(c2)); +} + +ColorMapObject* GifTranscoder::getColorMap(GifFileType* gifIn) { + if (gifIn->Image.ColorMap) { + return gifIn->Image.ColorMap; + } + return gifIn->SColorMap; +} + +ColorARGB GifTranscoder::getColorARGB(ColorMapObject* colorMap, int transparentColorIndex, + GifByteType colorIndex) { + if (colorIndex == transparentColorIndex) { + return TRANSPARENT; + } + return gifColorToColorARGB(colorMap->Colors[colorIndex]); +} + +ColorARGB GifTranscoder::gifColorToColorARGB(const GifColorType& color) { + return MAKE_COLOR_ARGB(0xff, color.Red, color.Green, color.Blue); +} + +GifFilesCloser::~GifFilesCloser() { + if (mGifIn) { + DGifCloseFile(mGifIn); + mGifIn = NULL; + } + if (mGifOut) { + EGifCloseFile(mGifOut); + mGifOut = NULL; + } +} + +void GifFilesCloser::setGifIn(GifFileType* gifIn) { + ASSERT(mGifIn == NULL, "mGifIn is already set"); + mGifIn = gifIn; +} + +void GifFilesCloser::releaseGifIn() { + ASSERT(mGifIn != NULL, "mGifIn is already NULL"); + mGifIn = NULL; +} + +void GifFilesCloser::setGifOut(GifFileType* gifOut) { + ASSERT(mGifOut == NULL, "mGifOut is already set"); + mGifOut = gifOut; +} + +void GifFilesCloser::releaseGifOut() { + ASSERT(mGifOut != NULL, "mGifOut is already NULL"); + mGifOut = NULL; +} + +// JNI stuff + +jboolean transcode(JNIEnv* env, jobject clazz, jstring filePath, jstring outFilePath) { + const char* pathIn = env->GetStringUTFChars(filePath, JNI_FALSE); + const char* pathOut = env->GetStringUTFChars(outFilePath, JNI_FALSE); + + GifTranscoder transcoder; + int gifCode = transcoder.transcode(pathIn, pathOut); + + env->ReleaseStringUTFChars(filePath, pathIn); + env->ReleaseStringUTFChars(outFilePath, pathOut); + + return (gifCode == GIF_OK); +} + +const char *kClassPathName = "com/android/messaging/util/GifTranscoder"; + +JNINativeMethod kMethods[] = { + { "transcodeInternal", "(Ljava/lang/String;Ljava/lang/String;)Z", (void*)transcode }, +}; + +int registerNativeMethods(JNIEnv* env, const char* className, + JNINativeMethod* gMethods, int numMethods) { + jclass clazz = env->FindClass(className); + if (clazz == NULL) { + return JNI_FALSE; + } + if (env->RegisterNatives(clazz, gMethods, numMethods) < 0) { + return JNI_FALSE; + } + return JNI_TRUE; +} + +jint JNI_OnLoad(JavaVM* vm, void* reserved) { + JNIEnv* env; + if (vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) { + return -1; + } + if (!registerNativeMethods(env, kClassPathName, + kMethods, sizeof(kMethods) / sizeof(kMethods[0]))) { + return -1; + } + return JNI_VERSION_1_6; +} diff --git a/jni/GifTranscoder.h b/jni/GifTranscoder.h new file mode 100644 index 0000000..39ecc24 --- /dev/null +++ b/jni/GifTranscoder.h @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef GIF_TRANSCODER_H +#define GIF_TRANSCODER_H + +#include + +#include "gif_lib.h" + +// 24-bit color with alpha, stored in order: A, R, G, B. +// The internal GIF render buffer stores pixels using this format. +typedef uint32_t ColorARGB; + +// Compresses a GIF (probably animated) so it can be sent via MMS, which generally has a 1 MB limit +// on attachments. GIF image data is already compressed (LZW), so to achieve further reduction in +// file size, we reduce the image dimensions. +// +// Helpful GIF references: +// GIF89A spec: http://www.w3.org/Graphics/GIF/spec-gif89a.txt +// What's in a GIF: http://giflib.sourceforge.net/whatsinagif/index.html +// +class GifTranscoder { +public: + GifTranscoder() {} + ~GifTranscoder() {} + + // Resizes a GIF's width and height to 50% of their original dimensions. The new file is + // written to pathOut. + // + // The image is resized using a box filter, which averages the colors in each 2x2 box of pixels + // in the source to generate the color of the pixel in the destination. + // + // Returns GIF_OK (1) on success, or GIF_ERROR (0) on failure. + int transcode(const char* pathIn, const char* pathOut); + +private: + // Implementation of the box filter algorithm. + static bool resizeBoxFilter(GifFileType* gifIn, GifFileType* gifOut); + + // Reads the raster data for the current image of the GIF. + static bool readImage(GifFileType* gifIn, GifByteType* rasterBits); + + // Renders the current image of the GIF into the supplied render buffer. + static bool renderImage(GifFileType* gifIn, + GifByteType* rasterBits, + int imageIndex, + int transparentColorIndex, + ColorARGB* renderBuffer, + ColorARGB bgColor, + GifImageDesc prevImageDimens, + int prevImageDisposalMode); + + // Fills a rectangle in the buffer with a solid color. + static void fillRect(ColorARGB* renderBuffer, + int imageWidth, + int imageHeight, + int left, + int top, + int width, + int height, + ColorARGB color); + + // Computes the color for the pixel (x,y) in the current image in the output GIF. + static GifByteType computeNewColorIndex(GifFileType* gifIn, + int transparentColorIndex, + ColorARGB* renderBuffer, + int x, + int y); + + // Computes the average color (by averaging the per-channel (ARGB) values). + static ColorARGB computeAverage(ColorARGB c1, ColorARGB c2, ColorARGB c3, ColorARGB c4); + + // Searches a color map for the color closest (Euclidean distance) to the target color. + static GifByteType findBestColor(ColorMapObject* colorMap, int transparentColorIndex, + ColorARGB targetColor); + + // Computes distance (squared) between 2 colors, considering each channel a separate dimension. + static int computeDistance(ColorARGB c1, ColorARGB c2); + + // Returns the local color map of the current image (if any), or else the global color map. + static ColorMapObject* getColorMap(GifFileType* gifIn); + + // Returns an indexed color from the color map. + static ColorARGB getColorARGB(ColorMapObject* colorMap, int transparentColorIndex, + GifByteType colorIndex); + + // Converts a 24-bit GIF color (RGB) to a 32-bit ARGB color. + static ColorARGB gifColorToColorARGB(const GifColorType& color); +}; + +// Wrapper class that automatically closes the GIF files when the wrapper goes out of scope. +class GifFilesCloser { +public: + GifFilesCloser() {} + ~GifFilesCloser(); + + void setGifIn(GifFileType* gifIn); + void releaseGifIn(); + + void setGifOut(GifFileType* gifOut); + void releaseGifOut(); + +private: + GifFileType* mGifIn = NULL; + GifFileType* mGifOut = NULL; +}; + +#endif // GIF_TRANSCODER_H diff --git a/proguard-release.flags b/proguard-release.flags new file mode 100644 index 0000000..36b6720 --- /dev/null +++ b/proguard-release.flags @@ -0,0 +1,24 @@ +# Copyright (C) 2015 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +-assumenosideeffects public class com.android.messaging.util.Trace { + public void beginSection(...); + public void endSection(...); +} +-assumenosideeffects public class com.android.messaging.util.Assert { + private void setIfEngBuild(); +} +-assumenosideeffects public class com.android.messaging.util.DebugUtils { + public void logCurrentMethod(...); +} diff --git a/proguard-test.flags b/proguard-test.flags new file mode 100755 index 0000000..46e569f --- /dev/null +++ b/proguard-test.flags @@ -0,0 +1,41 @@ +# Copyright (C) 2015 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# These flags are in addition to the one in proguard.flags, which is included by the build +# directives in Android.mk. + +-dontobfuscate +-dontoptimize + + + +# FLAG(dnotario): Until we rationalize how to handle tests (extensive unit tests will want similar +# settings to these, but maybe we want to require VisibleForTesting attribute), just expose all +# non-private methods. This means we cannot run tests on user builds for the moment. +-keep class com.android.messaging.* { + !private *; +} + +-keep class com.android.messaging.*.* { + !private *; +} + +-keep class com.android.messaging.*.*.* { + !private *; +} + +# Keep the classes needed by emma +-keep class com.vladium.** { *; } + +# End of Test Rules diff --git a/proguard.flags b/proguard.flags new file mode 100644 index 0000000..759f2d4 --- /dev/null +++ b/proguard.flags @@ -0,0 +1,58 @@ +# Copyright (C) 2015 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Keep enough data for stack traces +-renamesourcefileattribute SourceFile +-keepattributes SourceFile,LineNumberTable,*Annotation* + +# Keep classes and methods that have the guava @VisibleForTesting annotation +-keep @com.google.common.annotations.VisibleForTesting class * +-keepclassmembers class * { + @com.google.common.annotations.VisibleForTesting *; +} + +# Keep methods that have the @VisibleForAnimation annotation +-keep @interface com.android.messaging.annotation.VisibleForAnimation +-keepclassmembers class * { + @com.android.messaging.annotation.VisibleForAnimation *; +} + +-keep public class * extends android.app.Activity +-keep public class * extends android.app.Application +-keep public class * extends android.app.Service +-keep public class * extends android.content.BroadcastReceiver +-keep public class * extends android.content.ContentProvider +-keep public class * extends android.app.backup.BackupAgentHelper +-keep public class * extends android.preference.Preference +-keep public class * extends android.support.v4.app.Fragment +-keep public class com.android.vcard.* { *; } + +-keep class android.support.v4.* { *; } +-keep class android.support.v4.*.* { *; } +-keep class android.support.v7.* { *; } +-keep class android.support.v7.*.* { *; } + +# Keep rastermill classes that need to be accessed from native code (JNI) +-keep class android.support.rastermill.** { *; } + +# Preserve the name of the getCaller method so it can find itself in the stack trace it generates +-keepclassmembers public class com.android.messaging.util.DebugUtils { + public static java.lang.StackTraceElement getCaller(...); +} + +# Keep the static fields of referenced inner classes of auto-generated R classes, in case we +# access those fields by reflection (e.g. EmojiMarkup) +-keepclassmembers class **.R$* { + public static ; +} diff --git a/res/animator/fab_anim.xml b/res/animator/fab_anim.xml new file mode 100644 index 0000000..e06416e --- /dev/null +++ b/res/animator/fab_anim.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + diff --git a/res/color/tab_text_color.xml b/res/color/tab_text_color.xml new file mode 100644 index 0000000..dec20dd --- /dev/null +++ b/res/color/tab_text_color.xml @@ -0,0 +1,23 @@ + + + + + + \ No newline at end of file diff --git a/res/drawable-hdpi/btn_keyboard_key_dark_normal_holo.9.png b/res/drawable-hdpi/btn_keyboard_key_dark_normal_holo.9.png new file mode 100644 index 0000000..e5f8942 Binary files /dev/null and b/res/drawable-hdpi/btn_keyboard_key_dark_normal_holo.9.png differ diff --git a/res/drawable-hdpi/btn_keyboard_key_dark_pressed_holo.9.png b/res/drawable-hdpi/btn_keyboard_key_dark_pressed_holo.9.png new file mode 100644 index 0000000..11ef458 Binary files /dev/null and b/res/drawable-hdpi/btn_keyboard_key_dark_pressed_holo.9.png differ diff --git a/res/drawable-hdpi/btn_keyboard_key_light_normal_holo.9.png b/res/drawable-hdpi/btn_keyboard_key_light_normal_holo.9.png new file mode 100644 index 0000000..eb948c1 Binary files /dev/null and b/res/drawable-hdpi/btn_keyboard_key_light_normal_holo.9.png differ diff --git a/res/drawable-hdpi/btn_keyboard_key_light_pressed_holo.9.png b/res/drawable-hdpi/btn_keyboard_key_light_pressed_holo.9.png new file mode 100644 index 0000000..b90188e Binary files /dev/null and b/res/drawable-hdpi/btn_keyboard_key_light_pressed_holo.9.png differ diff --git a/res/drawable-hdpi/cab_bg.9.png b/res/drawable-hdpi/cab_bg.9.png new file mode 100644 index 0000000..1adfbc6 Binary files /dev/null and b/res/drawable-hdpi/cab_bg.9.png differ diff --git a/res/drawable-hdpi/contact_popup_background.9.png b/res/drawable-hdpi/contact_popup_background.9.png new file mode 100644 index 0000000..060ffad Binary files /dev/null and b/res/drawable-hdpi/contact_popup_background.9.png differ diff --git a/res/drawable-hdpi/fab_new_message_static_shadow.png b/res/drawable-hdpi/fab_new_message_static_shadow.png new file mode 100644 index 0000000..22e1764 Binary files /dev/null and b/res/drawable-hdpi/fab_new_message_static_shadow.png differ diff --git a/res/drawable-hdpi/ic_add_gray.png b/res/drawable-hdpi/ic_add_gray.png new file mode 100644 index 0000000..dbbd78d Binary files /dev/null and b/res/drawable-hdpi/ic_add_gray.png differ diff --git a/res/drawable-hdpi/ic_add_white.png b/res/drawable-hdpi/ic_add_white.png new file mode 100644 index 0000000..b52309f Binary files /dev/null and b/res/drawable-hdpi/ic_add_white.png differ diff --git a/res/drawable-hdpi/ic_archive_small_dark.png b/res/drawable-hdpi/ic_archive_small_dark.png new file mode 100644 index 0000000..b679ebb Binary files /dev/null and b/res/drawable-hdpi/ic_archive_small_dark.png differ diff --git a/res/drawable-hdpi/ic_archive_small_light.png b/res/drawable-hdpi/ic_archive_small_light.png new file mode 100644 index 0000000..783126c Binary files /dev/null and b/res/drawable-hdpi/ic_archive_small_light.png differ diff --git a/res/drawable-hdpi/ic_archive_undo_small_dark.png b/res/drawable-hdpi/ic_archive_undo_small_dark.png new file mode 100644 index 0000000..505988d Binary files /dev/null and b/res/drawable-hdpi/ic_archive_undo_small_dark.png differ diff --git a/res/drawable-hdpi/ic_archive_undo_small_light.png b/res/drawable-hdpi/ic_archive_undo_small_light.png new file mode 100644 index 0000000..2c91e44 Binary files /dev/null and b/res/drawable-hdpi/ic_archive_undo_small_light.png differ diff --git a/res/drawable-hdpi/ic_arrow_back_dark.png b/res/drawable-hdpi/ic_arrow_back_dark.png new file mode 100644 index 0000000..4e5dc89 Binary files /dev/null and b/res/drawable-hdpi/ic_arrow_back_dark.png differ diff --git a/res/drawable-hdpi/ic_arrow_back_light.png b/res/drawable-hdpi/ic_arrow_back_light.png new file mode 100644 index 0000000..fa1f67b Binary files /dev/null and b/res/drawable-hdpi/ic_arrow_back_light.png differ diff --git a/res/drawable-hdpi/ic_attachment_dark.png b/res/drawable-hdpi/ic_attachment_dark.png new file mode 100644 index 0000000..10a1028 Binary files /dev/null and b/res/drawable-hdpi/ic_attachment_dark.png differ diff --git a/res/drawable-hdpi/ic_audio_light.png b/res/drawable-hdpi/ic_audio_light.png new file mode 100644 index 0000000..9605a5c Binary files /dev/null and b/res/drawable-hdpi/ic_audio_light.png differ diff --git a/res/drawable-hdpi/ic_audio_pause.png b/res/drawable-hdpi/ic_audio_pause.png new file mode 100644 index 0000000..8797ad0 Binary files /dev/null and b/res/drawable-hdpi/ic_audio_pause.png differ diff --git a/res/drawable-hdpi/ic_audio_play.png b/res/drawable-hdpi/ic_audio_play.png new file mode 100644 index 0000000..552dc9c Binary files /dev/null and b/res/drawable-hdpi/ic_audio_play.png differ diff --git a/res/drawable-hdpi/ic_camera_front_light.png b/res/drawable-hdpi/ic_camera_front_light.png new file mode 100644 index 0000000..c0ddaf6 Binary files /dev/null and b/res/drawable-hdpi/ic_camera_front_light.png differ diff --git a/res/drawable-hdpi/ic_camera_light.png b/res/drawable-hdpi/ic_camera_light.png new file mode 100644 index 0000000..d0406e4 Binary files /dev/null and b/res/drawable-hdpi/ic_camera_light.png differ diff --git a/res/drawable-hdpi/ic_camera_rear_light.png b/res/drawable-hdpi/ic_camera_rear_light.png new file mode 100644 index 0000000..6624c55 Binary files /dev/null and b/res/drawable-hdpi/ic_camera_rear_light.png differ diff --git a/res/drawable-hdpi/ic_cancel_small_dark.png b/res/drawable-hdpi/ic_cancel_small_dark.png new file mode 100644 index 0000000..5de5cfb Binary files /dev/null and b/res/drawable-hdpi/ic_cancel_small_dark.png differ diff --git a/res/drawable-hdpi/ic_cancel_small_light.png b/res/drawable-hdpi/ic_cancel_small_light.png new file mode 100644 index 0000000..05c6b19 Binary files /dev/null and b/res/drawable-hdpi/ic_cancel_small_light.png differ diff --git a/res/drawable-hdpi/ic_checkbox_blank_light.png b/res/drawable-hdpi/ic_checkbox_blank_light.png new file mode 100644 index 0000000..879a770 Binary files /dev/null and b/res/drawable-hdpi/ic_checkbox_blank_light.png differ diff --git a/res/drawable-hdpi/ic_checkbox_light.png b/res/drawable-hdpi/ic_checkbox_light.png new file mode 100644 index 0000000..7aa9f5f Binary files /dev/null and b/res/drawable-hdpi/ic_checkbox_light.png differ diff --git a/res/drawable-hdpi/ic_checkbox_outline_light.png b/res/drawable-hdpi/ic_checkbox_outline_light.png new file mode 100644 index 0000000..44ed34e Binary files /dev/null and b/res/drawable-hdpi/ic_checkbox_outline_light.png differ diff --git a/res/drawable-hdpi/ic_checkmark_circle_blue.png b/res/drawable-hdpi/ic_checkmark_circle_blue.png new file mode 100644 index 0000000..55f277e Binary files /dev/null and b/res/drawable-hdpi/ic_checkmark_circle_blue.png differ diff --git a/res/drawable-hdpi/ic_checkmark_large.png b/res/drawable-hdpi/ic_checkmark_large.png new file mode 100644 index 0000000..b8ecc06 Binary files /dev/null and b/res/drawable-hdpi/ic_checkmark_large.png differ diff --git a/res/drawable-hdpi/ic_checkmark_large_light.png b/res/drawable-hdpi/ic_checkmark_large_light.png new file mode 100644 index 0000000..48bf283 Binary files /dev/null and b/res/drawable-hdpi/ic_checkmark_large_light.png differ diff --git a/res/drawable-hdpi/ic_checkmark_small_blue.png b/res/drawable-hdpi/ic_checkmark_small_blue.png new file mode 100644 index 0000000..8661489 Binary files /dev/null and b/res/drawable-hdpi/ic_checkmark_small_blue.png differ diff --git a/res/drawable-hdpi/ic_checkmark_small_light.png b/res/drawable-hdpi/ic_checkmark_small_light.png new file mode 100644 index 0000000..ae0bf6d Binary files /dev/null and b/res/drawable-hdpi/ic_checkmark_small_light.png differ diff --git a/res/drawable-hdpi/ic_color_lens.png b/res/drawable-hdpi/ic_color_lens.png new file mode 100644 index 0000000..69a4287 Binary files /dev/null and b/res/drawable-hdpi/ic_color_lens.png differ diff --git a/res/drawable-hdpi/ic_content_copy_dark.png b/res/drawable-hdpi/ic_content_copy_dark.png new file mode 100644 index 0000000..22d2516 Binary files /dev/null and b/res/drawable-hdpi/ic_content_copy_dark.png differ diff --git a/res/drawable-hdpi/ic_crying_hero.png b/res/drawable-hdpi/ic_crying_hero.png new file mode 100644 index 0000000..804b4e6 Binary files /dev/null and b/res/drawable-hdpi/ic_crying_hero.png differ diff --git a/res/drawable-hdpi/ic_delete_small_dark.png b/res/drawable-hdpi/ic_delete_small_dark.png new file mode 100644 index 0000000..901f992 Binary files /dev/null and b/res/drawable-hdpi/ic_delete_small_dark.png differ diff --git a/res/drawable-hdpi/ic_delete_small_light.png b/res/drawable-hdpi/ic_delete_small_light.png new file mode 100644 index 0000000..d2ed6d3 Binary files /dev/null and b/res/drawable-hdpi/ic_delete_small_light.png differ diff --git a/res/drawable-hdpi/ic_dnd_on_dark.png b/res/drawable-hdpi/ic_dnd_on_dark.png new file mode 100644 index 0000000..181df02 Binary files /dev/null and b/res/drawable-hdpi/ic_dnd_on_dark.png differ diff --git a/res/drawable-hdpi/ic_dnd_on_light.png b/res/drawable-hdpi/ic_dnd_on_light.png new file mode 100644 index 0000000..cadf5b5 Binary files /dev/null and b/res/drawable-hdpi/ic_dnd_on_light.png differ diff --git a/res/drawable-hdpi/ic_failed_light.png b/res/drawable-hdpi/ic_failed_light.png new file mode 100644 index 0000000..3614fbe Binary files /dev/null and b/res/drawable-hdpi/ic_failed_light.png differ diff --git a/res/drawable-hdpi/ic_failed_status_red.png b/res/drawable-hdpi/ic_failed_status_red.png new file mode 100644 index 0000000..aa2d138 Binary files /dev/null and b/res/drawable-hdpi/ic_failed_status_red.png differ diff --git a/res/drawable-hdpi/ic_file_download_dark.png b/res/drawable-hdpi/ic_file_download_dark.png new file mode 100644 index 0000000..028c85e Binary files /dev/null and b/res/drawable-hdpi/ic_file_download_dark.png differ diff --git a/res/drawable-hdpi/ic_file_download_light.png b/res/drawable-hdpi/ic_file_download_light.png new file mode 100644 index 0000000..ffa5b82 Binary files /dev/null and b/res/drawable-hdpi/ic_file_download_light.png differ diff --git a/res/drawable-hdpi/ic_forward_dark.png b/res/drawable-hdpi/ic_forward_dark.png new file mode 100644 index 0000000..e8b718a Binary files /dev/null and b/res/drawable-hdpi/ic_forward_dark.png differ diff --git a/res/drawable-hdpi/ic_history_light.png b/res/drawable-hdpi/ic_history_light.png new file mode 100644 index 0000000..4b512fe Binary files /dev/null and b/res/drawable-hdpi/ic_history_light.png differ diff --git a/res/drawable-hdpi/ic_image_light.png b/res/drawable-hdpi/ic_image_light.png new file mode 100644 index 0000000..744f920 Binary files /dev/null and b/res/drawable-hdpi/ic_image_light.png differ diff --git a/res/drawable-hdpi/ic_ime_dark.png b/res/drawable-hdpi/ic_ime_dark.png new file mode 100644 index 0000000..a539d28 Binary files /dev/null and b/res/drawable-hdpi/ic_ime_dark.png differ diff --git a/res/drawable-hdpi/ic_ime_light.png b/res/drawable-hdpi/ic_ime_light.png new file mode 100644 index 0000000..da65039 Binary files /dev/null and b/res/drawable-hdpi/ic_ime_light.png differ diff --git a/res/drawable-hdpi/ic_info_dark.png b/res/drawable-hdpi/ic_info_dark.png new file mode 100644 index 0000000..1453228 Binary files /dev/null and b/res/drawable-hdpi/ic_info_dark.png differ diff --git a/res/drawable-hdpi/ic_keyboard_arrow_left_light.png b/res/drawable-hdpi/ic_keyboard_arrow_left_light.png new file mode 100644 index 0000000..50f280e Binary files /dev/null and b/res/drawable-hdpi/ic_keyboard_arrow_left_light.png differ diff --git a/res/drawable-hdpi/ic_keyboard_arrow_right_light.png b/res/drawable-hdpi/ic_keyboard_arrow_right_light.png new file mode 100644 index 0000000..3e31c08 Binary files /dev/null and b/res/drawable-hdpi/ic_keyboard_arrow_right_light.png differ diff --git a/res/drawable-hdpi/ic_launcher.png b/res/drawable-hdpi/ic_launcher.png new file mode 100644 index 0000000..2c52f06 Binary files /dev/null and b/res/drawable-hdpi/ic_launcher.png differ diff --git a/res/drawable-hdpi/ic_mp_audio_mic.png b/res/drawable-hdpi/ic_mp_audio_mic.png new file mode 100644 index 0000000..9c005d6 Binary files /dev/null and b/res/drawable-hdpi/ic_mp_audio_mic.png differ diff --git a/res/drawable-hdpi/ic_mp_camera_large_light.png b/res/drawable-hdpi/ic_mp_camera_large_light.png new file mode 100644 index 0000000..721b760 Binary files /dev/null and b/res/drawable-hdpi/ic_mp_camera_large_light.png differ diff --git a/res/drawable-hdpi/ic_mp_camera_small_light.png b/res/drawable-hdpi/ic_mp_camera_small_light.png new file mode 100644 index 0000000..6e3b09b Binary files /dev/null and b/res/drawable-hdpi/ic_mp_camera_small_light.png differ diff --git a/res/drawable-hdpi/ic_mp_capture_stop_large_light.png b/res/drawable-hdpi/ic_mp_capture_stop_large_light.png new file mode 100644 index 0000000..5e29fdd Binary files /dev/null and b/res/drawable-hdpi/ic_mp_capture_stop_large_light.png differ diff --git a/res/drawable-hdpi/ic_mp_full_screen_light.png b/res/drawable-hdpi/ic_mp_full_screen_light.png new file mode 100644 index 0000000..9c69eb8 Binary files /dev/null and b/res/drawable-hdpi/ic_mp_full_screen_light.png differ diff --git a/res/drawable-hdpi/ic_mp_video_large_light.png b/res/drawable-hdpi/ic_mp_video_large_light.png new file mode 100644 index 0000000..d6db42c Binary files /dev/null and b/res/drawable-hdpi/ic_mp_video_large_light.png differ diff --git a/res/drawable-hdpi/ic_mp_video_small_light.png b/res/drawable-hdpi/ic_mp_video_small_light.png new file mode 100644 index 0000000..9816af7 Binary files /dev/null and b/res/drawable-hdpi/ic_mp_video_small_light.png differ diff --git a/res/drawable-hdpi/ic_notifications_off_dark.png b/res/drawable-hdpi/ic_notifications_off_dark.png new file mode 100644 index 0000000..9bcdc62 Binary files /dev/null and b/res/drawable-hdpi/ic_notifications_off_dark.png differ diff --git a/res/drawable-hdpi/ic_notifications_off_light.png b/res/drawable-hdpi/ic_notifications_off_light.png new file mode 100644 index 0000000..433b6a9 Binary files /dev/null and b/res/drawable-hdpi/ic_notifications_off_light.png differ diff --git a/res/drawable-hdpi/ic_notifications_off_small_light.png b/res/drawable-hdpi/ic_notifications_off_small_light.png new file mode 100644 index 0000000..fbd0ad5 Binary files /dev/null and b/res/drawable-hdpi/ic_notifications_off_small_light.png differ diff --git a/res/drawable-hdpi/ic_notifications_on_dark.png b/res/drawable-hdpi/ic_notifications_on_dark.png new file mode 100644 index 0000000..21e48a5 Binary files /dev/null and b/res/drawable-hdpi/ic_notifications_on_dark.png differ diff --git a/res/drawable-hdpi/ic_notifications_on_light.png b/res/drawable-hdpi/ic_notifications_on_light.png new file mode 100644 index 0000000..6b39fec Binary files /dev/null and b/res/drawable-hdpi/ic_notifications_on_light.png differ diff --git a/res/drawable-hdpi/ic_numeric_dialpad.png b/res/drawable-hdpi/ic_numeric_dialpad.png new file mode 100644 index 0000000..5460fd7 Binary files /dev/null and b/res/drawable-hdpi/ic_numeric_dialpad.png differ diff --git a/res/drawable-hdpi/ic_oobe_conv_list.png b/res/drawable-hdpi/ic_oobe_conv_list.png new file mode 100644 index 0000000..3b904ba Binary files /dev/null and b/res/drawable-hdpi/ic_oobe_conv_list.png differ diff --git a/res/drawable-hdpi/ic_oobe_freq_list.png b/res/drawable-hdpi/ic_oobe_freq_list.png new file mode 100644 index 0000000..148a9ea Binary files /dev/null and b/res/drawable-hdpi/ic_oobe_freq_list.png differ diff --git a/res/drawable-hdpi/ic_open_in_new.png b/res/drawable-hdpi/ic_open_in_new.png new file mode 100644 index 0000000..06e5f71 Binary files /dev/null and b/res/drawable-hdpi/ic_open_in_new.png differ diff --git a/res/drawable-hdpi/ic_people_add_light.png b/res/drawable-hdpi/ic_people_add_light.png new file mode 100644 index 0000000..c506e9a Binary files /dev/null and b/res/drawable-hdpi/ic_people_add_light.png differ diff --git a/res/drawable-hdpi/ic_person_add_blue.png b/res/drawable-hdpi/ic_person_add_blue.png new file mode 100644 index 0000000..dfd2bc1 Binary files /dev/null and b/res/drawable-hdpi/ic_person_add_blue.png differ diff --git a/res/drawable-hdpi/ic_person_add_dark.png b/res/drawable-hdpi/ic_person_add_dark.png new file mode 100644 index 0000000..71ca2a0 Binary files /dev/null and b/res/drawable-hdpi/ic_person_add_dark.png differ diff --git a/res/drawable-hdpi/ic_person_add_light.png b/res/drawable-hdpi/ic_person_add_light.png new file mode 100644 index 0000000..bb8ad3e Binary files /dev/null and b/res/drawable-hdpi/ic_person_add_light.png differ diff --git a/res/drawable-hdpi/ic_person_light.png b/res/drawable-hdpi/ic_person_light.png new file mode 100644 index 0000000..b439c93 Binary files /dev/null and b/res/drawable-hdpi/ic_person_light.png differ diff --git a/res/drawable-hdpi/ic_person_light_large.png b/res/drawable-hdpi/ic_person_light_large.png new file mode 100644 index 0000000..5ddf265 Binary files /dev/null and b/res/drawable-hdpi/ic_person_light_large.png differ diff --git a/res/drawable-hdpi/ic_phone_small_light.png b/res/drawable-hdpi/ic_phone_small_light.png new file mode 100644 index 0000000..35cf121 Binary files /dev/null and b/res/drawable-hdpi/ic_phone_small_light.png differ diff --git a/res/drawable-hdpi/ic_photo_library_light.png b/res/drawable-hdpi/ic_photo_library_light.png new file mode 100644 index 0000000..1f71a62 Binary files /dev/null and b/res/drawable-hdpi/ic_photo_library_light.png differ diff --git a/res/drawable-hdpi/ic_preview_pause.png b/res/drawable-hdpi/ic_preview_pause.png new file mode 100644 index 0000000..fd49749 Binary files /dev/null and b/res/drawable-hdpi/ic_preview_pause.png differ diff --git a/res/drawable-hdpi/ic_preview_play.png b/res/drawable-hdpi/ic_preview_play.png new file mode 100644 index 0000000..db2b2ac Binary files /dev/null and b/res/drawable-hdpi/ic_preview_play.png differ diff --git a/res/drawable-hdpi/ic_remove_light.png b/res/drawable-hdpi/ic_remove_light.png new file mode 100644 index 0000000..3992231 Binary files /dev/null and b/res/drawable-hdpi/ic_remove_light.png differ diff --git a/res/drawable-hdpi/ic_remove_small_light.png b/res/drawable-hdpi/ic_remove_small_light.png new file mode 100644 index 0000000..07e668c Binary files /dev/null and b/res/drawable-hdpi/ic_remove_small_light.png differ diff --git a/res/drawable-hdpi/ic_save_dark.png b/res/drawable-hdpi/ic_save_dark.png new file mode 100644 index 0000000..7cf01da Binary files /dev/null and b/res/drawable-hdpi/ic_save_dark.png differ diff --git a/res/drawable-hdpi/ic_save_light.png b/res/drawable-hdpi/ic_save_light.png new file mode 100644 index 0000000..b76adb2 Binary files /dev/null and b/res/drawable-hdpi/ic_save_light.png differ diff --git a/res/drawable-hdpi/ic_search_light.png b/res/drawable-hdpi/ic_search_light.png new file mode 100644 index 0000000..c35418e Binary files /dev/null and b/res/drawable-hdpi/ic_search_light.png differ diff --git a/res/drawable-hdpi/ic_send_dark.png b/res/drawable-hdpi/ic_send_dark.png new file mode 100644 index 0000000..62108ef Binary files /dev/null and b/res/drawable-hdpi/ic_send_dark.png differ diff --git a/res/drawable-hdpi/ic_send_light.png b/res/drawable-hdpi/ic_send_light.png new file mode 100644 index 0000000..724783d Binary files /dev/null and b/res/drawable-hdpi/ic_send_light.png differ diff --git a/res/drawable-hdpi/ic_share_dark.png b/res/drawable-hdpi/ic_share_dark.png new file mode 100644 index 0000000..bd27042 Binary files /dev/null and b/res/drawable-hdpi/ic_share_dark.png differ diff --git a/res/drawable-hdpi/ic_share_light.png b/res/drawable-hdpi/ic_share_light.png new file mode 100644 index 0000000..dadb3e8 Binary files /dev/null and b/res/drawable-hdpi/ic_share_light.png differ diff --git a/res/drawable-hdpi/ic_sim_card_send.png b/res/drawable-hdpi/ic_sim_card_send.png new file mode 100644 index 0000000..426b153 Binary files /dev/null and b/res/drawable-hdpi/ic_sim_card_send.png differ diff --git a/res/drawable-hdpi/ic_sms_delivery_ok.png b/res/drawable-hdpi/ic_sms_delivery_ok.png new file mode 100644 index 0000000..e038563 Binary files /dev/null and b/res/drawable-hdpi/ic_sms_delivery_ok.png differ diff --git a/res/drawable-hdpi/ic_sms_failed_light.png b/res/drawable-hdpi/ic_sms_failed_light.png new file mode 100644 index 0000000..4973427 Binary files /dev/null and b/res/drawable-hdpi/ic_sms_failed_light.png differ diff --git a/res/drawable-hdpi/ic_sms_light.png b/res/drawable-hdpi/ic_sms_light.png new file mode 100644 index 0000000..aaeec4e Binary files /dev/null and b/res/drawable-hdpi/ic_sms_light.png differ diff --git a/res/drawable-hdpi/ic_sms_multi_light.png b/res/drawable-hdpi/ic_sms_multi_light.png new file mode 100644 index 0000000..81ac2e7 Binary files /dev/null and b/res/drawable-hdpi/ic_sms_multi_light.png differ diff --git a/res/drawable-hdpi/ic_tooltip_arrow.png b/res/drawable-hdpi/ic_tooltip_arrow.png new file mode 100644 index 0000000..870d502 Binary files /dev/null and b/res/drawable-hdpi/ic_tooltip_arrow.png differ diff --git a/res/drawable-hdpi/ic_video_play_light.png b/res/drawable-hdpi/ic_video_play_light.png new file mode 100644 index 0000000..4a74f37 Binary files /dev/null and b/res/drawable-hdpi/ic_video_play_light.png differ diff --git a/res/drawable-hdpi/ic_wear_reply.png b/res/drawable-hdpi/ic_wear_reply.png new file mode 100644 index 0000000..19ce472 Binary files /dev/null and b/res/drawable-hdpi/ic_wear_reply.png differ diff --git a/res/drawable-hdpi/ic_widget_avatar_shadow.png b/res/drawable-hdpi/ic_widget_avatar_shadow.png new file mode 100644 index 0000000..9e0d519 Binary files /dev/null and b/res/drawable-hdpi/ic_widget_avatar_shadow.png differ diff --git a/res/drawable-hdpi/ic_widget_list.png b/res/drawable-hdpi/ic_widget_list.png new file mode 100644 index 0000000..06f9732 Binary files /dev/null and b/res/drawable-hdpi/ic_widget_list.png differ diff --git a/res/drawable-hdpi/msg_bubble_error.9.png b/res/drawable-hdpi/msg_bubble_error.9.png new file mode 100644 index 0000000..6a26c30 Binary files /dev/null and b/res/drawable-hdpi/msg_bubble_error.9.png differ diff --git a/res/drawable-hdpi/msg_bubble_incoming.9.png b/res/drawable-hdpi/msg_bubble_incoming.9.png new file mode 100644 index 0000000..7fed0a8 Binary files /dev/null and b/res/drawable-hdpi/msg_bubble_incoming.9.png differ diff --git a/res/drawable-hdpi/msg_bubble_input.9.png b/res/drawable-hdpi/msg_bubble_input.9.png new file mode 100644 index 0000000..01076da Binary files /dev/null and b/res/drawable-hdpi/msg_bubble_input.9.png differ diff --git a/res/drawable-hdpi/msg_bubble_outgoing.9.png b/res/drawable-hdpi/msg_bubble_outgoing.9.png new file mode 100644 index 0000000..8e7ccc0 Binary files /dev/null and b/res/drawable-hdpi/msg_bubble_outgoing.9.png differ diff --git a/res/drawable-hdpi/permissions.png b/res/drawable-hdpi/permissions.png new file mode 100644 index 0000000..20697cf Binary files /dev/null and b/res/drawable-hdpi/permissions.png differ diff --git a/res/drawable-hdpi/swipe_shadow.9.png b/res/drawable-hdpi/swipe_shadow.9.png new file mode 100644 index 0000000..8405721 Binary files /dev/null and b/res/drawable-hdpi/swipe_shadow.9.png differ diff --git a/res/drawable-hdpi/swipe_shadow_drag.9.png b/res/drawable-hdpi/swipe_shadow_drag.9.png new file mode 100644 index 0000000..950ec71 Binary files /dev/null and b/res/drawable-hdpi/swipe_shadow_drag.9.png differ diff --git a/res/drawable-hdpi/sym_keyboard_delete_holo.png b/res/drawable-hdpi/sym_keyboard_delete_holo.png new file mode 100644 index 0000000..9e74a64 Binary files /dev/null and b/res/drawable-hdpi/sym_keyboard_delete_holo.png differ diff --git a/res/drawable-hdpi/tab_btn_bg_normal.9.png b/res/drawable-hdpi/tab_btn_bg_normal.9.png new file mode 100644 index 0000000..791bb40 Binary files /dev/null and b/res/drawable-hdpi/tab_btn_bg_normal.9.png differ diff --git a/res/drawable-hdpi/tab_btn_bg_pressed.9.png b/res/drawable-hdpi/tab_btn_bg_pressed.9.png new file mode 100644 index 0000000..b2b488c Binary files /dev/null and b/res/drawable-hdpi/tab_btn_bg_pressed.9.png differ diff --git a/res/drawable-hdpi/tab_selected.9.png b/res/drawable-hdpi/tab_selected.9.png new file mode 100644 index 0000000..84e63df Binary files /dev/null and b/res/drawable-hdpi/tab_selected.9.png differ diff --git a/res/drawable-hdpi/tab_selected_focused_holo.9.png b/res/drawable-hdpi/tab_selected_focused_holo.9.png new file mode 100644 index 0000000..5cdc5b4 Binary files /dev/null and b/res/drawable-hdpi/tab_selected_focused_holo.9.png differ diff --git a/res/drawable-hdpi/tab_selected_pressed_focused_holo.9.png b/res/drawable-hdpi/tab_selected_pressed_focused_holo.9.png new file mode 100644 index 0000000..ca3eb7b Binary files /dev/null and b/res/drawable-hdpi/tab_selected_pressed_focused_holo.9.png differ diff --git a/res/drawable-hdpi/tab_selected_pressed_holo.9.png b/res/drawable-hdpi/tab_selected_pressed_holo.9.png new file mode 100644 index 0000000..08e5edb Binary files /dev/null and b/res/drawable-hdpi/tab_selected_pressed_holo.9.png differ diff --git a/res/drawable-hdpi/tab_unselected.9.png b/res/drawable-hdpi/tab_unselected.9.png new file mode 100644 index 0000000..bbcfb2c Binary files /dev/null and b/res/drawable-hdpi/tab_unselected.9.png differ diff --git a/res/drawable-hdpi/tab_unselected_focused_holo.9.png b/res/drawable-hdpi/tab_unselected_focused_holo.9.png new file mode 100644 index 0000000..a0c8bc3 Binary files /dev/null and b/res/drawable-hdpi/tab_unselected_focused_holo.9.png differ diff --git a/res/drawable-hdpi/tab_unselected_pressed_focused_holo.9.png b/res/drawable-hdpi/tab_unselected_pressed_focused_holo.9.png new file mode 100644 index 0000000..6cf948e Binary files /dev/null and b/res/drawable-hdpi/tab_unselected_pressed_focused_holo.9.png differ diff --git a/res/drawable-hdpi/tab_unselected_pressed_holo.9.png b/res/drawable-hdpi/tab_unselected_pressed_holo.9.png new file mode 100644 index 0000000..a5dce50 Binary files /dev/null and b/res/drawable-hdpi/tab_unselected_pressed_holo.9.png differ diff --git a/res/drawable-hdpi/widget_hr.9.png b/res/drawable-hdpi/widget_hr.9.png new file mode 100644 index 0000000..7590256 Binary files /dev/null and b/res/drawable-hdpi/widget_hr.9.png differ diff --git a/res/drawable-hdpi/widget_msg_bubble_incoming.9.png b/res/drawable-hdpi/widget_msg_bubble_incoming.9.png new file mode 100644 index 0000000..c6e77a7 Binary files /dev/null and b/res/drawable-hdpi/widget_msg_bubble_incoming.9.png differ diff --git a/res/drawable-hdpi/widget_msg_bubble_outgoing.9.png b/res/drawable-hdpi/widget_msg_bubble_outgoing.9.png new file mode 100644 index 0000000..476efae Binary files /dev/null and b/res/drawable-hdpi/widget_msg_bubble_outgoing.9.png differ diff --git a/res/drawable-ldrtl-hdpi/ic_keyboard_arrow_left_light.png b/res/drawable-ldrtl-hdpi/ic_keyboard_arrow_left_light.png new file mode 100644 index 0000000..3e31c08 Binary files /dev/null and b/res/drawable-ldrtl-hdpi/ic_keyboard_arrow_left_light.png differ diff --git a/res/drawable-ldrtl-hdpi/ic_keyboard_arrow_right_light.png b/res/drawable-ldrtl-hdpi/ic_keyboard_arrow_right_light.png new file mode 100644 index 0000000..50f280e Binary files /dev/null and b/res/drawable-ldrtl-hdpi/ic_keyboard_arrow_right_light.png differ diff --git a/res/drawable-ldrtl-hdpi/ic_send_light.png b/res/drawable-ldrtl-hdpi/ic_send_light.png new file mode 100644 index 0000000..4ee98a3 Binary files /dev/null and b/res/drawable-ldrtl-hdpi/ic_send_light.png differ diff --git a/res/drawable-ldrtl-hdpi/msg_bubble_incoming.9.png b/res/drawable-ldrtl-hdpi/msg_bubble_incoming.9.png new file mode 100644 index 0000000..8e7ccc0 Binary files /dev/null and b/res/drawable-ldrtl-hdpi/msg_bubble_incoming.9.png differ diff --git a/res/drawable-ldrtl-hdpi/msg_bubble_input.9.png b/res/drawable-ldrtl-hdpi/msg_bubble_input.9.png new file mode 100644 index 0000000..c3f68b0 Binary files /dev/null and b/res/drawable-ldrtl-hdpi/msg_bubble_input.9.png differ diff --git a/res/drawable-ldrtl-hdpi/msg_bubble_outgoing.9.png b/res/drawable-ldrtl-hdpi/msg_bubble_outgoing.9.png new file mode 100644 index 0000000..7fed0a8 Binary files /dev/null and b/res/drawable-ldrtl-hdpi/msg_bubble_outgoing.9.png differ diff --git a/res/drawable-ldrtl-hdpi/sym_keyboard_delete_holo_dark.png b/res/drawable-ldrtl-hdpi/sym_keyboard_delete_holo_dark.png new file mode 100644 index 0000000..f0e1b08 Binary files /dev/null and b/res/drawable-ldrtl-hdpi/sym_keyboard_delete_holo_dark.png differ diff --git a/res/drawable-ldrtl-mdpi/ic_keyboard_arrow_left_light.png b/res/drawable-ldrtl-mdpi/ic_keyboard_arrow_left_light.png new file mode 100644 index 0000000..9bd625d Binary files /dev/null and b/res/drawable-ldrtl-mdpi/ic_keyboard_arrow_left_light.png differ diff --git a/res/drawable-ldrtl-mdpi/ic_keyboard_arrow_right_light.png b/res/drawable-ldrtl-mdpi/ic_keyboard_arrow_right_light.png new file mode 100644 index 0000000..6004f37 Binary files /dev/null and b/res/drawable-ldrtl-mdpi/ic_keyboard_arrow_right_light.png differ diff --git a/res/drawable-ldrtl-mdpi/ic_send_light.png b/res/drawable-ldrtl-mdpi/ic_send_light.png new file mode 100644 index 0000000..1267d66 Binary files /dev/null and b/res/drawable-ldrtl-mdpi/ic_send_light.png differ diff --git a/res/drawable-ldrtl-mdpi/msg_bubble_incoming.9.png b/res/drawable-ldrtl-mdpi/msg_bubble_incoming.9.png new file mode 100644 index 0000000..5ca9b14 Binary files /dev/null and b/res/drawable-ldrtl-mdpi/msg_bubble_incoming.9.png differ diff --git a/res/drawable-ldrtl-mdpi/msg_bubble_input.9.png b/res/drawable-ldrtl-mdpi/msg_bubble_input.9.png new file mode 100644 index 0000000..6f0b4bc Binary files /dev/null and b/res/drawable-ldrtl-mdpi/msg_bubble_input.9.png differ diff --git a/res/drawable-ldrtl-mdpi/msg_bubble_outgoing.9.png b/res/drawable-ldrtl-mdpi/msg_bubble_outgoing.9.png new file mode 100644 index 0000000..4e7e464 Binary files /dev/null and b/res/drawable-ldrtl-mdpi/msg_bubble_outgoing.9.png differ diff --git a/res/drawable-ldrtl-mdpi/sym_keyboard_delete_holo_dark.png b/res/drawable-ldrtl-mdpi/sym_keyboard_delete_holo_dark.png new file mode 100644 index 0000000..346e754 Binary files /dev/null and b/res/drawable-ldrtl-mdpi/sym_keyboard_delete_holo_dark.png differ diff --git a/res/drawable-ldrtl-xhdpi/ic_keyboard_arrow_left_light.png b/res/drawable-ldrtl-xhdpi/ic_keyboard_arrow_left_light.png new file mode 100644 index 0000000..51a1903 Binary files /dev/null and b/res/drawable-ldrtl-xhdpi/ic_keyboard_arrow_left_light.png differ diff --git a/res/drawable-ldrtl-xhdpi/ic_keyboard_arrow_right_light.png b/res/drawable-ldrtl-xhdpi/ic_keyboard_arrow_right_light.png new file mode 100644 index 0000000..464a0bc Binary files /dev/null and b/res/drawable-ldrtl-xhdpi/ic_keyboard_arrow_right_light.png differ diff --git a/res/drawable-ldrtl-xhdpi/ic_send_light.png b/res/drawable-ldrtl-xhdpi/ic_send_light.png new file mode 100644 index 0000000..178c3cf Binary files /dev/null and b/res/drawable-ldrtl-xhdpi/ic_send_light.png differ diff --git a/res/drawable-ldrtl-xhdpi/msg_bubble_incoming.9.png b/res/drawable-ldrtl-xhdpi/msg_bubble_incoming.9.png new file mode 100644 index 0000000..52cb936 Binary files /dev/null and b/res/drawable-ldrtl-xhdpi/msg_bubble_incoming.9.png differ diff --git a/res/drawable-ldrtl-xhdpi/msg_bubble_input.9.png b/res/drawable-ldrtl-xhdpi/msg_bubble_input.9.png new file mode 100644 index 0000000..4c5f45d Binary files /dev/null and b/res/drawable-ldrtl-xhdpi/msg_bubble_input.9.png differ diff --git a/res/drawable-ldrtl-xhdpi/msg_bubble_outgoing.9.png b/res/drawable-ldrtl-xhdpi/msg_bubble_outgoing.9.png new file mode 100644 index 0000000..0661fdb Binary files /dev/null and b/res/drawable-ldrtl-xhdpi/msg_bubble_outgoing.9.png differ diff --git a/res/drawable-ldrtl-xhdpi/sym_keyboard_delete_holo_dark.png b/res/drawable-ldrtl-xhdpi/sym_keyboard_delete_holo_dark.png new file mode 100644 index 0000000..a662fc2 Binary files /dev/null and b/res/drawable-ldrtl-xhdpi/sym_keyboard_delete_holo_dark.png differ diff --git a/res/drawable-ldrtl-xxhdpi/ic_keyboard_arrow_left_light.png b/res/drawable-ldrtl-xxhdpi/ic_keyboard_arrow_left_light.png new file mode 100644 index 0000000..62ca382 Binary files /dev/null and b/res/drawable-ldrtl-xxhdpi/ic_keyboard_arrow_left_light.png differ diff --git a/res/drawable-ldrtl-xxhdpi/ic_keyboard_arrow_right_light.png b/res/drawable-ldrtl-xxhdpi/ic_keyboard_arrow_right_light.png new file mode 100644 index 0000000..09536ed Binary files /dev/null and b/res/drawable-ldrtl-xxhdpi/ic_keyboard_arrow_right_light.png differ diff --git a/res/drawable-ldrtl-xxhdpi/ic_send_light.png b/res/drawable-ldrtl-xxhdpi/ic_send_light.png new file mode 100644 index 0000000..74ffc46 Binary files /dev/null and b/res/drawable-ldrtl-xxhdpi/ic_send_light.png differ diff --git a/res/drawable-ldrtl-xxhdpi/msg_bubble_incoming.9.png b/res/drawable-ldrtl-xxhdpi/msg_bubble_incoming.9.png new file mode 100644 index 0000000..5d8fc74 Binary files /dev/null and b/res/drawable-ldrtl-xxhdpi/msg_bubble_incoming.9.png differ diff --git a/res/drawable-ldrtl-xxhdpi/msg_bubble_input.9.png b/res/drawable-ldrtl-xxhdpi/msg_bubble_input.9.png new file mode 100644 index 0000000..6d59b8f Binary files /dev/null and b/res/drawable-ldrtl-xxhdpi/msg_bubble_input.9.png differ diff --git a/res/drawable-ldrtl-xxhdpi/msg_bubble_outgoing.9.png b/res/drawable-ldrtl-xxhdpi/msg_bubble_outgoing.9.png new file mode 100644 index 0000000..009c721 Binary files /dev/null and b/res/drawable-ldrtl-xxhdpi/msg_bubble_outgoing.9.png differ diff --git a/res/drawable-ldrtl-xxhdpi/sym_keyboard_delete_holo_dark.png b/res/drawable-ldrtl-xxhdpi/sym_keyboard_delete_holo_dark.png new file mode 100644 index 0000000..273efd6 Binary files /dev/null and b/res/drawable-ldrtl-xxhdpi/sym_keyboard_delete_holo_dark.png differ diff --git a/res/drawable-ldrtl-xxxhdpi/ic_keyboard_arrow_left_light.png b/res/drawable-ldrtl-xxxhdpi/ic_keyboard_arrow_left_light.png new file mode 100644 index 0000000..85d005b Binary files /dev/null and b/res/drawable-ldrtl-xxxhdpi/ic_keyboard_arrow_left_light.png differ diff --git a/res/drawable-ldrtl-xxxhdpi/ic_keyboard_arrow_right_light.png b/res/drawable-ldrtl-xxxhdpi/ic_keyboard_arrow_right_light.png new file mode 100644 index 0000000..3737fec Binary files /dev/null and b/res/drawable-ldrtl-xxxhdpi/ic_keyboard_arrow_right_light.png differ diff --git a/res/drawable-ldrtl-xxxhdpi/ic_send_light.png b/res/drawable-ldrtl-xxxhdpi/ic_send_light.png new file mode 100644 index 0000000..cd8e266 Binary files /dev/null and b/res/drawable-ldrtl-xxxhdpi/ic_send_light.png differ diff --git a/res/drawable-ldrtl-xxxhdpi/msg_bubble_incoming.9.png b/res/drawable-ldrtl-xxxhdpi/msg_bubble_incoming.9.png new file mode 100644 index 0000000..8378db5 Binary files /dev/null and b/res/drawable-ldrtl-xxxhdpi/msg_bubble_incoming.9.png differ diff --git a/res/drawable-ldrtl-xxxhdpi/msg_bubble_input.9.png b/res/drawable-ldrtl-xxxhdpi/msg_bubble_input.9.png new file mode 100644 index 0000000..a707db0 Binary files /dev/null and b/res/drawable-ldrtl-xxxhdpi/msg_bubble_input.9.png differ diff --git a/res/drawable-ldrtl-xxxhdpi/msg_bubble_outgoing.9.png b/res/drawable-ldrtl-xxxhdpi/msg_bubble_outgoing.9.png new file mode 100644 index 0000000..f0d0d9e Binary files /dev/null and b/res/drawable-ldrtl-xxxhdpi/msg_bubble_outgoing.9.png differ diff --git a/res/drawable-ldrtl-xxxhdpi/sym_keyboard_delete_holo_dark.png b/res/drawable-ldrtl-xxxhdpi/sym_keyboard_delete_holo_dark.png new file mode 100644 index 0000000..9203260 Binary files /dev/null and b/res/drawable-ldrtl-xxxhdpi/sym_keyboard_delete_holo_dark.png differ diff --git a/res/drawable-mdpi/btn_keyboard_key_dark_normal_holo.9.png b/res/drawable-mdpi/btn_keyboard_key_dark_normal_holo.9.png new file mode 100644 index 0000000..577ab08 Binary files /dev/null and b/res/drawable-mdpi/btn_keyboard_key_dark_normal_holo.9.png differ diff --git a/res/drawable-mdpi/btn_keyboard_key_dark_pressed_holo.9.png b/res/drawable-mdpi/btn_keyboard_key_dark_pressed_holo.9.png new file mode 100644 index 0000000..d8525e4 Binary files /dev/null and b/res/drawable-mdpi/btn_keyboard_key_dark_pressed_holo.9.png differ diff --git a/res/drawable-mdpi/btn_keyboard_key_light_normal_holo.9.png b/res/drawable-mdpi/btn_keyboard_key_light_normal_holo.9.png new file mode 100644 index 0000000..1b029e1 Binary files /dev/null and b/res/drawable-mdpi/btn_keyboard_key_light_normal_holo.9.png differ diff --git a/res/drawable-mdpi/btn_keyboard_key_light_pressed_holo.9.png b/res/drawable-mdpi/btn_keyboard_key_light_pressed_holo.9.png new file mode 100644 index 0000000..4fa69f8 Binary files /dev/null and b/res/drawable-mdpi/btn_keyboard_key_light_pressed_holo.9.png differ diff --git a/res/drawable-mdpi/cab_bg.9.png b/res/drawable-mdpi/cab_bg.9.png new file mode 100644 index 0000000..f43a85b Binary files /dev/null and b/res/drawable-mdpi/cab_bg.9.png differ diff --git a/res/drawable-mdpi/contact_popup_background.9.png b/res/drawable-mdpi/contact_popup_background.9.png new file mode 100644 index 0000000..2989590 Binary files /dev/null and b/res/drawable-mdpi/contact_popup_background.9.png differ diff --git a/res/drawable-mdpi/fab_new_message_static_shadow.png b/res/drawable-mdpi/fab_new_message_static_shadow.png new file mode 100644 index 0000000..2e24f0a Binary files /dev/null and b/res/drawable-mdpi/fab_new_message_static_shadow.png differ diff --git a/res/drawable-mdpi/ic_add_gray.png b/res/drawable-mdpi/ic_add_gray.png new file mode 100644 index 0000000..806b6e1 Binary files /dev/null and b/res/drawable-mdpi/ic_add_gray.png differ diff --git a/res/drawable-mdpi/ic_add_white.png b/res/drawable-mdpi/ic_add_white.png new file mode 100644 index 0000000..4a14229 Binary files /dev/null and b/res/drawable-mdpi/ic_add_white.png differ diff --git a/res/drawable-mdpi/ic_archive_small_dark.png b/res/drawable-mdpi/ic_archive_small_dark.png new file mode 100644 index 0000000..a733f56 Binary files /dev/null and b/res/drawable-mdpi/ic_archive_small_dark.png differ diff --git a/res/drawable-mdpi/ic_archive_small_light.png b/res/drawable-mdpi/ic_archive_small_light.png new file mode 100644 index 0000000..fa3ffa9 Binary files /dev/null and b/res/drawable-mdpi/ic_archive_small_light.png differ diff --git a/res/drawable-mdpi/ic_archive_undo_small_dark.png b/res/drawable-mdpi/ic_archive_undo_small_dark.png new file mode 100644 index 0000000..bc9dead Binary files /dev/null and b/res/drawable-mdpi/ic_archive_undo_small_dark.png differ diff --git a/res/drawable-mdpi/ic_archive_undo_small_light.png b/res/drawable-mdpi/ic_archive_undo_small_light.png new file mode 100644 index 0000000..08b2332 Binary files /dev/null and b/res/drawable-mdpi/ic_archive_undo_small_light.png differ diff --git a/res/drawable-mdpi/ic_arrow_back_dark.png b/res/drawable-mdpi/ic_arrow_back_dark.png new file mode 100644 index 0000000..75ae1b1 Binary files /dev/null and b/res/drawable-mdpi/ic_arrow_back_dark.png differ diff --git a/res/drawable-mdpi/ic_arrow_back_light.png b/res/drawable-mdpi/ic_arrow_back_light.png new file mode 100644 index 0000000..c59a517 Binary files /dev/null and b/res/drawable-mdpi/ic_arrow_back_light.png differ diff --git a/res/drawable-mdpi/ic_attachment_dark.png b/res/drawable-mdpi/ic_attachment_dark.png new file mode 100644 index 0000000..2315e23 Binary files /dev/null and b/res/drawable-mdpi/ic_attachment_dark.png differ diff --git a/res/drawable-mdpi/ic_audio_light.png b/res/drawable-mdpi/ic_audio_light.png new file mode 100644 index 0000000..78bc072 Binary files /dev/null and b/res/drawable-mdpi/ic_audio_light.png differ diff --git a/res/drawable-mdpi/ic_audio_pause.png b/res/drawable-mdpi/ic_audio_pause.png new file mode 100644 index 0000000..e74d158 Binary files /dev/null and b/res/drawable-mdpi/ic_audio_pause.png differ diff --git a/res/drawable-mdpi/ic_audio_play.png b/res/drawable-mdpi/ic_audio_play.png new file mode 100644 index 0000000..0d986a4 Binary files /dev/null and b/res/drawable-mdpi/ic_audio_play.png differ diff --git a/res/drawable-mdpi/ic_camera_front_light.png b/res/drawable-mdpi/ic_camera_front_light.png new file mode 100644 index 0000000..a537897 Binary files /dev/null and b/res/drawable-mdpi/ic_camera_front_light.png differ diff --git a/res/drawable-mdpi/ic_camera_light.png b/res/drawable-mdpi/ic_camera_light.png new file mode 100644 index 0000000..429bef4 Binary files /dev/null and b/res/drawable-mdpi/ic_camera_light.png differ diff --git a/res/drawable-mdpi/ic_camera_rear_light.png b/res/drawable-mdpi/ic_camera_rear_light.png new file mode 100644 index 0000000..4da3328 Binary files /dev/null and b/res/drawable-mdpi/ic_camera_rear_light.png differ diff --git a/res/drawable-mdpi/ic_cancel_small_dark.png b/res/drawable-mdpi/ic_cancel_small_dark.png new file mode 100644 index 0000000..f63f351 Binary files /dev/null and b/res/drawable-mdpi/ic_cancel_small_dark.png differ diff --git a/res/drawable-mdpi/ic_cancel_small_light.png b/res/drawable-mdpi/ic_cancel_small_light.png new file mode 100644 index 0000000..33fdf10 Binary files /dev/null and b/res/drawable-mdpi/ic_cancel_small_light.png differ diff --git a/res/drawable-mdpi/ic_checkbox_blank_light.png b/res/drawable-mdpi/ic_checkbox_blank_light.png new file mode 100644 index 0000000..baec804 Binary files /dev/null and b/res/drawable-mdpi/ic_checkbox_blank_light.png differ diff --git a/res/drawable-mdpi/ic_checkbox_light.png b/res/drawable-mdpi/ic_checkbox_light.png new file mode 100644 index 0000000..3762dc4 Binary files /dev/null and b/res/drawable-mdpi/ic_checkbox_light.png differ diff --git a/res/drawable-mdpi/ic_checkbox_outline_light.png b/res/drawable-mdpi/ic_checkbox_outline_light.png new file mode 100644 index 0000000..4c3c722 Binary files /dev/null and b/res/drawable-mdpi/ic_checkbox_outline_light.png differ diff --git a/res/drawable-mdpi/ic_checkmark_circle_blue.png b/res/drawable-mdpi/ic_checkmark_circle_blue.png new file mode 100644 index 0000000..85735be Binary files /dev/null and b/res/drawable-mdpi/ic_checkmark_circle_blue.png differ diff --git a/res/drawable-mdpi/ic_checkmark_large.png b/res/drawable-mdpi/ic_checkmark_large.png new file mode 100644 index 0000000..95206d1 Binary files /dev/null and b/res/drawable-mdpi/ic_checkmark_large.png differ diff --git a/res/drawable-mdpi/ic_checkmark_large_light.png b/res/drawable-mdpi/ic_checkmark_large_light.png new file mode 100644 index 0000000..4c33ce6 Binary files /dev/null and b/res/drawable-mdpi/ic_checkmark_large_light.png differ diff --git a/res/drawable-mdpi/ic_checkmark_small_blue.png b/res/drawable-mdpi/ic_checkmark_small_blue.png new file mode 100644 index 0000000..ec9dcb1 Binary files /dev/null and b/res/drawable-mdpi/ic_checkmark_small_blue.png differ diff --git a/res/drawable-mdpi/ic_checkmark_small_light.png b/res/drawable-mdpi/ic_checkmark_small_light.png new file mode 100644 index 0000000..414fe8f Binary files /dev/null and b/res/drawable-mdpi/ic_checkmark_small_light.png differ diff --git a/res/drawable-mdpi/ic_color_lens.png b/res/drawable-mdpi/ic_color_lens.png new file mode 100644 index 0000000..b33c8b6 Binary files /dev/null and b/res/drawable-mdpi/ic_color_lens.png differ diff --git a/res/drawable-mdpi/ic_content_copy_dark.png b/res/drawable-mdpi/ic_content_copy_dark.png new file mode 100644 index 0000000..4b99a83 Binary files /dev/null and b/res/drawable-mdpi/ic_content_copy_dark.png differ diff --git a/res/drawable-mdpi/ic_crying_hero.png b/res/drawable-mdpi/ic_crying_hero.png new file mode 100644 index 0000000..d2f5eef Binary files /dev/null and b/res/drawable-mdpi/ic_crying_hero.png differ diff --git a/res/drawable-mdpi/ic_delete_small_dark.png b/res/drawable-mdpi/ic_delete_small_dark.png new file mode 100644 index 0000000..86c0002 Binary files /dev/null and b/res/drawable-mdpi/ic_delete_small_dark.png differ diff --git a/res/drawable-mdpi/ic_delete_small_light.png b/res/drawable-mdpi/ic_delete_small_light.png new file mode 100644 index 0000000..641adc4 Binary files /dev/null and b/res/drawable-mdpi/ic_delete_small_light.png differ diff --git a/res/drawable-mdpi/ic_dnd_on_dark.png b/res/drawable-mdpi/ic_dnd_on_dark.png new file mode 100644 index 0000000..4e911a9 Binary files /dev/null and b/res/drawable-mdpi/ic_dnd_on_dark.png differ diff --git a/res/drawable-mdpi/ic_dnd_on_light.png b/res/drawable-mdpi/ic_dnd_on_light.png new file mode 100644 index 0000000..4936c2f Binary files /dev/null and b/res/drawable-mdpi/ic_dnd_on_light.png differ diff --git a/res/drawable-mdpi/ic_failed_light.png b/res/drawable-mdpi/ic_failed_light.png new file mode 100644 index 0000000..02a4d0b Binary files /dev/null and b/res/drawable-mdpi/ic_failed_light.png differ diff --git a/res/drawable-mdpi/ic_failed_status_red.png b/res/drawable-mdpi/ic_failed_status_red.png new file mode 100644 index 0000000..f6feafe Binary files /dev/null and b/res/drawable-mdpi/ic_failed_status_red.png differ diff --git a/res/drawable-mdpi/ic_file_download_dark.png b/res/drawable-mdpi/ic_file_download_dark.png new file mode 100644 index 0000000..8104657 Binary files /dev/null and b/res/drawable-mdpi/ic_file_download_dark.png differ diff --git a/res/drawable-mdpi/ic_file_download_light.png b/res/drawable-mdpi/ic_file_download_light.png new file mode 100644 index 0000000..d066170 Binary files /dev/null and b/res/drawable-mdpi/ic_file_download_light.png differ diff --git a/res/drawable-mdpi/ic_forward_dark.png b/res/drawable-mdpi/ic_forward_dark.png new file mode 100644 index 0000000..7930393 Binary files /dev/null and b/res/drawable-mdpi/ic_forward_dark.png differ diff --git a/res/drawable-mdpi/ic_history_light.png b/res/drawable-mdpi/ic_history_light.png new file mode 100644 index 0000000..234b2ce Binary files /dev/null and b/res/drawable-mdpi/ic_history_light.png differ diff --git a/res/drawable-mdpi/ic_image_light.png b/res/drawable-mdpi/ic_image_light.png new file mode 100644 index 0000000..3bd919e Binary files /dev/null and b/res/drawable-mdpi/ic_image_light.png differ diff --git a/res/drawable-mdpi/ic_ime_dark.png b/res/drawable-mdpi/ic_ime_dark.png new file mode 100644 index 0000000..8c0a156 Binary files /dev/null and b/res/drawable-mdpi/ic_ime_dark.png differ diff --git a/res/drawable-mdpi/ic_ime_light.png b/res/drawable-mdpi/ic_ime_light.png new file mode 100644 index 0000000..fd58cb5 Binary files /dev/null and b/res/drawable-mdpi/ic_ime_light.png differ diff --git a/res/drawable-mdpi/ic_info_dark.png b/res/drawable-mdpi/ic_info_dark.png new file mode 100644 index 0000000..67457a3 Binary files /dev/null and b/res/drawable-mdpi/ic_info_dark.png differ diff --git a/res/drawable-mdpi/ic_keyboard_arrow_left_light.png b/res/drawable-mdpi/ic_keyboard_arrow_left_light.png new file mode 100644 index 0000000..6004f37 Binary files /dev/null and b/res/drawable-mdpi/ic_keyboard_arrow_left_light.png differ diff --git a/res/drawable-mdpi/ic_keyboard_arrow_right_light.png b/res/drawable-mdpi/ic_keyboard_arrow_right_light.png new file mode 100644 index 0000000..9bd625d Binary files /dev/null and b/res/drawable-mdpi/ic_keyboard_arrow_right_light.png differ diff --git a/res/drawable-mdpi/ic_launcher.png b/res/drawable-mdpi/ic_launcher.png new file mode 100644 index 0000000..a6d2f55 Binary files /dev/null and b/res/drawable-mdpi/ic_launcher.png differ diff --git a/res/drawable-mdpi/ic_mp_audio_mic.png b/res/drawable-mdpi/ic_mp_audio_mic.png new file mode 100644 index 0000000..918d799 Binary files /dev/null and b/res/drawable-mdpi/ic_mp_audio_mic.png differ diff --git a/res/drawable-mdpi/ic_mp_camera_large_light.png b/res/drawable-mdpi/ic_mp_camera_large_light.png new file mode 100644 index 0000000..3ec72f9 Binary files /dev/null and b/res/drawable-mdpi/ic_mp_camera_large_light.png differ diff --git a/res/drawable-mdpi/ic_mp_camera_small_light.png b/res/drawable-mdpi/ic_mp_camera_small_light.png new file mode 100644 index 0000000..fe1dc7e Binary files /dev/null and b/res/drawable-mdpi/ic_mp_camera_small_light.png differ diff --git a/res/drawable-mdpi/ic_mp_capture_stop_large_light.png b/res/drawable-mdpi/ic_mp_capture_stop_large_light.png new file mode 100644 index 0000000..a9705c1 Binary files /dev/null and b/res/drawable-mdpi/ic_mp_capture_stop_large_light.png differ diff --git a/res/drawable-mdpi/ic_mp_full_screen_light.png b/res/drawable-mdpi/ic_mp_full_screen_light.png new file mode 100644 index 0000000..5462c59 Binary files /dev/null and b/res/drawable-mdpi/ic_mp_full_screen_light.png differ diff --git a/res/drawable-mdpi/ic_mp_video_large_light.png b/res/drawable-mdpi/ic_mp_video_large_light.png new file mode 100644 index 0000000..fa8e3e2 Binary files /dev/null and b/res/drawable-mdpi/ic_mp_video_large_light.png differ diff --git a/res/drawable-mdpi/ic_mp_video_small_light.png b/res/drawable-mdpi/ic_mp_video_small_light.png new file mode 100644 index 0000000..9204827 Binary files /dev/null and b/res/drawable-mdpi/ic_mp_video_small_light.png differ diff --git a/res/drawable-mdpi/ic_notifications_off_dark.png b/res/drawable-mdpi/ic_notifications_off_dark.png new file mode 100644 index 0000000..4a5a7e9 Binary files /dev/null and b/res/drawable-mdpi/ic_notifications_off_dark.png differ diff --git a/res/drawable-mdpi/ic_notifications_off_light.png b/res/drawable-mdpi/ic_notifications_off_light.png new file mode 100644 index 0000000..33e0b4a Binary files /dev/null and b/res/drawable-mdpi/ic_notifications_off_light.png differ diff --git a/res/drawable-mdpi/ic_notifications_off_small_light.png b/res/drawable-mdpi/ic_notifications_off_small_light.png new file mode 100644 index 0000000..4bd9563 Binary files /dev/null and b/res/drawable-mdpi/ic_notifications_off_small_light.png differ diff --git a/res/drawable-mdpi/ic_notifications_on_dark.png b/res/drawable-mdpi/ic_notifications_on_dark.png new file mode 100644 index 0000000..77a2a7d Binary files /dev/null and b/res/drawable-mdpi/ic_notifications_on_dark.png differ diff --git a/res/drawable-mdpi/ic_notifications_on_light.png b/res/drawable-mdpi/ic_notifications_on_light.png new file mode 100644 index 0000000..eae03a5 Binary files /dev/null and b/res/drawable-mdpi/ic_notifications_on_light.png differ diff --git a/res/drawable-mdpi/ic_numeric_dialpad.png b/res/drawable-mdpi/ic_numeric_dialpad.png new file mode 100644 index 0000000..5802167 Binary files /dev/null and b/res/drawable-mdpi/ic_numeric_dialpad.png differ diff --git a/res/drawable-mdpi/ic_oobe_conv_list.png b/res/drawable-mdpi/ic_oobe_conv_list.png new file mode 100644 index 0000000..1648f2e Binary files /dev/null and b/res/drawable-mdpi/ic_oobe_conv_list.png differ diff --git a/res/drawable-mdpi/ic_oobe_freq_list.png b/res/drawable-mdpi/ic_oobe_freq_list.png new file mode 100644 index 0000000..8d7d47f Binary files /dev/null and b/res/drawable-mdpi/ic_oobe_freq_list.png differ diff --git a/res/drawable-mdpi/ic_open_in_new.png b/res/drawable-mdpi/ic_open_in_new.png new file mode 100644 index 0000000..44ef9d5 Binary files /dev/null and b/res/drawable-mdpi/ic_open_in_new.png differ diff --git a/res/drawable-mdpi/ic_people_add_light.png b/res/drawable-mdpi/ic_people_add_light.png new file mode 100644 index 0000000..4bc974f Binary files /dev/null and b/res/drawable-mdpi/ic_people_add_light.png differ diff --git a/res/drawable-mdpi/ic_person_add_blue.png b/res/drawable-mdpi/ic_person_add_blue.png new file mode 100644 index 0000000..29f9066 Binary files /dev/null and b/res/drawable-mdpi/ic_person_add_blue.png differ diff --git a/res/drawable-mdpi/ic_person_add_dark.png b/res/drawable-mdpi/ic_person_add_dark.png new file mode 100644 index 0000000..6f1fd49 Binary files /dev/null and b/res/drawable-mdpi/ic_person_add_dark.png differ diff --git a/res/drawable-mdpi/ic_person_add_light.png b/res/drawable-mdpi/ic_person_add_light.png new file mode 100644 index 0000000..ba83709 Binary files /dev/null and b/res/drawable-mdpi/ic_person_add_light.png differ diff --git a/res/drawable-mdpi/ic_person_light.png b/res/drawable-mdpi/ic_person_light.png new file mode 100644 index 0000000..5698835 Binary files /dev/null and b/res/drawable-mdpi/ic_person_light.png differ diff --git a/res/drawable-mdpi/ic_person_light_large.png b/res/drawable-mdpi/ic_person_light_large.png new file mode 100644 index 0000000..7aa150b Binary files /dev/null and b/res/drawable-mdpi/ic_person_light_large.png differ diff --git a/res/drawable-mdpi/ic_phone_small_light.png b/res/drawable-mdpi/ic_phone_small_light.png new file mode 100644 index 0000000..dfcbba0 Binary files /dev/null and b/res/drawable-mdpi/ic_phone_small_light.png differ diff --git a/res/drawable-mdpi/ic_photo_library_light.png b/res/drawable-mdpi/ic_photo_library_light.png new file mode 100644 index 0000000..322d486 Binary files /dev/null and b/res/drawable-mdpi/ic_photo_library_light.png differ diff --git a/res/drawable-mdpi/ic_preview_pause.png b/res/drawable-mdpi/ic_preview_pause.png new file mode 100644 index 0000000..14046e2 Binary files /dev/null and b/res/drawable-mdpi/ic_preview_pause.png differ diff --git a/res/drawable-mdpi/ic_preview_play.png b/res/drawable-mdpi/ic_preview_play.png new file mode 100644 index 0000000..8ad0b02 Binary files /dev/null and b/res/drawable-mdpi/ic_preview_play.png differ diff --git a/res/drawable-mdpi/ic_remove_light.png b/res/drawable-mdpi/ic_remove_light.png new file mode 100644 index 0000000..0464885 Binary files /dev/null and b/res/drawable-mdpi/ic_remove_light.png differ diff --git a/res/drawable-mdpi/ic_remove_small_light.png b/res/drawable-mdpi/ic_remove_small_light.png new file mode 100644 index 0000000..2131b6e Binary files /dev/null and b/res/drawable-mdpi/ic_remove_small_light.png differ diff --git a/res/drawable-mdpi/ic_save_dark.png b/res/drawable-mdpi/ic_save_dark.png new file mode 100644 index 0000000..f9b0de5 Binary files /dev/null and b/res/drawable-mdpi/ic_save_dark.png differ diff --git a/res/drawable-mdpi/ic_save_light.png b/res/drawable-mdpi/ic_save_light.png new file mode 100644 index 0000000..4faf5d5 Binary files /dev/null and b/res/drawable-mdpi/ic_save_light.png differ diff --git a/res/drawable-mdpi/ic_search_light.png b/res/drawable-mdpi/ic_search_light.png new file mode 100644 index 0000000..b7e1de0 Binary files /dev/null and b/res/drawable-mdpi/ic_search_light.png differ diff --git a/res/drawable-mdpi/ic_send_dark.png b/res/drawable-mdpi/ic_send_dark.png new file mode 100644 index 0000000..965a9f0 Binary files /dev/null and b/res/drawable-mdpi/ic_send_dark.png differ diff --git a/res/drawable-mdpi/ic_send_light.png b/res/drawable-mdpi/ic_send_light.png new file mode 100644 index 0000000..46d2dcd Binary files /dev/null and b/res/drawable-mdpi/ic_send_light.png differ diff --git a/res/drawable-mdpi/ic_share_dark.png b/res/drawable-mdpi/ic_share_dark.png new file mode 100644 index 0000000..c6ba4c9 Binary files /dev/null and b/res/drawable-mdpi/ic_share_dark.png differ diff --git a/res/drawable-mdpi/ic_share_light.png b/res/drawable-mdpi/ic_share_light.png new file mode 100644 index 0000000..362c862 Binary files /dev/null and b/res/drawable-mdpi/ic_share_light.png differ diff --git a/res/drawable-mdpi/ic_sim_card_send.png b/res/drawable-mdpi/ic_sim_card_send.png new file mode 100644 index 0000000..1f26017 Binary files /dev/null and b/res/drawable-mdpi/ic_sim_card_send.png differ diff --git a/res/drawable-mdpi/ic_sms_delivery_ok.png b/res/drawable-mdpi/ic_sms_delivery_ok.png new file mode 100644 index 0000000..ffccf64 Binary files /dev/null and b/res/drawable-mdpi/ic_sms_delivery_ok.png differ diff --git a/res/drawable-mdpi/ic_sms_failed_light.png b/res/drawable-mdpi/ic_sms_failed_light.png new file mode 100644 index 0000000..9914e3f Binary files /dev/null and b/res/drawable-mdpi/ic_sms_failed_light.png differ diff --git a/res/drawable-mdpi/ic_sms_light.png b/res/drawable-mdpi/ic_sms_light.png new file mode 100644 index 0000000..518c989 Binary files /dev/null and b/res/drawable-mdpi/ic_sms_light.png differ diff --git a/res/drawable-mdpi/ic_sms_multi_light.png b/res/drawable-mdpi/ic_sms_multi_light.png new file mode 100644 index 0000000..bd6f4c6 Binary files /dev/null and b/res/drawable-mdpi/ic_sms_multi_light.png differ diff --git a/res/drawable-mdpi/ic_tooltip_arrow.png b/res/drawable-mdpi/ic_tooltip_arrow.png new file mode 100644 index 0000000..51824f5 Binary files /dev/null and b/res/drawable-mdpi/ic_tooltip_arrow.png differ diff --git a/res/drawable-mdpi/ic_video_play_light.png b/res/drawable-mdpi/ic_video_play_light.png new file mode 100644 index 0000000..e0ac1e4 Binary files /dev/null and b/res/drawable-mdpi/ic_video_play_light.png differ diff --git a/res/drawable-mdpi/ic_wear_reply.png b/res/drawable-mdpi/ic_wear_reply.png new file mode 100644 index 0000000..47bb396 Binary files /dev/null and b/res/drawable-mdpi/ic_wear_reply.png differ diff --git a/res/drawable-mdpi/ic_widget_avatar_shadow.png b/res/drawable-mdpi/ic_widget_avatar_shadow.png new file mode 100644 index 0000000..1a8dc8e Binary files /dev/null and b/res/drawable-mdpi/ic_widget_avatar_shadow.png differ diff --git a/res/drawable-mdpi/ic_widget_list.png b/res/drawable-mdpi/ic_widget_list.png new file mode 100644 index 0000000..9bf4b11 Binary files /dev/null and b/res/drawable-mdpi/ic_widget_list.png differ diff --git a/res/drawable-mdpi/msg_bubble_error.9.png b/res/drawable-mdpi/msg_bubble_error.9.png new file mode 100644 index 0000000..694d117 Binary files /dev/null and b/res/drawable-mdpi/msg_bubble_error.9.png differ diff --git a/res/drawable-mdpi/msg_bubble_incoming.9.png b/res/drawable-mdpi/msg_bubble_incoming.9.png new file mode 100644 index 0000000..4e7e464 Binary files /dev/null and b/res/drawable-mdpi/msg_bubble_incoming.9.png differ diff --git a/res/drawable-mdpi/msg_bubble_input.9.png b/res/drawable-mdpi/msg_bubble_input.9.png new file mode 100644 index 0000000..1810ffb Binary files /dev/null and b/res/drawable-mdpi/msg_bubble_input.9.png differ diff --git a/res/drawable-mdpi/msg_bubble_outgoing.9.png b/res/drawable-mdpi/msg_bubble_outgoing.9.png new file mode 100644 index 0000000..5ca9b14 Binary files /dev/null and b/res/drawable-mdpi/msg_bubble_outgoing.9.png differ diff --git a/res/drawable-mdpi/permissions.png b/res/drawable-mdpi/permissions.png new file mode 100644 index 0000000..2d754ec Binary files /dev/null and b/res/drawable-mdpi/permissions.png differ diff --git a/res/drawable-mdpi/swipe_shadow.9.png b/res/drawable-mdpi/swipe_shadow.9.png new file mode 100644 index 0000000..e2ed6d4 Binary files /dev/null and b/res/drawable-mdpi/swipe_shadow.9.png differ diff --git a/res/drawable-mdpi/swipe_shadow_drag.9.png b/res/drawable-mdpi/swipe_shadow_drag.9.png new file mode 100644 index 0000000..94be3f9 Binary files /dev/null and b/res/drawable-mdpi/swipe_shadow_drag.9.png differ diff --git a/res/drawable-mdpi/sym_keyboard_delete_holo.png b/res/drawable-mdpi/sym_keyboard_delete_holo.png new file mode 100644 index 0000000..7b3103b Binary files /dev/null and b/res/drawable-mdpi/sym_keyboard_delete_holo.png differ diff --git a/res/drawable-mdpi/tab_btn_bg_normal.9.png b/res/drawable-mdpi/tab_btn_bg_normal.9.png new file mode 100644 index 0000000..d56cd94 Binary files /dev/null and b/res/drawable-mdpi/tab_btn_bg_normal.9.png differ diff --git a/res/drawable-mdpi/tab_btn_bg_pressed.9.png b/res/drawable-mdpi/tab_btn_bg_pressed.9.png new file mode 100644 index 0000000..4d4c932 Binary files /dev/null and b/res/drawable-mdpi/tab_btn_bg_pressed.9.png differ diff --git a/res/drawable-mdpi/tab_selected.9.png b/res/drawable-mdpi/tab_selected.9.png new file mode 100644 index 0000000..4b00f35 Binary files /dev/null and b/res/drawable-mdpi/tab_selected.9.png differ diff --git a/res/drawable-mdpi/tab_selected_focused_holo.9.png b/res/drawable-mdpi/tab_selected_focused_holo.9.png new file mode 100644 index 0000000..c77808d Binary files /dev/null and b/res/drawable-mdpi/tab_selected_focused_holo.9.png differ diff --git a/res/drawable-mdpi/tab_selected_pressed_focused_holo.9.png b/res/drawable-mdpi/tab_selected_pressed_focused_holo.9.png new file mode 100644 index 0000000..1e28bb6 Binary files /dev/null and b/res/drawable-mdpi/tab_selected_pressed_focused_holo.9.png differ diff --git a/res/drawable-mdpi/tab_selected_pressed_holo.9.png b/res/drawable-mdpi/tab_selected_pressed_holo.9.png new file mode 100644 index 0000000..098bf7d Binary files /dev/null and b/res/drawable-mdpi/tab_selected_pressed_holo.9.png differ diff --git a/res/drawable-mdpi/tab_unselected.9.png b/res/drawable-mdpi/tab_unselected.9.png new file mode 100644 index 0000000..bb45ab9 Binary files /dev/null and b/res/drawable-mdpi/tab_unselected.9.png differ diff --git a/res/drawable-mdpi/tab_unselected_focused_holo.9.png b/res/drawable-mdpi/tab_unselected_focused_holo.9.png new file mode 100644 index 0000000..e00e256 Binary files /dev/null and b/res/drawable-mdpi/tab_unselected_focused_holo.9.png differ diff --git a/res/drawable-mdpi/tab_unselected_pressed_focused_holo.9.png b/res/drawable-mdpi/tab_unselected_pressed_focused_holo.9.png new file mode 100644 index 0000000..80e8f0c Binary files /dev/null and b/res/drawable-mdpi/tab_unselected_pressed_focused_holo.9.png differ diff --git a/res/drawable-mdpi/tab_unselected_pressed_holo.9.png b/res/drawable-mdpi/tab_unselected_pressed_holo.9.png new file mode 100644 index 0000000..eb9422e Binary files /dev/null and b/res/drawable-mdpi/tab_unselected_pressed_holo.9.png differ diff --git a/res/drawable-mdpi/widget_hr.9.png b/res/drawable-mdpi/widget_hr.9.png new file mode 100644 index 0000000..93f3417 Binary files /dev/null and b/res/drawable-mdpi/widget_hr.9.png differ diff --git a/res/drawable-mdpi/widget_msg_bubble_incoming.9.png b/res/drawable-mdpi/widget_msg_bubble_incoming.9.png new file mode 100644 index 0000000..ddadf73 Binary files /dev/null and b/res/drawable-mdpi/widget_msg_bubble_incoming.9.png differ diff --git a/res/drawable-mdpi/widget_msg_bubble_outgoing.9.png b/res/drawable-mdpi/widget_msg_bubble_outgoing.9.png new file mode 100644 index 0000000..00432c3 Binary files /dev/null and b/res/drawable-mdpi/widget_msg_bubble_outgoing.9.png differ diff --git a/res/drawable-nodpi/bg_sms.jpg b/res/drawable-nodpi/bg_sms.jpg new file mode 100644 index 0000000..d8711e7 Binary files /dev/null and b/res/drawable-nodpi/bg_sms.jpg differ diff --git a/res/drawable-nodpi/ic_person_wear.png b/res/drawable-nodpi/ic_person_wear.png new file mode 100644 index 0000000..97be017 Binary files /dev/null and b/res/drawable-nodpi/ic_person_wear.png differ diff --git a/res/drawable-nodpi/messenger_widget_conversation_preview.png b/res/drawable-nodpi/messenger_widget_conversation_preview.png new file mode 100644 index 0000000..e26ae97 Binary files /dev/null and b/res/drawable-nodpi/messenger_widget_conversation_preview.png differ diff --git a/res/drawable-nodpi/messenger_widget_preview.png b/res/drawable-nodpi/messenger_widget_preview.png new file mode 100644 index 0000000..a5d40b2 Binary files /dev/null and b/res/drawable-nodpi/messenger_widget_preview.png differ diff --git a/res/drawable-v21/contact_picker_tab_background_selector.xml b/res/drawable-v21/contact_picker_tab_background_selector.xml new file mode 100644 index 0000000..59bdf95 --- /dev/null +++ b/res/drawable-v21/contact_picker_tab_background_selector.xml @@ -0,0 +1,18 @@ + + + \ No newline at end of file diff --git a/res/drawable-v21/fab_new_message_bg.xml b/res/drawable-v21/fab_new_message_bg.xml new file mode 100644 index 0000000..8043f0f --- /dev/null +++ b/res/drawable-v21/fab_new_message_bg.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/res/drawable-v21/gallery_image_background_selector.xml b/res/drawable-v21/gallery_image_background_selector.xml new file mode 100644 index 0000000..7486fad --- /dev/null +++ b/res/drawable-v21/gallery_image_background_selector.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/res/drawable-v21/mediapicker_tab_button_background.xml b/res/drawable-v21/mediapicker_tab_button_background.xml new file mode 100644 index 0000000..10c4474 --- /dev/null +++ b/res/drawable-v21/mediapicker_tab_button_background.xml @@ -0,0 +1,18 @@ + + + diff --git a/res/drawable-v21/transparent_button_background.xml b/res/drawable-v21/transparent_button_background.xml new file mode 100644 index 0000000..10c4474 --- /dev/null +++ b/res/drawable-v21/transparent_button_background.xml @@ -0,0 +1,18 @@ + + + diff --git a/res/drawable-v21/widget_item_background.xml b/res/drawable-v21/widget_item_background.xml new file mode 100644 index 0000000..eb20c06 --- /dev/null +++ b/res/drawable-v21/widget_item_background.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/res/drawable-xhdpi/btn_keyboard_key_dark_normal_holo.9.png b/res/drawable-xhdpi/btn_keyboard_key_dark_normal_holo.9.png new file mode 100644 index 0000000..96eb10e Binary files /dev/null and b/res/drawable-xhdpi/btn_keyboard_key_dark_normal_holo.9.png differ diff --git a/res/drawable-xhdpi/btn_keyboard_key_dark_pressed_holo.9.png b/res/drawable-xhdpi/btn_keyboard_key_dark_pressed_holo.9.png new file mode 100644 index 0000000..10986ee Binary files /dev/null and b/res/drawable-xhdpi/btn_keyboard_key_dark_pressed_holo.9.png differ diff --git a/res/drawable-xhdpi/btn_keyboard_key_light_normal_holo.9.png b/res/drawable-xhdpi/btn_keyboard_key_light_normal_holo.9.png new file mode 100644 index 0000000..12f9a82 Binary files /dev/null and b/res/drawable-xhdpi/btn_keyboard_key_light_normal_holo.9.png differ diff --git a/res/drawable-xhdpi/btn_keyboard_key_light_pressed_holo.9.png b/res/drawable-xhdpi/btn_keyboard_key_light_pressed_holo.9.png new file mode 100644 index 0000000..e7590b0 Binary files /dev/null and b/res/drawable-xhdpi/btn_keyboard_key_light_pressed_holo.9.png differ diff --git a/res/drawable-xhdpi/cab_bg.9.png b/res/drawable-xhdpi/cab_bg.9.png new file mode 100644 index 0000000..ba8c446 Binary files /dev/null and b/res/drawable-xhdpi/cab_bg.9.png differ diff --git a/res/drawable-xhdpi/contact_popup_background.9.png b/res/drawable-xhdpi/contact_popup_background.9.png new file mode 100644 index 0000000..f8b809a Binary files /dev/null and b/res/drawable-xhdpi/contact_popup_background.9.png differ diff --git a/res/drawable-xhdpi/fab_new_message_static_shadow.png b/res/drawable-xhdpi/fab_new_message_static_shadow.png new file mode 100644 index 0000000..7075cb9 Binary files /dev/null and b/res/drawable-xhdpi/fab_new_message_static_shadow.png differ diff --git a/res/drawable-xhdpi/ic_add_gray.png b/res/drawable-xhdpi/ic_add_gray.png new file mode 100644 index 0000000..8ce295d Binary files /dev/null and b/res/drawable-xhdpi/ic_add_gray.png differ diff --git a/res/drawable-xhdpi/ic_add_white.png b/res/drawable-xhdpi/ic_add_white.png new file mode 100644 index 0000000..7ef4fc1 Binary files /dev/null and b/res/drawable-xhdpi/ic_add_white.png differ diff --git a/res/drawable-xhdpi/ic_archive_small_dark.png b/res/drawable-xhdpi/ic_archive_small_dark.png new file mode 100644 index 0000000..ea31204 Binary files /dev/null and b/res/drawable-xhdpi/ic_archive_small_dark.png differ diff --git a/res/drawable-xhdpi/ic_archive_small_light.png b/res/drawable-xhdpi/ic_archive_small_light.png new file mode 100644 index 0000000..b3e628f Binary files /dev/null and b/res/drawable-xhdpi/ic_archive_small_light.png differ diff --git a/res/drawable-xhdpi/ic_archive_undo_small_dark.png b/res/drawable-xhdpi/ic_archive_undo_small_dark.png new file mode 100644 index 0000000..7508484 Binary files /dev/null and b/res/drawable-xhdpi/ic_archive_undo_small_dark.png differ diff --git a/res/drawable-xhdpi/ic_archive_undo_small_light.png b/res/drawable-xhdpi/ic_archive_undo_small_light.png new file mode 100644 index 0000000..b7038d8 Binary files /dev/null and b/res/drawable-xhdpi/ic_archive_undo_small_light.png differ diff --git a/res/drawable-xhdpi/ic_arrow_back_dark.png b/res/drawable-xhdpi/ic_arrow_back_dark.png new file mode 100644 index 0000000..c40b832 Binary files /dev/null and b/res/drawable-xhdpi/ic_arrow_back_dark.png differ diff --git a/res/drawable-xhdpi/ic_arrow_back_light.png b/res/drawable-xhdpi/ic_arrow_back_light.png new file mode 100644 index 0000000..fa3d493 Binary files /dev/null and b/res/drawable-xhdpi/ic_arrow_back_light.png differ diff --git a/res/drawable-xhdpi/ic_attachment_dark.png b/res/drawable-xhdpi/ic_attachment_dark.png new file mode 100644 index 0000000..61e565c Binary files /dev/null and b/res/drawable-xhdpi/ic_attachment_dark.png differ diff --git a/res/drawable-xhdpi/ic_audio_light.png b/res/drawable-xhdpi/ic_audio_light.png new file mode 100644 index 0000000..fab5ba4 Binary files /dev/null and b/res/drawable-xhdpi/ic_audio_light.png differ diff --git a/res/drawable-xhdpi/ic_audio_pause.png b/res/drawable-xhdpi/ic_audio_pause.png new file mode 100644 index 0000000..e33b655 Binary files /dev/null and b/res/drawable-xhdpi/ic_audio_pause.png differ diff --git a/res/drawable-xhdpi/ic_audio_play.png b/res/drawable-xhdpi/ic_audio_play.png new file mode 100644 index 0000000..ff93063 Binary files /dev/null and b/res/drawable-xhdpi/ic_audio_play.png differ diff --git a/res/drawable-xhdpi/ic_camera_front_light.png b/res/drawable-xhdpi/ic_camera_front_light.png new file mode 100644 index 0000000..9cd577d Binary files /dev/null and b/res/drawable-xhdpi/ic_camera_front_light.png differ diff --git a/res/drawable-xhdpi/ic_camera_light.png b/res/drawable-xhdpi/ic_camera_light.png new file mode 100644 index 0000000..3b74ad6 Binary files /dev/null and b/res/drawable-xhdpi/ic_camera_light.png differ diff --git a/res/drawable-xhdpi/ic_camera_rear_light.png b/res/drawable-xhdpi/ic_camera_rear_light.png new file mode 100644 index 0000000..92727af Binary files /dev/null and b/res/drawable-xhdpi/ic_camera_rear_light.png differ diff --git a/res/drawable-xhdpi/ic_cancel_small_dark.png b/res/drawable-xhdpi/ic_cancel_small_dark.png new file mode 100644 index 0000000..8b34b9b Binary files /dev/null and b/res/drawable-xhdpi/ic_cancel_small_dark.png differ diff --git a/res/drawable-xhdpi/ic_cancel_small_light.png b/res/drawable-xhdpi/ic_cancel_small_light.png new file mode 100644 index 0000000..dea984f Binary files /dev/null and b/res/drawable-xhdpi/ic_cancel_small_light.png differ diff --git a/res/drawable-xhdpi/ic_checkbox_blank_light.png b/res/drawable-xhdpi/ic_checkbox_blank_light.png new file mode 100644 index 0000000..7f6e917 Binary files /dev/null and b/res/drawable-xhdpi/ic_checkbox_blank_light.png differ diff --git a/res/drawable-xhdpi/ic_checkbox_light.png b/res/drawable-xhdpi/ic_checkbox_light.png new file mode 100644 index 0000000..9cb2b7e Binary files /dev/null and b/res/drawable-xhdpi/ic_checkbox_light.png differ diff --git a/res/drawable-xhdpi/ic_checkbox_outline_light.png b/res/drawable-xhdpi/ic_checkbox_outline_light.png new file mode 100644 index 0000000..64f0a2b Binary files /dev/null and b/res/drawable-xhdpi/ic_checkbox_outline_light.png differ diff --git a/res/drawable-xhdpi/ic_checkmark_circle_blue.png b/res/drawable-xhdpi/ic_checkmark_circle_blue.png new file mode 100644 index 0000000..211a405 Binary files /dev/null and b/res/drawable-xhdpi/ic_checkmark_circle_blue.png differ diff --git a/res/drawable-xhdpi/ic_checkmark_large.png b/res/drawable-xhdpi/ic_checkmark_large.png new file mode 100644 index 0000000..fd4420b Binary files /dev/null and b/res/drawable-xhdpi/ic_checkmark_large.png differ diff --git a/res/drawable-xhdpi/ic_checkmark_large_light.png b/res/drawable-xhdpi/ic_checkmark_large_light.png new file mode 100644 index 0000000..d841688 Binary files /dev/null and b/res/drawable-xhdpi/ic_checkmark_large_light.png differ diff --git a/res/drawable-xhdpi/ic_checkmark_small_blue.png b/res/drawable-xhdpi/ic_checkmark_small_blue.png new file mode 100644 index 0000000..9fc4a4b Binary files /dev/null and b/res/drawable-xhdpi/ic_checkmark_small_blue.png differ diff --git a/res/drawable-xhdpi/ic_checkmark_small_light.png b/res/drawable-xhdpi/ic_checkmark_small_light.png new file mode 100644 index 0000000..cd084fb Binary files /dev/null and b/res/drawable-xhdpi/ic_checkmark_small_light.png differ diff --git a/res/drawable-xhdpi/ic_color_lens.png b/res/drawable-xhdpi/ic_color_lens.png new file mode 100644 index 0000000..8887429 Binary files /dev/null and b/res/drawable-xhdpi/ic_color_lens.png differ diff --git a/res/drawable-xhdpi/ic_content_copy_dark.png b/res/drawable-xhdpi/ic_content_copy_dark.png new file mode 100644 index 0000000..0114555 Binary files /dev/null and b/res/drawable-xhdpi/ic_content_copy_dark.png differ diff --git a/res/drawable-xhdpi/ic_crying_hero.png b/res/drawable-xhdpi/ic_crying_hero.png new file mode 100644 index 0000000..ccc40ab Binary files /dev/null and b/res/drawable-xhdpi/ic_crying_hero.png differ diff --git a/res/drawable-xhdpi/ic_delete_small_dark.png b/res/drawable-xhdpi/ic_delete_small_dark.png new file mode 100644 index 0000000..b9b0cbd Binary files /dev/null and b/res/drawable-xhdpi/ic_delete_small_dark.png differ diff --git a/res/drawable-xhdpi/ic_delete_small_light.png b/res/drawable-xhdpi/ic_delete_small_light.png new file mode 100644 index 0000000..7c45f70 Binary files /dev/null and b/res/drawable-xhdpi/ic_delete_small_light.png differ diff --git a/res/drawable-xhdpi/ic_dnd_on_dark.png b/res/drawable-xhdpi/ic_dnd_on_dark.png new file mode 100644 index 0000000..c40fe99 Binary files /dev/null and b/res/drawable-xhdpi/ic_dnd_on_dark.png differ diff --git a/res/drawable-xhdpi/ic_dnd_on_light.png b/res/drawable-xhdpi/ic_dnd_on_light.png new file mode 100644 index 0000000..e0527f5 Binary files /dev/null and b/res/drawable-xhdpi/ic_dnd_on_light.png differ diff --git a/res/drawable-xhdpi/ic_failed_light.png b/res/drawable-xhdpi/ic_failed_light.png new file mode 100644 index 0000000..33d3436 Binary files /dev/null and b/res/drawable-xhdpi/ic_failed_light.png differ diff --git a/res/drawable-xhdpi/ic_failed_status_red.png b/res/drawable-xhdpi/ic_failed_status_red.png new file mode 100644 index 0000000..3c6616a Binary files /dev/null and b/res/drawable-xhdpi/ic_failed_status_red.png differ diff --git a/res/drawable-xhdpi/ic_file_download_dark.png b/res/drawable-xhdpi/ic_file_download_dark.png new file mode 100644 index 0000000..c31257a Binary files /dev/null and b/res/drawable-xhdpi/ic_file_download_dark.png differ diff --git a/res/drawable-xhdpi/ic_file_download_light.png b/res/drawable-xhdpi/ic_file_download_light.png new file mode 100644 index 0000000..b894fa6 Binary files /dev/null and b/res/drawable-xhdpi/ic_file_download_light.png differ diff --git a/res/drawable-xhdpi/ic_forward_dark.png b/res/drawable-xhdpi/ic_forward_dark.png new file mode 100644 index 0000000..b62f0de Binary files /dev/null and b/res/drawable-xhdpi/ic_forward_dark.png differ diff --git a/res/drawable-xhdpi/ic_history_light.png b/res/drawable-xhdpi/ic_history_light.png new file mode 100644 index 0000000..23585a2 Binary files /dev/null and b/res/drawable-xhdpi/ic_history_light.png differ diff --git a/res/drawable-xhdpi/ic_image_light.png b/res/drawable-xhdpi/ic_image_light.png new file mode 100644 index 0000000..b2aecfb Binary files /dev/null and b/res/drawable-xhdpi/ic_image_light.png differ diff --git a/res/drawable-xhdpi/ic_ime_dark.png b/res/drawable-xhdpi/ic_ime_dark.png new file mode 100644 index 0000000..cd5c5c6 Binary files /dev/null and b/res/drawable-xhdpi/ic_ime_dark.png differ diff --git a/res/drawable-xhdpi/ic_ime_light.png b/res/drawable-xhdpi/ic_ime_light.png new file mode 100644 index 0000000..14b57f8 Binary files /dev/null and b/res/drawable-xhdpi/ic_ime_light.png differ diff --git a/res/drawable-xhdpi/ic_info_dark.png b/res/drawable-xhdpi/ic_info_dark.png new file mode 100644 index 0000000..31d1e05 Binary files /dev/null and b/res/drawable-xhdpi/ic_info_dark.png differ diff --git a/res/drawable-xhdpi/ic_keyboard_arrow_left_light.png b/res/drawable-xhdpi/ic_keyboard_arrow_left_light.png new file mode 100644 index 0000000..464a0bc Binary files /dev/null and b/res/drawable-xhdpi/ic_keyboard_arrow_left_light.png differ diff --git a/res/drawable-xhdpi/ic_keyboard_arrow_right_light.png b/res/drawable-xhdpi/ic_keyboard_arrow_right_light.png new file mode 100644 index 0000000..51a1903 Binary files /dev/null and b/res/drawable-xhdpi/ic_keyboard_arrow_right_light.png differ diff --git a/res/drawable-xhdpi/ic_launcher.png b/res/drawable-xhdpi/ic_launcher.png new file mode 100644 index 0000000..3c82a06 Binary files /dev/null and b/res/drawable-xhdpi/ic_launcher.png differ diff --git a/res/drawable-xhdpi/ic_mp_audio_mic.png b/res/drawable-xhdpi/ic_mp_audio_mic.png new file mode 100644 index 0000000..b35d0f1 Binary files /dev/null and b/res/drawable-xhdpi/ic_mp_audio_mic.png differ diff --git a/res/drawable-xhdpi/ic_mp_camera_large_light.png b/res/drawable-xhdpi/ic_mp_camera_large_light.png new file mode 100644 index 0000000..5552d92 Binary files /dev/null and b/res/drawable-xhdpi/ic_mp_camera_large_light.png differ diff --git a/res/drawable-xhdpi/ic_mp_camera_small_light.png b/res/drawable-xhdpi/ic_mp_camera_small_light.png new file mode 100644 index 0000000..75094e1 Binary files /dev/null and b/res/drawable-xhdpi/ic_mp_camera_small_light.png differ diff --git a/res/drawable-xhdpi/ic_mp_capture_stop_large_light.png b/res/drawable-xhdpi/ic_mp_capture_stop_large_light.png new file mode 100644 index 0000000..62125b3 Binary files /dev/null and b/res/drawable-xhdpi/ic_mp_capture_stop_large_light.png differ diff --git a/res/drawable-xhdpi/ic_mp_full_screen_light.png b/res/drawable-xhdpi/ic_mp_full_screen_light.png new file mode 100644 index 0000000..02cbd48 Binary files /dev/null and b/res/drawable-xhdpi/ic_mp_full_screen_light.png differ diff --git a/res/drawable-xhdpi/ic_mp_video_large_light.png b/res/drawable-xhdpi/ic_mp_video_large_light.png new file mode 100644 index 0000000..723b052 Binary files /dev/null and b/res/drawable-xhdpi/ic_mp_video_large_light.png differ diff --git a/res/drawable-xhdpi/ic_mp_video_small_light.png b/res/drawable-xhdpi/ic_mp_video_small_light.png new file mode 100644 index 0000000..00fde5f Binary files /dev/null and b/res/drawable-xhdpi/ic_mp_video_small_light.png differ diff --git a/res/drawable-xhdpi/ic_notifications_off_dark.png b/res/drawable-xhdpi/ic_notifications_off_dark.png new file mode 100644 index 0000000..4f3b924 Binary files /dev/null and b/res/drawable-xhdpi/ic_notifications_off_dark.png differ diff --git a/res/drawable-xhdpi/ic_notifications_off_light.png b/res/drawable-xhdpi/ic_notifications_off_light.png new file mode 100644 index 0000000..16ae132 Binary files /dev/null and b/res/drawable-xhdpi/ic_notifications_off_light.png differ diff --git a/res/drawable-xhdpi/ic_notifications_off_small_light.png b/res/drawable-xhdpi/ic_notifications_off_small_light.png new file mode 100644 index 0000000..e54fc52 Binary files /dev/null and b/res/drawable-xhdpi/ic_notifications_off_small_light.png differ diff --git a/res/drawable-xhdpi/ic_notifications_on_dark.png b/res/drawable-xhdpi/ic_notifications_on_dark.png new file mode 100644 index 0000000..40f2909 Binary files /dev/null and b/res/drawable-xhdpi/ic_notifications_on_dark.png differ diff --git a/res/drawable-xhdpi/ic_notifications_on_light.png b/res/drawable-xhdpi/ic_notifications_on_light.png new file mode 100644 index 0000000..3c44f93 Binary files /dev/null and b/res/drawable-xhdpi/ic_notifications_on_light.png differ diff --git a/res/drawable-xhdpi/ic_numeric_dialpad.png b/res/drawable-xhdpi/ic_numeric_dialpad.png new file mode 100644 index 0000000..b22360f Binary files /dev/null and b/res/drawable-xhdpi/ic_numeric_dialpad.png differ diff --git a/res/drawable-xhdpi/ic_oobe_conv_list.png b/res/drawable-xhdpi/ic_oobe_conv_list.png new file mode 100644 index 0000000..b41d322 Binary files /dev/null and b/res/drawable-xhdpi/ic_oobe_conv_list.png differ diff --git a/res/drawable-xhdpi/ic_oobe_freq_list.png b/res/drawable-xhdpi/ic_oobe_freq_list.png new file mode 100644 index 0000000..898daf5 Binary files /dev/null and b/res/drawable-xhdpi/ic_oobe_freq_list.png differ diff --git a/res/drawable-xhdpi/ic_open_in_new.png b/res/drawable-xhdpi/ic_open_in_new.png new file mode 100644 index 0000000..363a35d Binary files /dev/null and b/res/drawable-xhdpi/ic_open_in_new.png differ diff --git a/res/drawable-xhdpi/ic_people_add_light.png b/res/drawable-xhdpi/ic_people_add_light.png new file mode 100644 index 0000000..f57f056 Binary files /dev/null and b/res/drawable-xhdpi/ic_people_add_light.png differ diff --git a/res/drawable-xhdpi/ic_person_add_blue.png b/res/drawable-xhdpi/ic_person_add_blue.png new file mode 100644 index 0000000..4e76e64 Binary files /dev/null and b/res/drawable-xhdpi/ic_person_add_blue.png differ diff --git a/res/drawable-xhdpi/ic_person_add_dark.png b/res/drawable-xhdpi/ic_person_add_dark.png new file mode 100644 index 0000000..6f325cf Binary files /dev/null and b/res/drawable-xhdpi/ic_person_add_dark.png differ diff --git a/res/drawable-xhdpi/ic_person_add_light.png b/res/drawable-xhdpi/ic_person_add_light.png new file mode 100644 index 0000000..840f063 Binary files /dev/null and b/res/drawable-xhdpi/ic_person_add_light.png differ diff --git a/res/drawable-xhdpi/ic_person_light.png b/res/drawable-xhdpi/ic_person_light.png new file mode 100644 index 0000000..5d23639 Binary files /dev/null and b/res/drawable-xhdpi/ic_person_light.png differ diff --git a/res/drawable-xhdpi/ic_person_light_large.png b/res/drawable-xhdpi/ic_person_light_large.png new file mode 100644 index 0000000..22a4ff1 Binary files /dev/null and b/res/drawable-xhdpi/ic_person_light_large.png differ diff --git a/res/drawable-xhdpi/ic_phone_small_light.png b/res/drawable-xhdpi/ic_phone_small_light.png new file mode 100644 index 0000000..b2cab28 Binary files /dev/null and b/res/drawable-xhdpi/ic_phone_small_light.png differ diff --git a/res/drawable-xhdpi/ic_photo_library_light.png b/res/drawable-xhdpi/ic_photo_library_light.png new file mode 100644 index 0000000..1f5a69c Binary files /dev/null and b/res/drawable-xhdpi/ic_photo_library_light.png differ diff --git a/res/drawable-xhdpi/ic_preview_pause.png b/res/drawable-xhdpi/ic_preview_pause.png new file mode 100644 index 0000000..bc0319d Binary files /dev/null and b/res/drawable-xhdpi/ic_preview_pause.png differ diff --git a/res/drawable-xhdpi/ic_preview_play.png b/res/drawable-xhdpi/ic_preview_play.png new file mode 100644 index 0000000..dc3ac1e Binary files /dev/null and b/res/drawable-xhdpi/ic_preview_play.png differ diff --git a/res/drawable-xhdpi/ic_remove_light.png b/res/drawable-xhdpi/ic_remove_light.png new file mode 100644 index 0000000..cbaf6d0 Binary files /dev/null and b/res/drawable-xhdpi/ic_remove_light.png differ diff --git a/res/drawable-xhdpi/ic_remove_small_light.png b/res/drawable-xhdpi/ic_remove_small_light.png new file mode 100644 index 0000000..222b86c Binary files /dev/null and b/res/drawable-xhdpi/ic_remove_small_light.png differ diff --git a/res/drawable-xhdpi/ic_save_dark.png b/res/drawable-xhdpi/ic_save_dark.png new file mode 100644 index 0000000..7927ba6 Binary files /dev/null and b/res/drawable-xhdpi/ic_save_dark.png differ diff --git a/res/drawable-xhdpi/ic_save_light.png b/res/drawable-xhdpi/ic_save_light.png new file mode 100644 index 0000000..298bb47 Binary files /dev/null and b/res/drawable-xhdpi/ic_save_light.png differ diff --git a/res/drawable-xhdpi/ic_search_light.png b/res/drawable-xhdpi/ic_search_light.png new file mode 100644 index 0000000..b83d895 Binary files /dev/null and b/res/drawable-xhdpi/ic_search_light.png differ diff --git a/res/drawable-xhdpi/ic_send_dark.png b/res/drawable-xhdpi/ic_send_dark.png new file mode 100644 index 0000000..a6b3699 Binary files /dev/null and b/res/drawable-xhdpi/ic_send_dark.png differ diff --git a/res/drawable-xhdpi/ic_send_light.png b/res/drawable-xhdpi/ic_send_light.png new file mode 100644 index 0000000..a19b335 Binary files /dev/null and b/res/drawable-xhdpi/ic_send_light.png differ diff --git a/res/drawable-xhdpi/ic_share_dark.png b/res/drawable-xhdpi/ic_share_dark.png new file mode 100644 index 0000000..a4b822a Binary files /dev/null and b/res/drawable-xhdpi/ic_share_dark.png differ diff --git a/res/drawable-xhdpi/ic_share_light.png b/res/drawable-xhdpi/ic_share_light.png new file mode 100644 index 0000000..711797c Binary files /dev/null and b/res/drawable-xhdpi/ic_share_light.png differ diff --git a/res/drawable-xhdpi/ic_sim_card_send.png b/res/drawable-xhdpi/ic_sim_card_send.png new file mode 100644 index 0000000..9fc7e9a Binary files /dev/null and b/res/drawable-xhdpi/ic_sim_card_send.png differ diff --git a/res/drawable-xhdpi/ic_sms_delivery_ok.png b/res/drawable-xhdpi/ic_sms_delivery_ok.png new file mode 100644 index 0000000..c12ae0a Binary files /dev/null and b/res/drawable-xhdpi/ic_sms_delivery_ok.png differ diff --git a/res/drawable-xhdpi/ic_sms_failed_light.png b/res/drawable-xhdpi/ic_sms_failed_light.png new file mode 100644 index 0000000..90c899a Binary files /dev/null and b/res/drawable-xhdpi/ic_sms_failed_light.png differ diff --git a/res/drawable-xhdpi/ic_sms_light.png b/res/drawable-xhdpi/ic_sms_light.png new file mode 100644 index 0000000..4b24a4b Binary files /dev/null and b/res/drawable-xhdpi/ic_sms_light.png differ diff --git a/res/drawable-xhdpi/ic_sms_multi_light.png b/res/drawable-xhdpi/ic_sms_multi_light.png new file mode 100644 index 0000000..f3ba620 Binary files /dev/null and b/res/drawable-xhdpi/ic_sms_multi_light.png differ diff --git a/res/drawable-xhdpi/ic_tooltip_arrow.png b/res/drawable-xhdpi/ic_tooltip_arrow.png new file mode 100644 index 0000000..4a59931 Binary files /dev/null and b/res/drawable-xhdpi/ic_tooltip_arrow.png differ diff --git a/res/drawable-xhdpi/ic_video_play_light.png b/res/drawable-xhdpi/ic_video_play_light.png new file mode 100644 index 0000000..89397a8 Binary files /dev/null and b/res/drawable-xhdpi/ic_video_play_light.png differ diff --git a/res/drawable-xhdpi/ic_wear_reply.png b/res/drawable-xhdpi/ic_wear_reply.png new file mode 100644 index 0000000..c909c59 Binary files /dev/null and b/res/drawable-xhdpi/ic_wear_reply.png differ diff --git a/res/drawable-xhdpi/ic_widget_avatar_shadow.png b/res/drawable-xhdpi/ic_widget_avatar_shadow.png new file mode 100644 index 0000000..37600aa Binary files /dev/null and b/res/drawable-xhdpi/ic_widget_avatar_shadow.png differ diff --git a/res/drawable-xhdpi/ic_widget_list.png b/res/drawable-xhdpi/ic_widget_list.png new file mode 100644 index 0000000..a12764f Binary files /dev/null and b/res/drawable-xhdpi/ic_widget_list.png differ diff --git a/res/drawable-xhdpi/msg_bubble_error.9.png b/res/drawable-xhdpi/msg_bubble_error.9.png new file mode 100644 index 0000000..96b139b Binary files /dev/null and b/res/drawable-xhdpi/msg_bubble_error.9.png differ diff --git a/res/drawable-xhdpi/msg_bubble_incoming.9.png b/res/drawable-xhdpi/msg_bubble_incoming.9.png new file mode 100644 index 0000000..0661fdb Binary files /dev/null and b/res/drawable-xhdpi/msg_bubble_incoming.9.png differ diff --git a/res/drawable-xhdpi/msg_bubble_input.9.png b/res/drawable-xhdpi/msg_bubble_input.9.png new file mode 100644 index 0000000..67f92ff Binary files /dev/null and b/res/drawable-xhdpi/msg_bubble_input.9.png differ diff --git a/res/drawable-xhdpi/msg_bubble_outgoing.9.png b/res/drawable-xhdpi/msg_bubble_outgoing.9.png new file mode 100644 index 0000000..52cb936 Binary files /dev/null and b/res/drawable-xhdpi/msg_bubble_outgoing.9.png differ diff --git a/res/drawable-xhdpi/permissions.png b/res/drawable-xhdpi/permissions.png new file mode 100644 index 0000000..39a81bc Binary files /dev/null and b/res/drawable-xhdpi/permissions.png differ diff --git a/res/drawable-xhdpi/swipe_shadow.9.png b/res/drawable-xhdpi/swipe_shadow.9.png new file mode 100644 index 0000000..45d965d Binary files /dev/null and b/res/drawable-xhdpi/swipe_shadow.9.png differ diff --git a/res/drawable-xhdpi/swipe_shadow_drag.9.png b/res/drawable-xhdpi/swipe_shadow_drag.9.png new file mode 100644 index 0000000..b443b5c Binary files /dev/null and b/res/drawable-xhdpi/swipe_shadow_drag.9.png differ diff --git a/res/drawable-xhdpi/sym_keyboard_delete_holo.png b/res/drawable-xhdpi/sym_keyboard_delete_holo.png new file mode 100644 index 0000000..16b58a0 Binary files /dev/null and b/res/drawable-xhdpi/sym_keyboard_delete_holo.png differ diff --git a/res/drawable-xhdpi/tab_btn_bg_normal.9.png b/res/drawable-xhdpi/tab_btn_bg_normal.9.png new file mode 100644 index 0000000..d9446c6 Binary files /dev/null and b/res/drawable-xhdpi/tab_btn_bg_normal.9.png differ diff --git a/res/drawable-xhdpi/tab_btn_bg_pressed.9.png b/res/drawable-xhdpi/tab_btn_bg_pressed.9.png new file mode 100644 index 0000000..de76c06 Binary files /dev/null and b/res/drawable-xhdpi/tab_btn_bg_pressed.9.png differ diff --git a/res/drawable-xhdpi/tab_selected.9.png b/res/drawable-xhdpi/tab_selected.9.png new file mode 100644 index 0000000..95e5f43 Binary files /dev/null and b/res/drawable-xhdpi/tab_selected.9.png differ diff --git a/res/drawable-xhdpi/tab_selected_focused_holo.9.png b/res/drawable-xhdpi/tab_selected_focused_holo.9.png new file mode 100644 index 0000000..f98b4d9 Binary files /dev/null and b/res/drawable-xhdpi/tab_selected_focused_holo.9.png differ diff --git a/res/drawable-xhdpi/tab_selected_pressed_focused_holo.9.png b/res/drawable-xhdpi/tab_selected_pressed_focused_holo.9.png new file mode 100644 index 0000000..e8f2cb6 Binary files /dev/null and b/res/drawable-xhdpi/tab_selected_pressed_focused_holo.9.png differ diff --git a/res/drawable-xhdpi/tab_selected_pressed_holo.9.png b/res/drawable-xhdpi/tab_selected_pressed_holo.9.png new file mode 100644 index 0000000..1882927 Binary files /dev/null and b/res/drawable-xhdpi/tab_selected_pressed_holo.9.png differ diff --git a/res/drawable-xhdpi/tab_unselected.9.png b/res/drawable-xhdpi/tab_unselected.9.png new file mode 100644 index 0000000..8cede8d Binary files /dev/null and b/res/drawable-xhdpi/tab_unselected.9.png differ diff --git a/res/drawable-xhdpi/tab_unselected_focused_holo.9.png b/res/drawable-xhdpi/tab_unselected_focused_holo.9.png new file mode 100644 index 0000000..28a7ec5 Binary files /dev/null and b/res/drawable-xhdpi/tab_unselected_focused_holo.9.png differ diff --git a/res/drawable-xhdpi/tab_unselected_pressed_focused_holo.9.png b/res/drawable-xhdpi/tab_unselected_pressed_focused_holo.9.png new file mode 100644 index 0000000..b9962b1 Binary files /dev/null and b/res/drawable-xhdpi/tab_unselected_pressed_focused_holo.9.png differ diff --git a/res/drawable-xhdpi/tab_unselected_pressed_holo.9.png b/res/drawable-xhdpi/tab_unselected_pressed_holo.9.png new file mode 100644 index 0000000..dadc471 Binary files /dev/null and b/res/drawable-xhdpi/tab_unselected_pressed_holo.9.png differ diff --git a/res/drawable-xhdpi/widget_hr.9.png b/res/drawable-xhdpi/widget_hr.9.png new file mode 100644 index 0000000..31fcc7e Binary files /dev/null and b/res/drawable-xhdpi/widget_hr.9.png differ diff --git a/res/drawable-xhdpi/widget_msg_bubble_incoming.9.png b/res/drawable-xhdpi/widget_msg_bubble_incoming.9.png new file mode 100644 index 0000000..2abb496 Binary files /dev/null and b/res/drawable-xhdpi/widget_msg_bubble_incoming.9.png differ diff --git a/res/drawable-xhdpi/widget_msg_bubble_outgoing.9.png b/res/drawable-xhdpi/widget_msg_bubble_outgoing.9.png new file mode 100644 index 0000000..35b58cd Binary files /dev/null and b/res/drawable-xhdpi/widget_msg_bubble_outgoing.9.png differ diff --git a/res/drawable-xxhdpi/btn_keyboard_key_dark_normal_holo.9.png b/res/drawable-xxhdpi/btn_keyboard_key_dark_normal_holo.9.png new file mode 100644 index 0000000..cc880c2 Binary files /dev/null and b/res/drawable-xxhdpi/btn_keyboard_key_dark_normal_holo.9.png differ diff --git a/res/drawable-xxhdpi/btn_keyboard_key_dark_pressed_holo.9.png b/res/drawable-xxhdpi/btn_keyboard_key_dark_pressed_holo.9.png new file mode 100644 index 0000000..60ccf97 Binary files /dev/null and b/res/drawable-xxhdpi/btn_keyboard_key_dark_pressed_holo.9.png differ diff --git a/res/drawable-xxhdpi/btn_keyboard_key_light_normal_holo.9.png b/res/drawable-xxhdpi/btn_keyboard_key_light_normal_holo.9.png new file mode 100644 index 0000000..0358907 Binary files /dev/null and b/res/drawable-xxhdpi/btn_keyboard_key_light_normal_holo.9.png differ diff --git a/res/drawable-xxhdpi/btn_keyboard_key_light_pressed_holo.9.png b/res/drawable-xxhdpi/btn_keyboard_key_light_pressed_holo.9.png new file mode 100644 index 0000000..adcddc9 Binary files /dev/null and b/res/drawable-xxhdpi/btn_keyboard_key_light_pressed_holo.9.png differ diff --git a/res/drawable-xxhdpi/cab_bg.9.png b/res/drawable-xxhdpi/cab_bg.9.png new file mode 100644 index 0000000..b75401e Binary files /dev/null and b/res/drawable-xxhdpi/cab_bg.9.png differ diff --git a/res/drawable-xxhdpi/contact_popup_background.9.png b/res/drawable-xxhdpi/contact_popup_background.9.png new file mode 100644 index 0000000..bb4f281 Binary files /dev/null and b/res/drawable-xxhdpi/contact_popup_background.9.png differ diff --git a/res/drawable-xxhdpi/fab_new_message_static_shadow.png b/res/drawable-xxhdpi/fab_new_message_static_shadow.png new file mode 100644 index 0000000..adfd569 Binary files /dev/null and b/res/drawable-xxhdpi/fab_new_message_static_shadow.png differ diff --git a/res/drawable-xxhdpi/ic_add_gray.png b/res/drawable-xxhdpi/ic_add_gray.png new file mode 100644 index 0000000..8273830 Binary files /dev/null and b/res/drawable-xxhdpi/ic_add_gray.png differ diff --git a/res/drawable-xxhdpi/ic_add_white.png b/res/drawable-xxhdpi/ic_add_white.png new file mode 100644 index 0000000..7b4d52c Binary files /dev/null and b/res/drawable-xxhdpi/ic_add_white.png differ diff --git a/res/drawable-xxhdpi/ic_archive_small_dark.png b/res/drawable-xxhdpi/ic_archive_small_dark.png new file mode 100644 index 0000000..64aa9d5 Binary files /dev/null and b/res/drawable-xxhdpi/ic_archive_small_dark.png differ diff --git a/res/drawable-xxhdpi/ic_archive_small_light.png b/res/drawable-xxhdpi/ic_archive_small_light.png new file mode 100644 index 0000000..042ed27 Binary files /dev/null and b/res/drawable-xxhdpi/ic_archive_small_light.png differ diff --git a/res/drawable-xxhdpi/ic_archive_undo_small_dark.png b/res/drawable-xxhdpi/ic_archive_undo_small_dark.png new file mode 100644 index 0000000..a3f7503 Binary files /dev/null and b/res/drawable-xxhdpi/ic_archive_undo_small_dark.png differ diff --git a/res/drawable-xxhdpi/ic_archive_undo_small_light.png b/res/drawable-xxhdpi/ic_archive_undo_small_light.png new file mode 100644 index 0000000..cd9d0d5 Binary files /dev/null and b/res/drawable-xxhdpi/ic_archive_undo_small_light.png differ diff --git a/res/drawable-xxhdpi/ic_arrow_back_dark.png b/res/drawable-xxhdpi/ic_arrow_back_dark.png new file mode 100644 index 0000000..1c71cb0 Binary files /dev/null and b/res/drawable-xxhdpi/ic_arrow_back_dark.png differ diff --git a/res/drawable-xxhdpi/ic_arrow_back_light.png b/res/drawable-xxhdpi/ic_arrow_back_light.png new file mode 100644 index 0000000..8b532d0 Binary files /dev/null and b/res/drawable-xxhdpi/ic_arrow_back_light.png differ diff --git a/res/drawable-xxhdpi/ic_attachment_dark.png b/res/drawable-xxhdpi/ic_attachment_dark.png new file mode 100644 index 0000000..64d9dad Binary files /dev/null and b/res/drawable-xxhdpi/ic_attachment_dark.png differ diff --git a/res/drawable-xxhdpi/ic_audio_light.png b/res/drawable-xxhdpi/ic_audio_light.png new file mode 100644 index 0000000..9b2f05c Binary files /dev/null and b/res/drawable-xxhdpi/ic_audio_light.png differ diff --git a/res/drawable-xxhdpi/ic_audio_pause.png b/res/drawable-xxhdpi/ic_audio_pause.png new file mode 100644 index 0000000..d294f3c Binary files /dev/null and b/res/drawable-xxhdpi/ic_audio_pause.png differ diff --git a/res/drawable-xxhdpi/ic_audio_play.png b/res/drawable-xxhdpi/ic_audio_play.png new file mode 100644 index 0000000..cb84ee6 Binary files /dev/null and b/res/drawable-xxhdpi/ic_audio_play.png differ diff --git a/res/drawable-xxhdpi/ic_camera_front_light.png b/res/drawable-xxhdpi/ic_camera_front_light.png new file mode 100644 index 0000000..53616e4 Binary files /dev/null and b/res/drawable-xxhdpi/ic_camera_front_light.png differ diff --git a/res/drawable-xxhdpi/ic_camera_light.png b/res/drawable-xxhdpi/ic_camera_light.png new file mode 100644 index 0000000..64e9873 Binary files /dev/null and b/res/drawable-xxhdpi/ic_camera_light.png differ diff --git a/res/drawable-xxhdpi/ic_camera_rear_light.png b/res/drawable-xxhdpi/ic_camera_rear_light.png new file mode 100644 index 0000000..8e478e3 Binary files /dev/null and b/res/drawable-xxhdpi/ic_camera_rear_light.png differ diff --git a/res/drawable-xxhdpi/ic_cancel_small_dark.png b/res/drawable-xxhdpi/ic_cancel_small_dark.png new file mode 100644 index 0000000..c8f583c Binary files /dev/null and b/res/drawable-xxhdpi/ic_cancel_small_dark.png differ diff --git a/res/drawable-xxhdpi/ic_cancel_small_light.png b/res/drawable-xxhdpi/ic_cancel_small_light.png new file mode 100644 index 0000000..b3eacda Binary files /dev/null and b/res/drawable-xxhdpi/ic_cancel_small_light.png differ diff --git a/res/drawable-xxhdpi/ic_checkbox_blank_light.png b/res/drawable-xxhdpi/ic_checkbox_blank_light.png new file mode 100644 index 0000000..0556578 Binary files /dev/null and b/res/drawable-xxhdpi/ic_checkbox_blank_light.png differ diff --git a/res/drawable-xxhdpi/ic_checkbox_light.png b/res/drawable-xxhdpi/ic_checkbox_light.png new file mode 100644 index 0000000..3a1896b Binary files /dev/null and b/res/drawable-xxhdpi/ic_checkbox_light.png differ diff --git a/res/drawable-xxhdpi/ic_checkbox_outline_light.png b/res/drawable-xxhdpi/ic_checkbox_outline_light.png new file mode 100644 index 0000000..cc77c5e Binary files /dev/null and b/res/drawable-xxhdpi/ic_checkbox_outline_light.png differ diff --git a/res/drawable-xxhdpi/ic_checkmark_circle_blue.png b/res/drawable-xxhdpi/ic_checkmark_circle_blue.png new file mode 100644 index 0000000..bede5b9 Binary files /dev/null and b/res/drawable-xxhdpi/ic_checkmark_circle_blue.png differ diff --git a/res/drawable-xxhdpi/ic_checkmark_large.png b/res/drawable-xxhdpi/ic_checkmark_large.png new file mode 100644 index 0000000..1be0e02 Binary files /dev/null and b/res/drawable-xxhdpi/ic_checkmark_large.png differ diff --git a/res/drawable-xxhdpi/ic_checkmark_large_light.png b/res/drawable-xxhdpi/ic_checkmark_large_light.png new file mode 100644 index 0000000..b5f4e24 Binary files /dev/null and b/res/drawable-xxhdpi/ic_checkmark_large_light.png differ diff --git a/res/drawable-xxhdpi/ic_checkmark_small_blue.png b/res/drawable-xxhdpi/ic_checkmark_small_blue.png new file mode 100644 index 0000000..65d3e5c Binary files /dev/null and b/res/drawable-xxhdpi/ic_checkmark_small_blue.png differ diff --git a/res/drawable-xxhdpi/ic_checkmark_small_light.png b/res/drawable-xxhdpi/ic_checkmark_small_light.png new file mode 100644 index 0000000..7aeac03 Binary files /dev/null and b/res/drawable-xxhdpi/ic_checkmark_small_light.png differ diff --git a/res/drawable-xxhdpi/ic_color_lens.png b/res/drawable-xxhdpi/ic_color_lens.png new file mode 100644 index 0000000..6fd56fd Binary files /dev/null and b/res/drawable-xxhdpi/ic_color_lens.png differ diff --git a/res/drawable-xxhdpi/ic_content_copy_dark.png b/res/drawable-xxhdpi/ic_content_copy_dark.png new file mode 100644 index 0000000..e3518ae Binary files /dev/null and b/res/drawable-xxhdpi/ic_content_copy_dark.png differ diff --git a/res/drawable-xxhdpi/ic_crying_hero.png b/res/drawable-xxhdpi/ic_crying_hero.png new file mode 100644 index 0000000..46538b5 Binary files /dev/null and b/res/drawable-xxhdpi/ic_crying_hero.png differ diff --git a/res/drawable-xxhdpi/ic_delete_small_dark.png b/res/drawable-xxhdpi/ic_delete_small_dark.png new file mode 100644 index 0000000..146ac39 Binary files /dev/null and b/res/drawable-xxhdpi/ic_delete_small_dark.png differ diff --git a/res/drawable-xxhdpi/ic_delete_small_light.png b/res/drawable-xxhdpi/ic_delete_small_light.png new file mode 100644 index 0000000..b739ac9 Binary files /dev/null and b/res/drawable-xxhdpi/ic_delete_small_light.png differ diff --git a/res/drawable-xxhdpi/ic_dnd_on_dark.png b/res/drawable-xxhdpi/ic_dnd_on_dark.png new file mode 100644 index 0000000..a9ccb88 Binary files /dev/null and b/res/drawable-xxhdpi/ic_dnd_on_dark.png differ diff --git a/res/drawable-xxhdpi/ic_dnd_on_light.png b/res/drawable-xxhdpi/ic_dnd_on_light.png new file mode 100644 index 0000000..3ddaa13 Binary files /dev/null and b/res/drawable-xxhdpi/ic_dnd_on_light.png differ diff --git a/res/drawable-xxhdpi/ic_failed_light.png b/res/drawable-xxhdpi/ic_failed_light.png new file mode 100644 index 0000000..7612138 Binary files /dev/null and b/res/drawable-xxhdpi/ic_failed_light.png differ diff --git a/res/drawable-xxhdpi/ic_failed_status_red.png b/res/drawable-xxhdpi/ic_failed_status_red.png new file mode 100644 index 0000000..26f76cc Binary files /dev/null and b/res/drawable-xxhdpi/ic_failed_status_red.png differ diff --git a/res/drawable-xxhdpi/ic_file_download_dark.png b/res/drawable-xxhdpi/ic_file_download_dark.png new file mode 100644 index 0000000..d7fc0b3 Binary files /dev/null and b/res/drawable-xxhdpi/ic_file_download_dark.png differ diff --git a/res/drawable-xxhdpi/ic_file_download_light.png b/res/drawable-xxhdpi/ic_file_download_light.png new file mode 100644 index 0000000..42eadbc Binary files /dev/null and b/res/drawable-xxhdpi/ic_file_download_light.png differ diff --git a/res/drawable-xxhdpi/ic_forward_dark.png b/res/drawable-xxhdpi/ic_forward_dark.png new file mode 100644 index 0000000..cd071be Binary files /dev/null and b/res/drawable-xxhdpi/ic_forward_dark.png differ diff --git a/res/drawable-xxhdpi/ic_history_light.png b/res/drawable-xxhdpi/ic_history_light.png new file mode 100644 index 0000000..bd430f8 Binary files /dev/null and b/res/drawable-xxhdpi/ic_history_light.png differ diff --git a/res/drawable-xxhdpi/ic_image_light.png b/res/drawable-xxhdpi/ic_image_light.png new file mode 100644 index 0000000..16b7e7b Binary files /dev/null and b/res/drawable-xxhdpi/ic_image_light.png differ diff --git a/res/drawable-xxhdpi/ic_ime_dark.png b/res/drawable-xxhdpi/ic_ime_dark.png new file mode 100644 index 0000000..a32bd46 Binary files /dev/null and b/res/drawable-xxhdpi/ic_ime_dark.png differ diff --git a/res/drawable-xxhdpi/ic_ime_light.png b/res/drawable-xxhdpi/ic_ime_light.png new file mode 100644 index 0000000..159d085 Binary files /dev/null and b/res/drawable-xxhdpi/ic_ime_light.png differ diff --git a/res/drawable-xxhdpi/ic_info_dark.png b/res/drawable-xxhdpi/ic_info_dark.png new file mode 100644 index 0000000..2df42de Binary files /dev/null and b/res/drawable-xxhdpi/ic_info_dark.png differ diff --git a/res/drawable-xxhdpi/ic_keyboard_arrow_left_light.png b/res/drawable-xxhdpi/ic_keyboard_arrow_left_light.png new file mode 100644 index 0000000..09536ed Binary files /dev/null and b/res/drawable-xxhdpi/ic_keyboard_arrow_left_light.png differ diff --git a/res/drawable-xxhdpi/ic_keyboard_arrow_right_light.png b/res/drawable-xxhdpi/ic_keyboard_arrow_right_light.png new file mode 100644 index 0000000..62ca382 Binary files /dev/null and b/res/drawable-xxhdpi/ic_keyboard_arrow_right_light.png differ diff --git a/res/drawable-xxhdpi/ic_launcher.png b/res/drawable-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..0d61198 Binary files /dev/null and b/res/drawable-xxhdpi/ic_launcher.png differ diff --git a/res/drawable-xxhdpi/ic_mp_audio_mic.png b/res/drawable-xxhdpi/ic_mp_audio_mic.png new file mode 100644 index 0000000..a01388b Binary files /dev/null and b/res/drawable-xxhdpi/ic_mp_audio_mic.png differ diff --git a/res/drawable-xxhdpi/ic_mp_camera_large_light.png b/res/drawable-xxhdpi/ic_mp_camera_large_light.png new file mode 100644 index 0000000..37fa935 Binary files /dev/null and b/res/drawable-xxhdpi/ic_mp_camera_large_light.png differ diff --git a/res/drawable-xxhdpi/ic_mp_camera_small_light.png b/res/drawable-xxhdpi/ic_mp_camera_small_light.png new file mode 100644 index 0000000..8012fce Binary files /dev/null and b/res/drawable-xxhdpi/ic_mp_camera_small_light.png differ diff --git a/res/drawable-xxhdpi/ic_mp_capture_stop_large_light.png b/res/drawable-xxhdpi/ic_mp_capture_stop_large_light.png new file mode 100644 index 0000000..d34667a Binary files /dev/null and b/res/drawable-xxhdpi/ic_mp_capture_stop_large_light.png differ diff --git a/res/drawable-xxhdpi/ic_mp_full_screen_light.png b/res/drawable-xxhdpi/ic_mp_full_screen_light.png new file mode 100644 index 0000000..483a1a3 Binary files /dev/null and b/res/drawable-xxhdpi/ic_mp_full_screen_light.png differ diff --git a/res/drawable-xxhdpi/ic_mp_video_large_light.png b/res/drawable-xxhdpi/ic_mp_video_large_light.png new file mode 100644 index 0000000..0a34b5e Binary files /dev/null and b/res/drawable-xxhdpi/ic_mp_video_large_light.png differ diff --git a/res/drawable-xxhdpi/ic_mp_video_small_light.png b/res/drawable-xxhdpi/ic_mp_video_small_light.png new file mode 100644 index 0000000..1cb7798 Binary files /dev/null and b/res/drawable-xxhdpi/ic_mp_video_small_light.png differ diff --git a/res/drawable-xxhdpi/ic_notifications_off_dark.png b/res/drawable-xxhdpi/ic_notifications_off_dark.png new file mode 100644 index 0000000..27d3754 Binary files /dev/null and b/res/drawable-xxhdpi/ic_notifications_off_dark.png differ diff --git a/res/drawable-xxhdpi/ic_notifications_off_light.png b/res/drawable-xxhdpi/ic_notifications_off_light.png new file mode 100644 index 0000000..a2aedec Binary files /dev/null and b/res/drawable-xxhdpi/ic_notifications_off_light.png differ diff --git a/res/drawable-xxhdpi/ic_notifications_off_small_light.png b/res/drawable-xxhdpi/ic_notifications_off_small_light.png new file mode 100644 index 0000000..2a90e07 Binary files /dev/null and b/res/drawable-xxhdpi/ic_notifications_off_small_light.png differ diff --git a/res/drawable-xxhdpi/ic_notifications_on_dark.png b/res/drawable-xxhdpi/ic_notifications_on_dark.png new file mode 100644 index 0000000..e64da6b Binary files /dev/null and b/res/drawable-xxhdpi/ic_notifications_on_dark.png differ diff --git a/res/drawable-xxhdpi/ic_notifications_on_light.png b/res/drawable-xxhdpi/ic_notifications_on_light.png new file mode 100644 index 0000000..f8f7d15 Binary files /dev/null and b/res/drawable-xxhdpi/ic_notifications_on_light.png differ diff --git a/res/drawable-xxhdpi/ic_numeric_dialpad.png b/res/drawable-xxhdpi/ic_numeric_dialpad.png new file mode 100644 index 0000000..a0d9e5e Binary files /dev/null and b/res/drawable-xxhdpi/ic_numeric_dialpad.png differ diff --git a/res/drawable-xxhdpi/ic_oobe_conv_list.png b/res/drawable-xxhdpi/ic_oobe_conv_list.png new file mode 100644 index 0000000..f6fcde1 Binary files /dev/null and b/res/drawable-xxhdpi/ic_oobe_conv_list.png differ diff --git a/res/drawable-xxhdpi/ic_oobe_freq_list.png b/res/drawable-xxhdpi/ic_oobe_freq_list.png new file mode 100644 index 0000000..4d7543d Binary files /dev/null and b/res/drawable-xxhdpi/ic_oobe_freq_list.png differ diff --git a/res/drawable-xxhdpi/ic_open_in_new.png b/res/drawable-xxhdpi/ic_open_in_new.png new file mode 100644 index 0000000..9109fa4 Binary files /dev/null and b/res/drawable-xxhdpi/ic_open_in_new.png differ diff --git a/res/drawable-xxhdpi/ic_people_add_light.png b/res/drawable-xxhdpi/ic_people_add_light.png new file mode 100644 index 0000000..915b91d Binary files /dev/null and b/res/drawable-xxhdpi/ic_people_add_light.png differ diff --git a/res/drawable-xxhdpi/ic_person_add_blue.png b/res/drawable-xxhdpi/ic_person_add_blue.png new file mode 100644 index 0000000..10aa345 Binary files /dev/null and b/res/drawable-xxhdpi/ic_person_add_blue.png differ diff --git a/res/drawable-xxhdpi/ic_person_add_dark.png b/res/drawable-xxhdpi/ic_person_add_dark.png new file mode 100644 index 0000000..1d9bb20 Binary files /dev/null and b/res/drawable-xxhdpi/ic_person_add_dark.png differ diff --git a/res/drawable-xxhdpi/ic_person_add_light.png b/res/drawable-xxhdpi/ic_person_add_light.png new file mode 100644 index 0000000..5a17f08 Binary files /dev/null and b/res/drawable-xxhdpi/ic_person_add_light.png differ diff --git a/res/drawable-xxhdpi/ic_person_light.png b/res/drawable-xxhdpi/ic_person_light.png new file mode 100644 index 0000000..adab957 Binary files /dev/null and b/res/drawable-xxhdpi/ic_person_light.png differ diff --git a/res/drawable-xxhdpi/ic_person_light_large.png b/res/drawable-xxhdpi/ic_person_light_large.png new file mode 100644 index 0000000..3cf214f Binary files /dev/null and b/res/drawable-xxhdpi/ic_person_light_large.png differ diff --git a/res/drawable-xxhdpi/ic_phone_small_light.png b/res/drawable-xxhdpi/ic_phone_small_light.png new file mode 100644 index 0000000..8d7c25f Binary files /dev/null and b/res/drawable-xxhdpi/ic_phone_small_light.png differ diff --git a/res/drawable-xxhdpi/ic_photo_library_light.png b/res/drawable-xxhdpi/ic_photo_library_light.png new file mode 100644 index 0000000..f61bb58 Binary files /dev/null and b/res/drawable-xxhdpi/ic_photo_library_light.png differ diff --git a/res/drawable-xxhdpi/ic_preview_pause.png b/res/drawable-xxhdpi/ic_preview_pause.png new file mode 100644 index 0000000..4785e1b Binary files /dev/null and b/res/drawable-xxhdpi/ic_preview_pause.png differ diff --git a/res/drawable-xxhdpi/ic_preview_play.png b/res/drawable-xxhdpi/ic_preview_play.png new file mode 100644 index 0000000..3120cd1 Binary files /dev/null and b/res/drawable-xxhdpi/ic_preview_play.png differ diff --git a/res/drawable-xxhdpi/ic_remove_light.png b/res/drawable-xxhdpi/ic_remove_light.png new file mode 100644 index 0000000..25bcc74 Binary files /dev/null and b/res/drawable-xxhdpi/ic_remove_light.png differ diff --git a/res/drawable-xxhdpi/ic_remove_small_light.png b/res/drawable-xxhdpi/ic_remove_small_light.png new file mode 100644 index 0000000..c69e993 Binary files /dev/null and b/res/drawable-xxhdpi/ic_remove_small_light.png differ diff --git a/res/drawable-xxhdpi/ic_save_dark.png b/res/drawable-xxhdpi/ic_save_dark.png new file mode 100644 index 0000000..8267484 Binary files /dev/null and b/res/drawable-xxhdpi/ic_save_dark.png differ diff --git a/res/drawable-xxhdpi/ic_save_light.png b/res/drawable-xxhdpi/ic_save_light.png new file mode 100644 index 0000000..4b944cf Binary files /dev/null and b/res/drawable-xxhdpi/ic_save_light.png differ diff --git a/res/drawable-xxhdpi/ic_search_light.png b/res/drawable-xxhdpi/ic_search_light.png new file mode 100644 index 0000000..97d77d6 Binary files /dev/null and b/res/drawable-xxhdpi/ic_search_light.png differ diff --git a/res/drawable-xxhdpi/ic_send_dark.png b/res/drawable-xxhdpi/ic_send_dark.png new file mode 100644 index 0000000..44729b1 Binary files /dev/null and b/res/drawable-xxhdpi/ic_send_dark.png differ diff --git a/res/drawable-xxhdpi/ic_send_light.png b/res/drawable-xxhdpi/ic_send_light.png new file mode 100644 index 0000000..a1a6cab Binary files /dev/null and b/res/drawable-xxhdpi/ic_send_light.png differ diff --git a/res/drawable-xxhdpi/ic_share_dark.png b/res/drawable-xxhdpi/ic_share_dark.png new file mode 100644 index 0000000..790670a Binary files /dev/null and b/res/drawable-xxhdpi/ic_share_dark.png differ diff --git a/res/drawable-xxhdpi/ic_share_light.png b/res/drawable-xxhdpi/ic_share_light.png new file mode 100644 index 0000000..880c6a2 Binary files /dev/null and b/res/drawable-xxhdpi/ic_share_light.png differ diff --git a/res/drawable-xxhdpi/ic_sim_card_send.png b/res/drawable-xxhdpi/ic_sim_card_send.png new file mode 100644 index 0000000..8eae7c3 Binary files /dev/null and b/res/drawable-xxhdpi/ic_sim_card_send.png differ diff --git a/res/drawable-xxhdpi/ic_sms_delivery_ok.png b/res/drawable-xxhdpi/ic_sms_delivery_ok.png new file mode 100644 index 0000000..2c8c9b0 Binary files /dev/null and b/res/drawable-xxhdpi/ic_sms_delivery_ok.png differ diff --git a/res/drawable-xxhdpi/ic_sms_failed_light.png b/res/drawable-xxhdpi/ic_sms_failed_light.png new file mode 100644 index 0000000..5da454b Binary files /dev/null and b/res/drawable-xxhdpi/ic_sms_failed_light.png differ diff --git a/res/drawable-xxhdpi/ic_sms_light.png b/res/drawable-xxhdpi/ic_sms_light.png new file mode 100644 index 0000000..7a8b491 Binary files /dev/null and b/res/drawable-xxhdpi/ic_sms_light.png differ diff --git a/res/drawable-xxhdpi/ic_sms_multi_light.png b/res/drawable-xxhdpi/ic_sms_multi_light.png new file mode 100644 index 0000000..6496cb8 Binary files /dev/null and b/res/drawable-xxhdpi/ic_sms_multi_light.png differ diff --git a/res/drawable-xxhdpi/ic_tooltip_arrow.png b/res/drawable-xxhdpi/ic_tooltip_arrow.png new file mode 100644 index 0000000..72f1c5b Binary files /dev/null and b/res/drawable-xxhdpi/ic_tooltip_arrow.png differ diff --git a/res/drawable-xxhdpi/ic_video_play_light.png b/res/drawable-xxhdpi/ic_video_play_light.png new file mode 100644 index 0000000..f49c653 Binary files /dev/null and b/res/drawable-xxhdpi/ic_video_play_light.png differ diff --git a/res/drawable-xxhdpi/ic_wear_reply.png b/res/drawable-xxhdpi/ic_wear_reply.png new file mode 100644 index 0000000..0790ee8 Binary files /dev/null and b/res/drawable-xxhdpi/ic_wear_reply.png differ diff --git a/res/drawable-xxhdpi/ic_widget_avatar_shadow.png b/res/drawable-xxhdpi/ic_widget_avatar_shadow.png new file mode 100644 index 0000000..ea4e26d Binary files /dev/null and b/res/drawable-xxhdpi/ic_widget_avatar_shadow.png differ diff --git a/res/drawable-xxhdpi/ic_widget_list.png b/res/drawable-xxhdpi/ic_widget_list.png new file mode 100644 index 0000000..c370ee8 Binary files /dev/null and b/res/drawable-xxhdpi/ic_widget_list.png differ diff --git a/res/drawable-xxhdpi/msg_bubble_error.9.png b/res/drawable-xxhdpi/msg_bubble_error.9.png new file mode 100644 index 0000000..f8bdc96 Binary files /dev/null and b/res/drawable-xxhdpi/msg_bubble_error.9.png differ diff --git a/res/drawable-xxhdpi/msg_bubble_incoming.9.png b/res/drawable-xxhdpi/msg_bubble_incoming.9.png new file mode 100644 index 0000000..ab693b3 Binary files /dev/null and b/res/drawable-xxhdpi/msg_bubble_incoming.9.png differ diff --git a/res/drawable-xxhdpi/msg_bubble_input.9.png b/res/drawable-xxhdpi/msg_bubble_input.9.png new file mode 100644 index 0000000..de41ef4 Binary files /dev/null and b/res/drawable-xxhdpi/msg_bubble_input.9.png differ diff --git a/res/drawable-xxhdpi/msg_bubble_outgoing.9.png b/res/drawable-xxhdpi/msg_bubble_outgoing.9.png new file mode 100644 index 0000000..5b2a2ff Binary files /dev/null and b/res/drawable-xxhdpi/msg_bubble_outgoing.9.png differ diff --git a/res/drawable-xxhdpi/permissions.png b/res/drawable-xxhdpi/permissions.png new file mode 100644 index 0000000..fd61476 Binary files /dev/null and b/res/drawable-xxhdpi/permissions.png differ diff --git a/res/drawable-xxhdpi/swipe_shadow.9.png b/res/drawable-xxhdpi/swipe_shadow.9.png new file mode 100644 index 0000000..0491faa Binary files /dev/null and b/res/drawable-xxhdpi/swipe_shadow.9.png differ diff --git a/res/drawable-xxhdpi/swipe_shadow_drag.9.png b/res/drawable-xxhdpi/swipe_shadow_drag.9.png new file mode 100644 index 0000000..0153c5b Binary files /dev/null and b/res/drawable-xxhdpi/swipe_shadow_drag.9.png differ diff --git a/res/drawable-xxhdpi/sym_keyboard_delete_holo.png b/res/drawable-xxhdpi/sym_keyboard_delete_holo.png new file mode 100644 index 0000000..f2adbec Binary files /dev/null and b/res/drawable-xxhdpi/sym_keyboard_delete_holo.png differ diff --git a/res/drawable-xxhdpi/tab_btn_bg_normal.9.png b/res/drawable-xxhdpi/tab_btn_bg_normal.9.png new file mode 100644 index 0000000..9f3a0f9 Binary files /dev/null and b/res/drawable-xxhdpi/tab_btn_bg_normal.9.png differ diff --git a/res/drawable-xxhdpi/tab_btn_bg_pressed.9.png b/res/drawable-xxhdpi/tab_btn_bg_pressed.9.png new file mode 100644 index 0000000..53f226e Binary files /dev/null and b/res/drawable-xxhdpi/tab_btn_bg_pressed.9.png differ diff --git a/res/drawable-xxhdpi/tab_selected.9.png b/res/drawable-xxhdpi/tab_selected.9.png new file mode 100644 index 0000000..e5efc58 Binary files /dev/null and b/res/drawable-xxhdpi/tab_selected.9.png differ diff --git a/res/drawable-xxhdpi/tab_selected_focused_holo.9.png b/res/drawable-xxhdpi/tab_selected_focused_holo.9.png new file mode 100644 index 0000000..8762b9e Binary files /dev/null and b/res/drawable-xxhdpi/tab_selected_focused_holo.9.png differ diff --git a/res/drawable-xxhdpi/tab_selected_pressed_focused_holo.9.png b/res/drawable-xxhdpi/tab_selected_pressed_focused_holo.9.png new file mode 100644 index 0000000..8faa7ff Binary files /dev/null and b/res/drawable-xxhdpi/tab_selected_pressed_focused_holo.9.png differ diff --git a/res/drawable-xxhdpi/tab_selected_pressed_holo.9.png b/res/drawable-xxhdpi/tab_selected_pressed_holo.9.png new file mode 100644 index 0000000..c7a539b Binary files /dev/null and b/res/drawable-xxhdpi/tab_selected_pressed_holo.9.png differ diff --git a/res/drawable-xxhdpi/tab_unselected.9.png b/res/drawable-xxhdpi/tab_unselected.9.png new file mode 100644 index 0000000..3891886 Binary files /dev/null and b/res/drawable-xxhdpi/tab_unselected.9.png differ diff --git a/res/drawable-xxhdpi/tab_unselected_focused_holo.9.png b/res/drawable-xxhdpi/tab_unselected_focused_holo.9.png new file mode 100644 index 0000000..462ab15 Binary files /dev/null and b/res/drawable-xxhdpi/tab_unselected_focused_holo.9.png differ diff --git a/res/drawable-xxhdpi/tab_unselected_pressed_focused_holo.9.png b/res/drawable-xxhdpi/tab_unselected_pressed_focused_holo.9.png new file mode 100644 index 0000000..58ddbe8 Binary files /dev/null and b/res/drawable-xxhdpi/tab_unselected_pressed_focused_holo.9.png differ diff --git a/res/drawable-xxhdpi/tab_unselected_pressed_holo.9.png b/res/drawable-xxhdpi/tab_unselected_pressed_holo.9.png new file mode 100644 index 0000000..de61933 Binary files /dev/null and b/res/drawable-xxhdpi/tab_unselected_pressed_holo.9.png differ diff --git a/res/drawable-xxhdpi/widget_hr.9.png b/res/drawable-xxhdpi/widget_hr.9.png new file mode 100644 index 0000000..375272b Binary files /dev/null and b/res/drawable-xxhdpi/widget_hr.9.png differ diff --git a/res/drawable-xxhdpi/widget_msg_bubble_incoming.9.png b/res/drawable-xxhdpi/widget_msg_bubble_incoming.9.png new file mode 100644 index 0000000..5093c23 Binary files /dev/null and b/res/drawable-xxhdpi/widget_msg_bubble_incoming.9.png differ diff --git a/res/drawable-xxhdpi/widget_msg_bubble_outgoing.9.png b/res/drawable-xxhdpi/widget_msg_bubble_outgoing.9.png new file mode 100644 index 0000000..6ae5608 Binary files /dev/null and b/res/drawable-xxhdpi/widget_msg_bubble_outgoing.9.png differ diff --git a/res/drawable-xxxhdpi/btn_keyboard_key_dark_normal_holo.9.png b/res/drawable-xxxhdpi/btn_keyboard_key_dark_normal_holo.9.png new file mode 100644 index 0000000..65cd55e Binary files /dev/null and b/res/drawable-xxxhdpi/btn_keyboard_key_dark_normal_holo.9.png differ diff --git a/res/drawable-xxxhdpi/btn_keyboard_key_dark_pressed_holo.9.png b/res/drawable-xxxhdpi/btn_keyboard_key_dark_pressed_holo.9.png new file mode 100644 index 0000000..b6cac56 Binary files /dev/null and b/res/drawable-xxxhdpi/btn_keyboard_key_dark_pressed_holo.9.png differ diff --git a/res/drawable-xxxhdpi/btn_keyboard_key_light_normal_holo.9.png b/res/drawable-xxxhdpi/btn_keyboard_key_light_normal_holo.9.png new file mode 100644 index 0000000..f017e03 Binary files /dev/null and b/res/drawable-xxxhdpi/btn_keyboard_key_light_normal_holo.9.png differ diff --git a/res/drawable-xxxhdpi/btn_keyboard_key_light_pressed_holo.9.png b/res/drawable-xxxhdpi/btn_keyboard_key_light_pressed_holo.9.png new file mode 100644 index 0000000..025e694 Binary files /dev/null and b/res/drawable-xxxhdpi/btn_keyboard_key_light_pressed_holo.9.png differ diff --git a/res/drawable-xxxhdpi/cab_bg.9.png b/res/drawable-xxxhdpi/cab_bg.9.png new file mode 100644 index 0000000..978cfad Binary files /dev/null and b/res/drawable-xxxhdpi/cab_bg.9.png differ diff --git a/res/drawable-xxxhdpi/contact_popup_background.9.png b/res/drawable-xxxhdpi/contact_popup_background.9.png new file mode 100644 index 0000000..30919fa Binary files /dev/null and b/res/drawable-xxxhdpi/contact_popup_background.9.png differ diff --git a/res/drawable-xxxhdpi/fab_new_message_static_shadow.png b/res/drawable-xxxhdpi/fab_new_message_static_shadow.png new file mode 100644 index 0000000..e1d16a6 Binary files /dev/null and b/res/drawable-xxxhdpi/fab_new_message_static_shadow.png differ diff --git a/res/drawable-xxxhdpi/ic_add_white.png b/res/drawable-xxxhdpi/ic_add_white.png new file mode 100644 index 0000000..0ed23b1 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_add_white.png differ diff --git a/res/drawable-xxxhdpi/ic_archive_small_dark.png b/res/drawable-xxxhdpi/ic_archive_small_dark.png new file mode 100644 index 0000000..c380363 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_archive_small_dark.png differ diff --git a/res/drawable-xxxhdpi/ic_archive_small_light.png b/res/drawable-xxxhdpi/ic_archive_small_light.png new file mode 100644 index 0000000..59ffef9 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_archive_small_light.png differ diff --git a/res/drawable-xxxhdpi/ic_archive_undo_small_dark.png b/res/drawable-xxxhdpi/ic_archive_undo_small_dark.png new file mode 100644 index 0000000..b2e4f8b Binary files /dev/null and b/res/drawable-xxxhdpi/ic_archive_undo_small_dark.png differ diff --git a/res/drawable-xxxhdpi/ic_archive_undo_small_light.png b/res/drawable-xxxhdpi/ic_archive_undo_small_light.png new file mode 100644 index 0000000..46fd7d8 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_archive_undo_small_light.png differ diff --git a/res/drawable-xxxhdpi/ic_arrow_back_dark.png b/res/drawable-xxxhdpi/ic_arrow_back_dark.png new file mode 100644 index 0000000..e7a9c81 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_arrow_back_dark.png differ diff --git a/res/drawable-xxxhdpi/ic_arrow_back_light.png b/res/drawable-xxxhdpi/ic_arrow_back_light.png new file mode 100644 index 0000000..54a5687 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_arrow_back_light.png differ diff --git a/res/drawable-xxxhdpi/ic_attachment_dark.png b/res/drawable-xxxhdpi/ic_attachment_dark.png new file mode 100644 index 0000000..7cf871a Binary files /dev/null and b/res/drawable-xxxhdpi/ic_attachment_dark.png differ diff --git a/res/drawable-xxxhdpi/ic_audio_light.png b/res/drawable-xxxhdpi/ic_audio_light.png new file mode 100644 index 0000000..768ac89 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_audio_light.png differ diff --git a/res/drawable-xxxhdpi/ic_audio_pause.png b/res/drawable-xxxhdpi/ic_audio_pause.png new file mode 100644 index 0000000..f7ae29f Binary files /dev/null and b/res/drawable-xxxhdpi/ic_audio_pause.png differ diff --git a/res/drawable-xxxhdpi/ic_audio_play.png b/res/drawable-xxxhdpi/ic_audio_play.png new file mode 100644 index 0000000..71f21e7 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_audio_play.png differ diff --git a/res/drawable-xxxhdpi/ic_camera_front_light.png b/res/drawable-xxxhdpi/ic_camera_front_light.png new file mode 100644 index 0000000..3af175b Binary files /dev/null and b/res/drawable-xxxhdpi/ic_camera_front_light.png differ diff --git a/res/drawable-xxxhdpi/ic_camera_light.png b/res/drawable-xxxhdpi/ic_camera_light.png new file mode 100644 index 0000000..c3484b6 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_camera_light.png differ diff --git a/res/drawable-xxxhdpi/ic_camera_rear_light.png b/res/drawable-xxxhdpi/ic_camera_rear_light.png new file mode 100644 index 0000000..c59b65b Binary files /dev/null and b/res/drawable-xxxhdpi/ic_camera_rear_light.png differ diff --git a/res/drawable-xxxhdpi/ic_cancel_small_dark.png b/res/drawable-xxxhdpi/ic_cancel_small_dark.png new file mode 100644 index 0000000..f454d2a Binary files /dev/null and b/res/drawable-xxxhdpi/ic_cancel_small_dark.png differ diff --git a/res/drawable-xxxhdpi/ic_cancel_small_light.png b/res/drawable-xxxhdpi/ic_cancel_small_light.png new file mode 100644 index 0000000..cce1904 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_cancel_small_light.png differ diff --git a/res/drawable-xxxhdpi/ic_checkbox_blank_light.png b/res/drawable-xxxhdpi/ic_checkbox_blank_light.png new file mode 100644 index 0000000..ad983af Binary files /dev/null and b/res/drawable-xxxhdpi/ic_checkbox_blank_light.png differ diff --git a/res/drawable-xxxhdpi/ic_checkbox_light.png b/res/drawable-xxxhdpi/ic_checkbox_light.png new file mode 100644 index 0000000..c8e8b79 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_checkbox_light.png differ diff --git a/res/drawable-xxxhdpi/ic_checkbox_outline_light.png b/res/drawable-xxxhdpi/ic_checkbox_outline_light.png new file mode 100644 index 0000000..eb0a803 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_checkbox_outline_light.png differ diff --git a/res/drawable-xxxhdpi/ic_checkmark_circle_blue.png b/res/drawable-xxxhdpi/ic_checkmark_circle_blue.png new file mode 100644 index 0000000..7bb1501 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_checkmark_circle_blue.png differ diff --git a/res/drawable-xxxhdpi/ic_checkmark_large.png b/res/drawable-xxxhdpi/ic_checkmark_large.png new file mode 100644 index 0000000..8119ff2 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_checkmark_large.png differ diff --git a/res/drawable-xxxhdpi/ic_checkmark_large_light.png b/res/drawable-xxxhdpi/ic_checkmark_large_light.png new file mode 100644 index 0000000..dddac27 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_checkmark_large_light.png differ diff --git a/res/drawable-xxxhdpi/ic_checkmark_small_blue.png b/res/drawable-xxxhdpi/ic_checkmark_small_blue.png new file mode 100644 index 0000000..3199069 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_checkmark_small_blue.png differ diff --git a/res/drawable-xxxhdpi/ic_checkmark_small_light.png b/res/drawable-xxxhdpi/ic_checkmark_small_light.png new file mode 100644 index 0000000..380f4e8 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_checkmark_small_light.png differ diff --git a/res/drawable-xxxhdpi/ic_color_lens.png b/res/drawable-xxxhdpi/ic_color_lens.png new file mode 100644 index 0000000..5c71071 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_color_lens.png differ diff --git a/res/drawable-xxxhdpi/ic_content_copy_dark.png b/res/drawable-xxxhdpi/ic_content_copy_dark.png new file mode 100644 index 0000000..24dc390 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_content_copy_dark.png differ diff --git a/res/drawable-xxxhdpi/ic_crying_hero.png b/res/drawable-xxxhdpi/ic_crying_hero.png new file mode 100644 index 0000000..0a428c9 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_crying_hero.png differ diff --git a/res/drawable-xxxhdpi/ic_delete_small_dark.png b/res/drawable-xxxhdpi/ic_delete_small_dark.png new file mode 100644 index 0000000..497e7a7 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_delete_small_dark.png differ diff --git a/res/drawable-xxxhdpi/ic_delete_small_light.png b/res/drawable-xxxhdpi/ic_delete_small_light.png new file mode 100644 index 0000000..d8a661f Binary files /dev/null and b/res/drawable-xxxhdpi/ic_delete_small_light.png differ diff --git a/res/drawable-xxxhdpi/ic_dnd_on_dark.png b/res/drawable-xxxhdpi/ic_dnd_on_dark.png new file mode 100644 index 0000000..de2f17b Binary files /dev/null and b/res/drawable-xxxhdpi/ic_dnd_on_dark.png differ diff --git a/res/drawable-xxxhdpi/ic_dnd_on_light.png b/res/drawable-xxxhdpi/ic_dnd_on_light.png new file mode 100644 index 0000000..7851985 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_dnd_on_light.png differ diff --git a/res/drawable-xxxhdpi/ic_failed_light.png b/res/drawable-xxxhdpi/ic_failed_light.png new file mode 100644 index 0000000..53d374c Binary files /dev/null and b/res/drawable-xxxhdpi/ic_failed_light.png differ diff --git a/res/drawable-xxxhdpi/ic_failed_status_red.png b/res/drawable-xxxhdpi/ic_failed_status_red.png new file mode 100644 index 0000000..ee1ed24 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_failed_status_red.png differ diff --git a/res/drawable-xxxhdpi/ic_file_download_dark.png b/res/drawable-xxxhdpi/ic_file_download_dark.png new file mode 100644 index 0000000..0012e5e Binary files /dev/null and b/res/drawable-xxxhdpi/ic_file_download_dark.png differ diff --git a/res/drawable-xxxhdpi/ic_file_download_light.png b/res/drawable-xxxhdpi/ic_file_download_light.png new file mode 100644 index 0000000..6d7ca08 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_file_download_light.png differ diff --git a/res/drawable-xxxhdpi/ic_forward_dark.png b/res/drawable-xxxhdpi/ic_forward_dark.png new file mode 100644 index 0000000..6408fb9 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_forward_dark.png differ diff --git a/res/drawable-xxxhdpi/ic_history_light.png b/res/drawable-xxxhdpi/ic_history_light.png new file mode 100644 index 0000000..7ff42b7 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_history_light.png differ diff --git a/res/drawable-xxxhdpi/ic_image_light.png b/res/drawable-xxxhdpi/ic_image_light.png new file mode 100644 index 0000000..0d229e2 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_image_light.png differ diff --git a/res/drawable-xxxhdpi/ic_ime_dark.png b/res/drawable-xxxhdpi/ic_ime_dark.png new file mode 100644 index 0000000..7c051df Binary files /dev/null and b/res/drawable-xxxhdpi/ic_ime_dark.png differ diff --git a/res/drawable-xxxhdpi/ic_ime_light.png b/res/drawable-xxxhdpi/ic_ime_light.png new file mode 100644 index 0000000..031d086 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_ime_light.png differ diff --git a/res/drawable-xxxhdpi/ic_info_dark.png b/res/drawable-xxxhdpi/ic_info_dark.png new file mode 100644 index 0000000..107385b Binary files /dev/null and b/res/drawable-xxxhdpi/ic_info_dark.png differ diff --git a/res/drawable-xxxhdpi/ic_keyboard_arrow_left_light.png b/res/drawable-xxxhdpi/ic_keyboard_arrow_left_light.png new file mode 100644 index 0000000..3737fec Binary files /dev/null and b/res/drawable-xxxhdpi/ic_keyboard_arrow_left_light.png differ diff --git a/res/drawable-xxxhdpi/ic_keyboard_arrow_right_light.png b/res/drawable-xxxhdpi/ic_keyboard_arrow_right_light.png new file mode 100644 index 0000000..85d005b Binary files /dev/null and b/res/drawable-xxxhdpi/ic_keyboard_arrow_right_light.png differ diff --git a/res/drawable-xxxhdpi/ic_launcher.png b/res/drawable-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..0d61198 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_launcher.png differ diff --git a/res/drawable-xxxhdpi/ic_mp_audio_mic.png b/res/drawable-xxxhdpi/ic_mp_audio_mic.png new file mode 100644 index 0000000..9f51c4b Binary files /dev/null and b/res/drawable-xxxhdpi/ic_mp_audio_mic.png differ diff --git a/res/drawable-xxxhdpi/ic_mp_camera_large_light.png b/res/drawable-xxxhdpi/ic_mp_camera_large_light.png new file mode 100644 index 0000000..f915989 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_mp_camera_large_light.png differ diff --git a/res/drawable-xxxhdpi/ic_mp_camera_small_light.png b/res/drawable-xxxhdpi/ic_mp_camera_small_light.png new file mode 100644 index 0000000..d6d8c9d Binary files /dev/null and b/res/drawable-xxxhdpi/ic_mp_camera_small_light.png differ diff --git a/res/drawable-xxxhdpi/ic_mp_capture_stop_large_light.png b/res/drawable-xxxhdpi/ic_mp_capture_stop_large_light.png new file mode 100644 index 0000000..30e725f Binary files /dev/null and b/res/drawable-xxxhdpi/ic_mp_capture_stop_large_light.png differ diff --git a/res/drawable-xxxhdpi/ic_mp_full_screen_light.png b/res/drawable-xxxhdpi/ic_mp_full_screen_light.png new file mode 100644 index 0000000..4860109 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_mp_full_screen_light.png differ diff --git a/res/drawable-xxxhdpi/ic_mp_video_large_light.png b/res/drawable-xxxhdpi/ic_mp_video_large_light.png new file mode 100644 index 0000000..32c4317 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_mp_video_large_light.png differ diff --git a/res/drawable-xxxhdpi/ic_mp_video_small_light.png b/res/drawable-xxxhdpi/ic_mp_video_small_light.png new file mode 100644 index 0000000..6d2a14b Binary files /dev/null and b/res/drawable-xxxhdpi/ic_mp_video_small_light.png differ diff --git a/res/drawable-xxxhdpi/ic_notifications_off_dark.png b/res/drawable-xxxhdpi/ic_notifications_off_dark.png new file mode 100644 index 0000000..10ac318 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_notifications_off_dark.png differ diff --git a/res/drawable-xxxhdpi/ic_notifications_off_light.png b/res/drawable-xxxhdpi/ic_notifications_off_light.png new file mode 100644 index 0000000..8eb5782 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_notifications_off_light.png differ diff --git a/res/drawable-xxxhdpi/ic_notifications_off_small_light.png b/res/drawable-xxxhdpi/ic_notifications_off_small_light.png new file mode 100644 index 0000000..3756943 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_notifications_off_small_light.png differ diff --git a/res/drawable-xxxhdpi/ic_notifications_on_dark.png b/res/drawable-xxxhdpi/ic_notifications_on_dark.png new file mode 100644 index 0000000..3896212 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_notifications_on_dark.png differ diff --git a/res/drawable-xxxhdpi/ic_notifications_on_light.png b/res/drawable-xxxhdpi/ic_notifications_on_light.png new file mode 100644 index 0000000..47794fd Binary files /dev/null and b/res/drawable-xxxhdpi/ic_notifications_on_light.png differ diff --git a/res/drawable-xxxhdpi/ic_numeric_dialpad.png b/res/drawable-xxxhdpi/ic_numeric_dialpad.png new file mode 100644 index 0000000..5e0986c Binary files /dev/null and b/res/drawable-xxxhdpi/ic_numeric_dialpad.png differ diff --git a/res/drawable-xxxhdpi/ic_oobe_conv_list.png b/res/drawable-xxxhdpi/ic_oobe_conv_list.png new file mode 100644 index 0000000..0decb23 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_oobe_conv_list.png differ diff --git a/res/drawable-xxxhdpi/ic_oobe_freq_list.png b/res/drawable-xxxhdpi/ic_oobe_freq_list.png new file mode 100644 index 0000000..4522cb6 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_oobe_freq_list.png differ diff --git a/res/drawable-xxxhdpi/ic_open_in_new.png b/res/drawable-xxxhdpi/ic_open_in_new.png new file mode 100644 index 0000000..6d6b59f Binary files /dev/null and b/res/drawable-xxxhdpi/ic_open_in_new.png differ diff --git a/res/drawable-xxxhdpi/ic_people_add_light.png b/res/drawable-xxxhdpi/ic_people_add_light.png new file mode 100644 index 0000000..5db76a9 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_people_add_light.png differ diff --git a/res/drawable-xxxhdpi/ic_person_add_blue.png b/res/drawable-xxxhdpi/ic_person_add_blue.png new file mode 100644 index 0000000..b358660 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_person_add_blue.png differ diff --git a/res/drawable-xxxhdpi/ic_person_add_dark.png b/res/drawable-xxxhdpi/ic_person_add_dark.png new file mode 100644 index 0000000..37e252f Binary files /dev/null and b/res/drawable-xxxhdpi/ic_person_add_dark.png differ diff --git a/res/drawable-xxxhdpi/ic_person_add_light.png b/res/drawable-xxxhdpi/ic_person_add_light.png new file mode 100644 index 0000000..b279246 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_person_add_light.png differ diff --git a/res/drawable-xxxhdpi/ic_person_light.png b/res/drawable-xxxhdpi/ic_person_light.png new file mode 100644 index 0000000..59853fc Binary files /dev/null and b/res/drawable-xxxhdpi/ic_person_light.png differ diff --git a/res/drawable-xxxhdpi/ic_person_light_large.png b/res/drawable-xxxhdpi/ic_person_light_large.png new file mode 100644 index 0000000..44d4809 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_person_light_large.png differ diff --git a/res/drawable-xxxhdpi/ic_phone_small_light.png b/res/drawable-xxxhdpi/ic_phone_small_light.png new file mode 100644 index 0000000..433e4a7 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_phone_small_light.png differ diff --git a/res/drawable-xxxhdpi/ic_photo_library_light.png b/res/drawable-xxxhdpi/ic_photo_library_light.png new file mode 100644 index 0000000..8627f42 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_photo_library_light.png differ diff --git a/res/drawable-xxxhdpi/ic_preview_pause.png b/res/drawable-xxxhdpi/ic_preview_pause.png new file mode 100644 index 0000000..2ba4fe2 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_preview_pause.png differ diff --git a/res/drawable-xxxhdpi/ic_preview_play.png b/res/drawable-xxxhdpi/ic_preview_play.png new file mode 100644 index 0000000..f44f572 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_preview_play.png differ diff --git a/res/drawable-xxxhdpi/ic_remove_light.png b/res/drawable-xxxhdpi/ic_remove_light.png new file mode 100644 index 0000000..b9288bf Binary files /dev/null and b/res/drawable-xxxhdpi/ic_remove_light.png differ diff --git a/res/drawable-xxxhdpi/ic_remove_small_light.png b/res/drawable-xxxhdpi/ic_remove_small_light.png new file mode 100644 index 0000000..0e0cb05 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_remove_small_light.png differ diff --git a/res/drawable-xxxhdpi/ic_save_dark.png b/res/drawable-xxxhdpi/ic_save_dark.png new file mode 100644 index 0000000..2819de2 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_save_dark.png differ diff --git a/res/drawable-xxxhdpi/ic_save_light.png b/res/drawable-xxxhdpi/ic_save_light.png new file mode 100644 index 0000000..ba7564b Binary files /dev/null and b/res/drawable-xxxhdpi/ic_save_light.png differ diff --git a/res/drawable-xxxhdpi/ic_search_light.png b/res/drawable-xxxhdpi/ic_search_light.png new file mode 100644 index 0000000..b38785a Binary files /dev/null and b/res/drawable-xxxhdpi/ic_search_light.png differ diff --git a/res/drawable-xxxhdpi/ic_send_dark.png b/res/drawable-xxxhdpi/ic_send_dark.png new file mode 100644 index 0000000..5f52b29 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_send_dark.png differ diff --git a/res/drawable-xxxhdpi/ic_send_light.png b/res/drawable-xxxhdpi/ic_send_light.png new file mode 100644 index 0000000..b4e8563 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_send_light.png differ diff --git a/res/drawable-xxxhdpi/ic_share_dark.png b/res/drawable-xxxhdpi/ic_share_dark.png new file mode 100644 index 0000000..a6ddd2d Binary files /dev/null and b/res/drawable-xxxhdpi/ic_share_dark.png differ diff --git a/res/drawable-xxxhdpi/ic_share_light.png b/res/drawable-xxxhdpi/ic_share_light.png new file mode 100644 index 0000000..ac63637 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_share_light.png differ diff --git a/res/drawable-xxxhdpi/ic_sim_card_send.png b/res/drawable-xxxhdpi/ic_sim_card_send.png new file mode 100644 index 0000000..0db661b Binary files /dev/null and b/res/drawable-xxxhdpi/ic_sim_card_send.png differ diff --git a/res/drawable-xxxhdpi/ic_sms_delivery_ok.png b/res/drawable-xxxhdpi/ic_sms_delivery_ok.png new file mode 100644 index 0000000..487525d Binary files /dev/null and b/res/drawable-xxxhdpi/ic_sms_delivery_ok.png differ diff --git a/res/drawable-xxxhdpi/ic_sms_failed_light.png b/res/drawable-xxxhdpi/ic_sms_failed_light.png new file mode 100644 index 0000000..2daf2ae Binary files /dev/null and b/res/drawable-xxxhdpi/ic_sms_failed_light.png differ diff --git a/res/drawable-xxxhdpi/ic_sms_light.png b/res/drawable-xxxhdpi/ic_sms_light.png new file mode 100644 index 0000000..98d1bc8 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_sms_light.png differ diff --git a/res/drawable-xxxhdpi/ic_sms_multi_light.png b/res/drawable-xxxhdpi/ic_sms_multi_light.png new file mode 100644 index 0000000..e2f4bfc Binary files /dev/null and b/res/drawable-xxxhdpi/ic_sms_multi_light.png differ diff --git a/res/drawable-xxxhdpi/ic_tooltip_arrow.png b/res/drawable-xxxhdpi/ic_tooltip_arrow.png new file mode 100644 index 0000000..84b03ad Binary files /dev/null and b/res/drawable-xxxhdpi/ic_tooltip_arrow.png differ diff --git a/res/drawable-xxxhdpi/ic_video_play_light.png b/res/drawable-xxxhdpi/ic_video_play_light.png new file mode 100644 index 0000000..9192f84 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_video_play_light.png differ diff --git a/res/drawable-xxxhdpi/ic_wear_reply.png b/res/drawable-xxxhdpi/ic_wear_reply.png new file mode 100644 index 0000000..34065f6 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_wear_reply.png differ diff --git a/res/drawable-xxxhdpi/ic_widget_avatar_shadow.png b/res/drawable-xxxhdpi/ic_widget_avatar_shadow.png new file mode 100644 index 0000000..83d02ce Binary files /dev/null and b/res/drawable-xxxhdpi/ic_widget_avatar_shadow.png differ diff --git a/res/drawable-xxxhdpi/ic_widget_list.png b/res/drawable-xxxhdpi/ic_widget_list.png new file mode 100644 index 0000000..f316487 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_widget_list.png differ diff --git a/res/drawable-xxxhdpi/msg_bubble_error.9.png b/res/drawable-xxxhdpi/msg_bubble_error.9.png new file mode 100644 index 0000000..fb8161c Binary files /dev/null and b/res/drawable-xxxhdpi/msg_bubble_error.9.png differ diff --git a/res/drawable-xxxhdpi/msg_bubble_incoming.9.png b/res/drawable-xxxhdpi/msg_bubble_incoming.9.png new file mode 100644 index 0000000..34130fc Binary files /dev/null and b/res/drawable-xxxhdpi/msg_bubble_incoming.9.png differ diff --git a/res/drawable-xxxhdpi/msg_bubble_input.9.png b/res/drawable-xxxhdpi/msg_bubble_input.9.png new file mode 100644 index 0000000..d330459 Binary files /dev/null and b/res/drawable-xxxhdpi/msg_bubble_input.9.png differ diff --git a/res/drawable-xxxhdpi/msg_bubble_outgoing.9.png b/res/drawable-xxxhdpi/msg_bubble_outgoing.9.png new file mode 100644 index 0000000..cfd5734 Binary files /dev/null and b/res/drawable-xxxhdpi/msg_bubble_outgoing.9.png differ diff --git a/res/drawable-xxxhdpi/permissions.png b/res/drawable-xxxhdpi/permissions.png new file mode 100644 index 0000000..2a7a0d7 Binary files /dev/null and b/res/drawable-xxxhdpi/permissions.png differ diff --git a/res/drawable-xxxhdpi/swipe_shadow.9.png b/res/drawable-xxxhdpi/swipe_shadow.9.png new file mode 100644 index 0000000..4726ef4 Binary files /dev/null and b/res/drawable-xxxhdpi/swipe_shadow.9.png differ diff --git a/res/drawable-xxxhdpi/swipe_shadow_drag.9.png b/res/drawable-xxxhdpi/swipe_shadow_drag.9.png new file mode 100644 index 0000000..d48e311 Binary files /dev/null and b/res/drawable-xxxhdpi/swipe_shadow_drag.9.png differ diff --git a/res/drawable-xxxhdpi/sym_keyboard_delete_holo.png b/res/drawable-xxxhdpi/sym_keyboard_delete_holo.png new file mode 100644 index 0000000..09af002 Binary files /dev/null and b/res/drawable-xxxhdpi/sym_keyboard_delete_holo.png differ diff --git a/res/drawable-xxxhdpi/tab_btn_bg_normal.9.png b/res/drawable-xxxhdpi/tab_btn_bg_normal.9.png new file mode 100644 index 0000000..1290a0d Binary files /dev/null and b/res/drawable-xxxhdpi/tab_btn_bg_normal.9.png differ diff --git a/res/drawable-xxxhdpi/tab_btn_bg_pressed.9.png b/res/drawable-xxxhdpi/tab_btn_bg_pressed.9.png new file mode 100644 index 0000000..9fce798 Binary files /dev/null and b/res/drawable-xxxhdpi/tab_btn_bg_pressed.9.png differ diff --git a/res/drawable-xxxhdpi/tab_selected.9.png b/res/drawable-xxxhdpi/tab_selected.9.png new file mode 100644 index 0000000..5c20d4d Binary files /dev/null and b/res/drawable-xxxhdpi/tab_selected.9.png differ diff --git a/res/drawable-xxxhdpi/tab_selected_focused_holo.9.png b/res/drawable-xxxhdpi/tab_selected_focused_holo.9.png new file mode 100644 index 0000000..33be972 Binary files /dev/null and b/res/drawable-xxxhdpi/tab_selected_focused_holo.9.png differ diff --git a/res/drawable-xxxhdpi/tab_selected_pressed_focused_holo.9.png b/res/drawable-xxxhdpi/tab_selected_pressed_focused_holo.9.png new file mode 100644 index 0000000..dc38a52 Binary files /dev/null and b/res/drawable-xxxhdpi/tab_selected_pressed_focused_holo.9.png differ diff --git a/res/drawable-xxxhdpi/tab_selected_pressed_holo.9.png b/res/drawable-xxxhdpi/tab_selected_pressed_holo.9.png new file mode 100644 index 0000000..e4518a8 Binary files /dev/null and b/res/drawable-xxxhdpi/tab_selected_pressed_holo.9.png differ diff --git a/res/drawable-xxxhdpi/tab_unselected.9.png b/res/drawable-xxxhdpi/tab_unselected.9.png new file mode 100644 index 0000000..9df735b Binary files /dev/null and b/res/drawable-xxxhdpi/tab_unselected.9.png differ diff --git a/res/drawable-xxxhdpi/tab_unselected_focused_holo.9.png b/res/drawable-xxxhdpi/tab_unselected_focused_holo.9.png new file mode 100644 index 0000000..f01cfaf Binary files /dev/null and b/res/drawable-xxxhdpi/tab_unselected_focused_holo.9.png differ diff --git a/res/drawable-xxxhdpi/tab_unselected_pressed_focused_holo.9.png b/res/drawable-xxxhdpi/tab_unselected_pressed_focused_holo.9.png new file mode 100644 index 0000000..7d85b20 Binary files /dev/null and b/res/drawable-xxxhdpi/tab_unselected_pressed_focused_holo.9.png differ diff --git a/res/drawable-xxxhdpi/tab_unselected_pressed_holo.9.png b/res/drawable-xxxhdpi/tab_unselected_pressed_holo.9.png new file mode 100644 index 0000000..0ace170 Binary files /dev/null and b/res/drawable-xxxhdpi/tab_unselected_pressed_holo.9.png differ diff --git a/res/drawable-xxxhdpi/widget_hr.9.png b/res/drawable-xxxhdpi/widget_hr.9.png new file mode 100644 index 0000000..80fdaf6 Binary files /dev/null and b/res/drawable-xxxhdpi/widget_hr.9.png differ diff --git a/res/drawable-xxxhdpi/widget_msg_bubble_incoming.9.png b/res/drawable-xxxhdpi/widget_msg_bubble_incoming.9.png new file mode 100644 index 0000000..383133e Binary files /dev/null and b/res/drawable-xxxhdpi/widget_msg_bubble_incoming.9.png differ diff --git a/res/drawable-xxxhdpi/widget_msg_bubble_outgoing.9.png b/res/drawable-xxxhdpi/widget_msg_bubble_outgoing.9.png new file mode 100644 index 0000000..daf33b5 Binary files /dev/null and b/res/drawable-xxxhdpi/widget_msg_bubble_outgoing.9.png differ diff --git a/res/drawable/attachment_audio_preview_background.xml b/res/drawable/attachment_audio_preview_background.xml new file mode 100644 index 0000000..8b62415 --- /dev/null +++ b/res/drawable/attachment_audio_preview_background.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/res/drawable/attachment_image_placeholder_background.xml b/res/drawable/attachment_image_placeholder_background.xml new file mode 100644 index 0000000..b3315e8 --- /dev/null +++ b/res/drawable/attachment_image_placeholder_background.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/res/drawable/attachment_more_items_background.xml b/res/drawable/attachment_more_items_background.xml new file mode 100644 index 0000000..0ef8e61 --- /dev/null +++ b/res/drawable/attachment_more_items_background.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/res/drawable/attachment_vcard_preview_background.xml b/res/drawable/attachment_vcard_preview_background.xml new file mode 100644 index 0000000..8b62415 --- /dev/null +++ b/res/drawable/attachment_vcard_preview_background.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/res/drawable/audio_progress_bar_background_incoming.xml b/res/drawable/audio_progress_bar_background_incoming.xml new file mode 100644 index 0000000..990e1de --- /dev/null +++ b/res/drawable/audio_progress_bar_background_incoming.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/res/drawable/audio_progress_bar_background_outgoing.xml b/res/drawable/audio_progress_bar_background_outgoing.xml new file mode 100644 index 0000000..351cf18 --- /dev/null +++ b/res/drawable/audio_progress_bar_background_outgoing.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/res/drawable/audio_progress_bar_progress.xml b/res/drawable/audio_progress_bar_progress.xml new file mode 100644 index 0000000..a70ff32 --- /dev/null +++ b/res/drawable/audio_progress_bar_progress.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/res/drawable/audio_record_control_button_background.xml b/res/drawable/audio_record_control_button_background.xml new file mode 100644 index 0000000..02f329e --- /dev/null +++ b/res/drawable/audio_record_control_button_background.xml @@ -0,0 +1,26 @@ + + + + + + + \ No newline at end of file diff --git a/res/drawable/chips_dropdown_background.xml b/res/drawable/chips_dropdown_background.xml new file mode 100644 index 0000000..48a727d --- /dev/null +++ b/res/drawable/chips_dropdown_background.xml @@ -0,0 +1,22 @@ + + + + + + + \ No newline at end of file diff --git a/res/drawable/chips_dropdown_text_color.xml b/res/drawable/chips_dropdown_text_color.xml new file mode 100644 index 0000000..5f06751 --- /dev/null +++ b/res/drawable/chips_dropdown_text_color.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/res/drawable/compose_chips_divider_gradient.xml b/res/drawable/compose_chips_divider_gradient.xml new file mode 100644 index 0000000..65c6601 --- /dev/null +++ b/res/drawable/compose_chips_divider_gradient.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/res/drawable/contact_picker_tab_background_selector.xml b/res/drawable/contact_picker_tab_background_selector.xml new file mode 100644 index 0000000..78c443b --- /dev/null +++ b/res/drawable/contact_picker_tab_background_selector.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/res/drawable/contacts_fastscroll_label_left.xml b/res/drawable/contacts_fastscroll_label_left.xml new file mode 100644 index 0000000..5ed079a --- /dev/null +++ b/res/drawable/contacts_fastscroll_label_left.xml @@ -0,0 +1,28 @@ + + + + + + + + + \ No newline at end of file diff --git a/res/drawable/contacts_fastscroll_label_right.xml b/res/drawable/contacts_fastscroll_label_right.xml new file mode 100644 index 0000000..67c4622 --- /dev/null +++ b/res/drawable/contacts_fastscroll_label_right.xml @@ -0,0 +1,28 @@ + + + + + + + + + \ No newline at end of file diff --git a/res/drawable/conversation_compose_divider_gradient.xml b/res/drawable/conversation_compose_divider_gradient.xml new file mode 100644 index 0000000..2c8a180 --- /dev/null +++ b/res/drawable/conversation_compose_divider_gradient.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/res/drawable/default_image.xml b/res/drawable/default_image.xml new file mode 100644 index 0000000..749e73b --- /dev/null +++ b/res/drawable/default_image.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + diff --git a/res/drawable/exit_button_bg.xml b/res/drawable/exit_button_bg.xml new file mode 100644 index 0000000..9fa0fb2 --- /dev/null +++ b/res/drawable/exit_button_bg.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/res/drawable/fab_new_message_bg.xml b/res/drawable/fab_new_message_bg.xml new file mode 100644 index 0000000..741b4b5 --- /dev/null +++ b/res/drawable/fab_new_message_bg.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/drawable/fastscroll_preview_left.xml b/res/drawable/fastscroll_preview_left.xml new file mode 100644 index 0000000..165a222 --- /dev/null +++ b/res/drawable/fastscroll_preview_left.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/res/drawable/fastscroll_preview_right.xml b/res/drawable/fastscroll_preview_right.xml new file mode 100644 index 0000000..7c0dc09 --- /dev/null +++ b/res/drawable/fastscroll_preview_right.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/res/drawable/fastscroll_thumb.xml b/res/drawable/fastscroll_thumb.xml new file mode 100644 index 0000000..be7c96f --- /dev/null +++ b/res/drawable/fastscroll_thumb.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/res/drawable/fastscroll_thumb_pressed.xml b/res/drawable/fastscroll_thumb_pressed.xml new file mode 100644 index 0000000..39de5a6 --- /dev/null +++ b/res/drawable/fastscroll_thumb_pressed.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/res/drawable/fastscroll_track.xml b/res/drawable/fastscroll_track.xml new file mode 100644 index 0000000..f3bf96d --- /dev/null +++ b/res/drawable/fastscroll_track.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/res/drawable/gallery_checkbox_selector.xml b/res/drawable/gallery_checkbox_selector.xml new file mode 100644 index 0000000..cdd9734 --- /dev/null +++ b/res/drawable/gallery_checkbox_selector.xml @@ -0,0 +1,27 @@ + + + + + + + \ No newline at end of file diff --git a/res/drawable/gallery_document_picker_item_background.xml b/res/drawable/gallery_document_picker_item_background.xml new file mode 100644 index 0000000..4bbd7fe --- /dev/null +++ b/res/drawable/gallery_document_picker_item_background.xml @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/res/drawable/gallery_image_background_selector.xml b/res/drawable/gallery_image_background_selector.xml new file mode 100644 index 0000000..0b902fd --- /dev/null +++ b/res/drawable/gallery_image_background_selector.xml @@ -0,0 +1,23 @@ + + + + + + \ No newline at end of file diff --git a/res/drawable/generic_video_icon.xml b/res/drawable/generic_video_icon.xml new file mode 100644 index 0000000..e98cb94 --- /dev/null +++ b/res/drawable/generic_video_icon.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/res/drawable/mediapicker_tab_button_background.xml b/res/drawable/mediapicker_tab_button_background.xml new file mode 100644 index 0000000..08dbf5c --- /dev/null +++ b/res/drawable/mediapicker_tab_button_background.xml @@ -0,0 +1,26 @@ + + + + + + + \ No newline at end of file diff --git a/res/drawable/message_bubble_incoming_no_arrow.xml b/res/drawable/message_bubble_incoming_no_arrow.xml new file mode 100644 index 0000000..d234508 --- /dev/null +++ b/res/drawable/message_bubble_incoming_no_arrow.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/res/drawable/message_bubble_outgoing_no_arrow.xml b/res/drawable/message_bubble_outgoing_no_arrow.xml new file mode 100644 index 0000000..d234508 --- /dev/null +++ b/res/drawable/message_bubble_outgoing_no_arrow.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/res/drawable/send_arrow_background.xml b/res/drawable/send_arrow_background.xml new file mode 100644 index 0000000..4434997 --- /dev/null +++ b/res/drawable/send_arrow_background.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/drawable/sim_selector_background_gradient.xml b/res/drawable/sim_selector_background_gradient.xml new file mode 100644 index 0000000..ea7e440 --- /dev/null +++ b/res/drawable/sim_selector_background_gradient.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/res/drawable/stat_notify_chat.png b/res/drawable/stat_notify_chat.png new file mode 100644 index 0000000..cc08e21 Binary files /dev/null and b/res/drawable/stat_notify_chat.png differ diff --git a/res/drawable/subject_editor_bubble.xml b/res/drawable/subject_editor_bubble.xml new file mode 100644 index 0000000..21f0248 --- /dev/null +++ b/res/drawable/subject_editor_bubble.xml @@ -0,0 +1,24 @@ + + + + + + + + + + \ No newline at end of file diff --git a/res/drawable/tab_btn_bg.xml b/res/drawable/tab_btn_bg.xml new file mode 100644 index 0000000..f7f03dd --- /dev/null +++ b/res/drawable/tab_btn_bg.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/res/drawable/tab_indicator.xml b/res/drawable/tab_indicator.xml new file mode 100644 index 0000000..28ecda3 --- /dev/null +++ b/res/drawable/tab_indicator.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/res/drawable/transparent_button_background.xml b/res/drawable/transparent_button_background.xml new file mode 100644 index 0000000..08dbf5c --- /dev/null +++ b/res/drawable/transparent_button_background.xml @@ -0,0 +1,26 @@ + + + + + + + \ No newline at end of file diff --git a/res/drawable/widget_bottom_background.xml b/res/drawable/widget_bottom_background.xml new file mode 100644 index 0000000..198b2d6 --- /dev/null +++ b/res/drawable/widget_bottom_background.xml @@ -0,0 +1,25 @@ + + + + + + \ No newline at end of file diff --git a/res/drawable/widget_top_background.xml b/res/drawable/widget_top_background.xml new file mode 100644 index 0000000..1476c95 --- /dev/null +++ b/res/drawable/widget_top_background.xml @@ -0,0 +1,25 @@ + + + + + + \ No newline at end of file diff --git a/res/layout/action_bar_conversation_name.xml b/res/layout/action_bar_conversation_name.xml new file mode 100644 index 0000000..586cf83 --- /dev/null +++ b/res/layout/action_bar_conversation_name.xml @@ -0,0 +1,31 @@ + + + + + + \ No newline at end of file diff --git a/res/layout/add_contacts_confirmation_dialog_body.xml b/res/layout/add_contacts_confirmation_dialog_body.xml new file mode 100644 index 0000000..07f3d0f --- /dev/null +++ b/res/layout/add_contacts_confirmation_dialog_body.xml @@ -0,0 +1,45 @@ + + + + + + + + + diff --git a/res/layout/all_contacts_list_view.xml b/res/layout/all_contacts_list_view.xml new file mode 100644 index 0000000..fe05c4d --- /dev/null +++ b/res/layout/all_contacts_list_view.xml @@ -0,0 +1,38 @@ + + + + + + + + + diff --git a/res/layout/apn_preference_layout.xml b/res/layout/apn_preference_layout.xml new file mode 100644 index 0000000..25a0323 --- /dev/null +++ b/res/layout/apn_preference_layout.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + diff --git a/res/layout/attachment_chooser_activity.xml b/res/layout/attachment_chooser_activity.xml new file mode 100644 index 0000000..850716e --- /dev/null +++ b/res/layout/attachment_chooser_activity.xml @@ -0,0 +1,22 @@ + + + \ No newline at end of file diff --git a/res/layout/attachment_chooser_audio.xml b/res/layout/attachment_chooser_audio.xml new file mode 100644 index 0000000..ea2f431 --- /dev/null +++ b/res/layout/attachment_chooser_audio.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/res/layout/attachment_chooser_fragment.xml b/res/layout/attachment_chooser_fragment.xml new file mode 100644 index 0000000..15e249a --- /dev/null +++ b/res/layout/attachment_chooser_fragment.xml @@ -0,0 +1,29 @@ + + + diff --git a/res/layout/attachment_chooser_image.xml b/res/layout/attachment_chooser_image.xml new file mode 100644 index 0000000..b635161 --- /dev/null +++ b/res/layout/attachment_chooser_image.xml @@ -0,0 +1,26 @@ + + + diff --git a/res/layout/attachment_chooser_vcard.xml b/res/layout/attachment_chooser_vcard.xml new file mode 100644 index 0000000..fb20f84 --- /dev/null +++ b/res/layout/attachment_chooser_vcard.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/res/layout/attachment_chooser_video.xml b/res/layout/attachment_chooser_video.xml new file mode 100644 index 0000000..4ae4b0a --- /dev/null +++ b/res/layout/attachment_chooser_video.xml @@ -0,0 +1,24 @@ + + + diff --git a/res/layout/attachment_grid_item_view.xml b/res/layout/attachment_grid_item_view.xml new file mode 100644 index 0000000..639ea64 --- /dev/null +++ b/res/layout/attachment_grid_item_view.xml @@ -0,0 +1,43 @@ + + + + + + + + + + diff --git a/res/layout/attachment_more_text_view.xml b/res/layout/attachment_more_text_view.xml new file mode 100644 index 0000000..1a99a63 --- /dev/null +++ b/res/layout/attachment_more_text_view.xml @@ -0,0 +1,22 @@ + + + \ No newline at end of file diff --git a/res/layout/attachment_multiple_audio.xml b/res/layout/attachment_multiple_audio.xml new file mode 100644 index 0000000..48440f0 --- /dev/null +++ b/res/layout/attachment_multiple_audio.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/res/layout/attachment_multiple_image.xml b/res/layout/attachment_multiple_image.xml new file mode 100644 index 0000000..7d911ac --- /dev/null +++ b/res/layout/attachment_multiple_image.xml @@ -0,0 +1,29 @@ + + + + diff --git a/res/layout/attachment_multiple_vcard.xml b/res/layout/attachment_multiple_vcard.xml new file mode 100644 index 0000000..e03d47b --- /dev/null +++ b/res/layout/attachment_multiple_vcard.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/res/layout/attachment_multiple_video.xml b/res/layout/attachment_multiple_video.xml new file mode 100644 index 0000000..fdbc423 --- /dev/null +++ b/res/layout/attachment_multiple_video.xml @@ -0,0 +1,25 @@ + + + + diff --git a/res/layout/attachment_pending_item.xml b/res/layout/attachment_pending_item.xml new file mode 100644 index 0000000..3e9bec7 --- /dev/null +++ b/res/layout/attachment_pending_item.xml @@ -0,0 +1,44 @@ + + + + + + + + + diff --git a/res/layout/attachment_preview.xml b/res/layout/attachment_preview.xml new file mode 100644 index 0000000..36ea038 --- /dev/null +++ b/res/layout/attachment_preview.xml @@ -0,0 +1,44 @@ + + + + + + + + + + \ No newline at end of file diff --git a/res/layout/attachment_single_audio.xml b/res/layout/attachment_single_audio.xml new file mode 100644 index 0000000..3ad02a8 --- /dev/null +++ b/res/layout/attachment_single_audio.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/res/layout/attachment_single_image.xml b/res/layout/attachment_single_image.xml new file mode 100644 index 0000000..7338368 --- /dev/null +++ b/res/layout/attachment_single_image.xml @@ -0,0 +1,46 @@ + + + + + + + + + diff --git a/res/layout/attachment_single_vcard.xml b/res/layout/attachment_single_vcard.xml new file mode 100644 index 0000000..6016593 --- /dev/null +++ b/res/layout/attachment_single_vcard.xml @@ -0,0 +1,30 @@ + + + + + + + diff --git a/res/layout/attachment_single_video.xml b/res/layout/attachment_single_video.xml new file mode 100644 index 0000000..fba594f --- /dev/null +++ b/res/layout/attachment_single_video.xml @@ -0,0 +1,28 @@ + + + + diff --git a/res/layout/audio_attachment_view.xml b/res/layout/audio_attachment_view.xml new file mode 100644 index 0000000..6fcf89c --- /dev/null +++ b/res/layout/audio_attachment_view.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + diff --git a/res/layout/blocked_participant_list_item_view.xml b/res/layout/blocked_participant_list_item_view.xml new file mode 100644 index 0000000..7ac0ec9 --- /dev/null +++ b/res/layout/blocked_participant_list_item_view.xml @@ -0,0 +1,51 @@ + + + + + + + diff --git a/res/layout/blocked_participants_activity.xml b/res/layout/blocked_participants_activity.xml new file mode 100644 index 0000000..e00f20f --- /dev/null +++ b/res/layout/blocked_participants_activity.xml @@ -0,0 +1,23 @@ + + + \ No newline at end of file diff --git a/res/layout/blocked_participants_fragment.xml b/res/layout/blocked_participants_fragment.xml new file mode 100644 index 0000000..7bb3c6a --- /dev/null +++ b/res/layout/blocked_participants_fragment.xml @@ -0,0 +1,23 @@ + + + \ No newline at end of file diff --git a/res/layout/chips_alternates_dropdown_item.xml b/res/layout/chips_alternates_dropdown_item.xml new file mode 100644 index 0000000..dad052c --- /dev/null +++ b/res/layout/chips_alternates_dropdown_item.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/layout/compose_message_view.xml b/res/layout/compose_message_view.xml new file mode 100644 index 0000000..fd60e2a --- /dev/null +++ b/res/layout/compose_message_view.xml @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/contact_list_item_view.xml b/res/layout/contact_list_item_view.xml new file mode 100644 index 0000000..3015ae3 --- /dev/null +++ b/res/layout/contact_list_item_view.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/contact_picker_fragment.xml b/res/layout/contact_picker_fragment.xml new file mode 100644 index 0000000..c65f140 --- /dev/null +++ b/res/layout/contact_picker_fragment.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/conversation_activity.xml b/res/layout/conversation_activity.xml new file mode 100644 index 0000000..8879704 --- /dev/null +++ b/res/layout/conversation_activity.xml @@ -0,0 +1,33 @@ + + + + + + + + \ No newline at end of file diff --git a/res/layout/conversation_fragment.xml b/res/layout/conversation_fragment.xml new file mode 100644 index 0000000..028e66b --- /dev/null +++ b/res/layout/conversation_fragment.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/conversation_list_activity.xml b/res/layout/conversation_list_activity.xml new file mode 100644 index 0000000..48f3b15 --- /dev/null +++ b/res/layout/conversation_list_activity.xml @@ -0,0 +1,23 @@ + + + \ No newline at end of file diff --git a/res/layout/conversation_list_fragment.xml b/res/layout/conversation_list_fragment.xml new file mode 100644 index 0000000..bf5d401 --- /dev/null +++ b/res/layout/conversation_list_fragment.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + diff --git a/res/layout/conversation_list_item_view.xml b/res/layout/conversation_list_item_view.xml new file mode 100644 index 0000000..da1ca4e --- /dev/null +++ b/res/layout/conversation_list_item_view.xml @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/conversation_message_view.xml b/res/layout/conversation_message_view.xml new file mode 100644 index 0000000..daad600 --- /dev/null +++ b/res/layout/conversation_message_view.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/copy_contact_dialog_view.xml b/res/layout/copy_contact_dialog_view.xml new file mode 100644 index 0000000..15e07f7 --- /dev/null +++ b/res/layout/copy_contact_dialog_view.xml @@ -0,0 +1,24 @@ + + + \ No newline at end of file diff --git a/res/layout/custom_header_view_pager.xml b/res/layout/custom_header_view_pager.xml new file mode 100644 index 0000000..9552cd6 --- /dev/null +++ b/res/layout/custom_header_view_pager.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/layout/debug_mmsconfig_activity.xml b/res/layout/debug_mmsconfig_activity.xml new file mode 100644 index 0000000..4056317 --- /dev/null +++ b/res/layout/debug_mmsconfig_activity.xml @@ -0,0 +1,22 @@ + + + \ No newline at end of file diff --git a/res/layout/debug_mmsconfig_fragment.xml b/res/layout/debug_mmsconfig_fragment.xml new file mode 100644 index 0000000..be61710 --- /dev/null +++ b/res/layout/debug_mmsconfig_fragment.xml @@ -0,0 +1,23 @@ + + + \ No newline at end of file diff --git a/res/layout/debug_mmsconfig_item_view.xml b/res/layout/debug_mmsconfig_item_view.xml new file mode 100644 index 0000000..a825ee5 --- /dev/null +++ b/res/layout/debug_mmsconfig_item_view.xml @@ -0,0 +1,46 @@ + + + + + + + diff --git a/res/layout/debug_sms_mms_from_dump_file_dialog.xml b/res/layout/debug_sms_mms_from_dump_file_dialog.xml new file mode 100644 index 0000000..342f489 --- /dev/null +++ b/res/layout/debug_sms_mms_from_dump_file_dialog.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/res/layout/enter_phone_number_view.xml b/res/layout/enter_phone_number_view.xml new file mode 100644 index 0000000..825ac51 --- /dev/null +++ b/res/layout/enter_phone_number_view.xml @@ -0,0 +1,29 @@ + + + diff --git a/res/layout/fastscroll_preview.xml b/res/layout/fastscroll_preview.xml new file mode 100644 index 0000000..ab72744 --- /dev/null +++ b/res/layout/fastscroll_preview.xml @@ -0,0 +1,25 @@ + + + diff --git a/res/layout/fastscroll_thumb.xml b/res/layout/fastscroll_thumb.xml new file mode 100644 index 0000000..6f57056 --- /dev/null +++ b/res/layout/fastscroll_thumb.xml @@ -0,0 +1,19 @@ + + + \ No newline at end of file diff --git a/res/layout/fastscroll_track.xml b/res/layout/fastscroll_track.xml new file mode 100644 index 0000000..6a35e94 --- /dev/null +++ b/res/layout/fastscroll_track.xml @@ -0,0 +1,20 @@ + + + diff --git a/res/layout/frequent_contacts_list_view.xml b/res/layout/frequent_contacts_list_view.xml new file mode 100644 index 0000000..9cb0c3a --- /dev/null +++ b/res/layout/frequent_contacts_list_view.xml @@ -0,0 +1,38 @@ + + + + + + + + + \ No newline at end of file diff --git a/res/layout/gallery_grid_item_view.xml b/res/layout/gallery_grid_item_view.xml new file mode 100644 index 0000000..8b7ee58 --- /dev/null +++ b/res/layout/gallery_grid_item_view.xml @@ -0,0 +1,46 @@ + + + + + + + + + + diff --git a/res/layout/group_mms_setting_dialog.xml b/res/layout/group_mms_setting_dialog.xml new file mode 100644 index 0000000..efc3b00 --- /dev/null +++ b/res/layout/group_mms_setting_dialog.xml @@ -0,0 +1,37 @@ + + + + + + + + + diff --git a/res/layout/license_activity.xml b/res/layout/license_activity.xml new file mode 100644 index 0000000..1475ac2 --- /dev/null +++ b/res/layout/license_activity.xml @@ -0,0 +1,22 @@ + + + + \ No newline at end of file diff --git a/res/layout/list_empty_view.xml b/res/layout/list_empty_view.xml new file mode 100644 index 0000000..aa4b4fb --- /dev/null +++ b/res/layout/list_empty_view.xml @@ -0,0 +1,42 @@ + + + + + + + + diff --git a/res/layout/mediapicker_audio_chooser.xml b/res/layout/mediapicker_audio_chooser.xml new file mode 100644 index 0000000..795d2f8 --- /dev/null +++ b/res/layout/mediapicker_audio_chooser.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/mediapicker_camera_chooser.xml b/res/layout/mediapicker_camera_chooser.xml new file mode 100644 index 0000000..27d26bd --- /dev/null +++ b/res/layout/mediapicker_camera_chooser.xml @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/mediapicker_fragment.xml b/res/layout/mediapicker_fragment.xml new file mode 100644 index 0000000..2e414bd --- /dev/null +++ b/res/layout/mediapicker_fragment.xml @@ -0,0 +1,34 @@ + + + + + + + + diff --git a/res/layout/mediapicker_image_chooser.xml b/res/layout/mediapicker_image_chooser.xml new file mode 100644 index 0000000..b4f3c01 --- /dev/null +++ b/res/layout/mediapicker_image_chooser.xml @@ -0,0 +1,44 @@ + + + + + + + \ No newline at end of file diff --git a/res/layout/mediapicker_location_chooser.xml b/res/layout/mediapicker_location_chooser.xml new file mode 100644 index 0000000..9135299 --- /dev/null +++ b/res/layout/mediapicker_location_chooser.xml @@ -0,0 +1,22 @@ + + + diff --git a/res/layout/mediapicker_location_container.xml b/res/layout/mediapicker_location_container.xml new file mode 100644 index 0000000..6f476d3 --- /dev/null +++ b/res/layout/mediapicker_location_container.xml @@ -0,0 +1,31 @@ + + + + + + diff --git a/res/layout/mediapicker_tab_button.xml b/res/layout/mediapicker_tab_button.xml new file mode 100644 index 0000000..353c871 --- /dev/null +++ b/res/layout/mediapicker_tab_button.xml @@ -0,0 +1,24 @@ + + + + + + \ No newline at end of file diff --git a/res/layout/message_audio_attachment.xml b/res/layout/message_audio_attachment.xml new file mode 100644 index 0000000..0854269 --- /dev/null +++ b/res/layout/message_audio_attachment.xml @@ -0,0 +1,22 @@ + + + diff --git a/res/layout/message_vcard_attachment.xml b/res/layout/message_vcard_attachment.xml new file mode 100644 index 0000000..0da6e2d --- /dev/null +++ b/res/layout/message_vcard_attachment.xml @@ -0,0 +1,23 @@ + + + diff --git a/res/layout/message_video_attachment.xml b/res/layout/message_video_attachment.xml new file mode 100644 index 0000000..8aa2061 --- /dev/null +++ b/res/layout/message_video_attachment.xml @@ -0,0 +1,25 @@ + + + diff --git a/res/layout/mms_config_debug_fragment.xml b/res/layout/mms_config_debug_fragment.xml new file mode 100644 index 0000000..27528f9 --- /dev/null +++ b/res/layout/mms_config_debug_fragment.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/layout/open_source_licenses.xml b/res/layout/open_source_licenses.xml new file mode 100644 index 0000000..89995eb --- /dev/null +++ b/res/layout/open_source_licenses.xml @@ -0,0 +1,22 @@ + + + + diff --git a/res/layout/people_and_options_activity.xml b/res/layout/people_and_options_activity.xml new file mode 100644 index 0000000..af879a0 --- /dev/null +++ b/res/layout/people_and_options_activity.xml @@ -0,0 +1,23 @@ + + + \ No newline at end of file diff --git a/res/layout/people_and_options_fragment.xml b/res/layout/people_and_options_fragment.xml new file mode 100644 index 0000000..be61710 --- /dev/null +++ b/res/layout/people_and_options_fragment.xml @@ -0,0 +1,23 @@ + + + \ No newline at end of file diff --git a/res/layout/people_and_options_section_header.xml b/res/layout/people_and_options_section_header.xml new file mode 100644 index 0000000..44b10c1 --- /dev/null +++ b/res/layout/people_and_options_section_header.xml @@ -0,0 +1,38 @@ + + + + + + + + + \ No newline at end of file diff --git a/res/layout/people_list_item_view.xml b/res/layout/people_list_item_view.xml new file mode 100644 index 0000000..e4c07e4 --- /dev/null +++ b/res/layout/people_list_item_view.xml @@ -0,0 +1,26 @@ + + + diff --git a/res/layout/people_options_item_view.xml b/res/layout/people_options_item_view.xml new file mode 100644 index 0000000..fbb9592 --- /dev/null +++ b/res/layout/people_options_item_view.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + diff --git a/res/layout/permission_check_activity.xml b/res/layout/permission_check_activity.xml new file mode 100644 index 0000000..57f665c --- /dev/null +++ b/res/layout/permission_check_activity.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + diff --git a/res/layout/person_item_view.xml b/res/layout/person_item_view.xml new file mode 100644 index 0000000..9645024 --- /dev/null +++ b/res/layout/person_item_view.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + diff --git a/res/layout/place_card.xml b/res/layout/place_card.xml new file mode 100644 index 0000000..93b9e8c --- /dev/null +++ b/res/layout/place_card.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/layout/settings_fragment.xml b/res/layout/settings_fragment.xml new file mode 100644 index 0000000..b62b245 --- /dev/null +++ b/res/layout/settings_fragment.xml @@ -0,0 +1,21 @@ + + + \ No newline at end of file diff --git a/res/layout/settings_item_view.xml b/res/layout/settings_item_view.xml new file mode 100644 index 0000000..a434c11 --- /dev/null +++ b/res/layout/settings_item_view.xml @@ -0,0 +1,44 @@ + + + + + + + + + diff --git a/res/layout/share_intent_activity.xml b/res/layout/share_intent_activity.xml new file mode 100644 index 0000000..d1e59ff --- /dev/null +++ b/res/layout/share_intent_activity.xml @@ -0,0 +1,21 @@ + + + diff --git a/res/layout/share_intent_conversation_list_view.xml b/res/layout/share_intent_conversation_list_view.xml new file mode 100644 index 0000000..3da3c6b --- /dev/null +++ b/res/layout/share_intent_conversation_list_view.xml @@ -0,0 +1,44 @@ + + + + + + + + + + diff --git a/res/layout/sim_selector_item_view.xml b/res/layout/sim_selector_item_view.xml new file mode 100644 index 0000000..a20c4a9 --- /dev/null +++ b/res/layout/sim_selector_item_view.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + diff --git a/res/layout/sim_selector_view.xml b/res/layout/sim_selector_view.xml new file mode 100644 index 0000000..816a2cc --- /dev/null +++ b/res/layout/sim_selector_view.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/res/layout/sms_free_storage_action_item_view.xml b/res/layout/sms_free_storage_action_item_view.xml new file mode 100644 index 0000000..fd71904 --- /dev/null +++ b/res/layout/sms_free_storage_action_item_view.xml @@ -0,0 +1,23 @@ + + + diff --git a/res/layout/sms_mms_dump_file_list_item.xml b/res/layout/sms_mms_dump_file_list_item.xml new file mode 100644 index 0000000..a939db2 --- /dev/null +++ b/res/layout/sms_mms_dump_file_list_item.xml @@ -0,0 +1,30 @@ + + + diff --git a/res/layout/sms_storage_low_warning_dialog.xml b/res/layout/sms_storage_low_warning_dialog.xml new file mode 100644 index 0000000..965cab6 --- /dev/null +++ b/res/layout/sms_storage_low_warning_dialog.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/res/layout/snack_bar.xml b/res/layout/snack_bar.xml new file mode 100644 index 0000000..1cdabc3 --- /dev/null +++ b/res/layout/snack_bar.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/layout/test_activity.xml b/res/layout/test_activity.xml new file mode 100644 index 0000000..a34b17a --- /dev/null +++ b/res/layout/test_activity.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/res/layout/vcard_detail_activity.xml b/res/layout/vcard_detail_activity.xml new file mode 100644 index 0000000..aa4c3b9 --- /dev/null +++ b/res/layout/vcard_detail_activity.xml @@ -0,0 +1,23 @@ + + + \ No newline at end of file diff --git a/res/layout/vcard_detail_fragment.xml b/res/layout/vcard_detail_fragment.xml new file mode 100644 index 0000000..8689df0 --- /dev/null +++ b/res/layout/vcard_detail_fragment.xml @@ -0,0 +1,28 @@ + + + + + + + \ No newline at end of file diff --git a/res/layout/video_thumbnail_view.xml b/res/layout/video_thumbnail_view.xml new file mode 100644 index 0000000..7c009cb --- /dev/null +++ b/res/layout/video_thumbnail_view.xml @@ -0,0 +1,38 @@ + + + + + + + + + + \ No newline at end of file diff --git a/res/layout/viewgroup_vertical_explode_animation_popup.xml b/res/layout/viewgroup_vertical_explode_animation_popup.xml new file mode 100644 index 0000000..8a8aa60 --- /dev/null +++ b/res/layout/viewgroup_vertical_explode_animation_popup.xml @@ -0,0 +1,30 @@ + + + + + + + diff --git a/res/layout/widget_conversation.xml b/res/layout/widget_conversation.xml new file mode 100644 index 0000000..a9cda98 --- /dev/null +++ b/res/layout/widget_conversation.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + diff --git a/res/layout/widget_conversation_list.xml b/res/layout/widget_conversation_list.xml new file mode 100644 index 0000000..822fe55 --- /dev/null +++ b/res/layout/widget_conversation_list.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + diff --git a/res/layout/widget_conversation_list_item.xml b/res/layout/widget_conversation_list_item.xml new file mode 100644 index 0000000..e0115a2 --- /dev/null +++ b/res/layout/widget_conversation_list_item.xml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/widget_loading.xml b/res/layout/widget_loading.xml new file mode 100644 index 0000000..8282b5d --- /dev/null +++ b/res/layout/widget_loading.xml @@ -0,0 +1,31 @@ + + + + + + \ No newline at end of file diff --git a/res/layout/widget_message_item_incoming.xml b/res/layout/widget_message_item_incoming.xml new file mode 100644 index 0000000..3a1bc00 --- /dev/null +++ b/res/layout/widget_message_item_incoming.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/widget_message_item_outgoing.xml b/res/layout/widget_message_item_outgoing.xml new file mode 100644 index 0000000..472b5a2 --- /dev/null +++ b/res/layout/widget_message_item_outgoing.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/widget_missing_permission.xml b/res/layout/widget_missing_permission.xml new file mode 100644 index 0000000..712ad02 --- /dev/null +++ b/res/layout/widget_missing_permission.xml @@ -0,0 +1,26 @@ + + + + diff --git a/res/menu/archived_conversation_list_menu.xml b/res/menu/archived_conversation_list_menu.xml new file mode 100644 index 0000000..a3a2a19 --- /dev/null +++ b/res/menu/archived_conversation_list_menu.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/res/menu/attachment_chooser_menu.xml b/res/menu/attachment_chooser_menu.xml new file mode 100644 index 0000000..99345c9 --- /dev/null +++ b/res/menu/attachment_chooser_menu.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/res/menu/compose_menu.xml b/res/menu/compose_menu.xml new file mode 100644 index 0000000..a384f57 --- /dev/null +++ b/res/menu/compose_menu.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + diff --git a/res/menu/conversation_fragment_select_menu.xml b/res/menu/conversation_fragment_select_menu.xml new file mode 100644 index 0000000..7ce0829 --- /dev/null +++ b/res/menu/conversation_fragment_select_menu.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + diff --git a/res/menu/conversation_list_fragment_menu.xml b/res/menu/conversation_list_fragment_menu.xml new file mode 100644 index 0000000..a83a2a5 --- /dev/null +++ b/res/menu/conversation_list_fragment_menu.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + diff --git a/res/menu/conversation_list_fragment_select_menu.xml b/res/menu/conversation_list_fragment_select_menu.xml new file mode 100644 index 0000000..8704691 --- /dev/null +++ b/res/menu/conversation_list_fragment_select_menu.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + diff --git a/res/menu/conversation_menu.xml b/res/menu/conversation_menu.xml new file mode 100644 index 0000000..57e561b --- /dev/null +++ b/res/menu/conversation_menu.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + diff --git a/res/menu/gallery_picker_menu.xml b/res/menu/gallery_picker_menu.xml new file mode 100644 index 0000000..428f1e7 --- /dev/null +++ b/res/menu/gallery_picker_menu.xml @@ -0,0 +1,34 @@ + + + + + + + + diff --git a/res/menu/photo_view_menu.xml b/res/menu/photo_view_menu.xml new file mode 100644 index 0000000..10a4367 --- /dev/null +++ b/res/menu/photo_view_menu.xml @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/res/menu/settings_menu.xml b/res/menu/settings_menu.xml new file mode 100644 index 0000000..3002be8 --- /dev/null +++ b/res/menu/settings_menu.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/res/menu/vcard_detail_fragment_menu.xml b/res/menu/vcard_detail_fragment_menu.xml new file mode 100644 index 0000000..ea923cb --- /dev/null +++ b/res/menu/vcard_detail_fragment_menu.xml @@ -0,0 +1,27 @@ + + + + + + diff --git a/res/raw/audio_end.wav b/res/raw/audio_end.wav new file mode 100644 index 0000000..01d08f5 Binary files /dev/null and b/res/raw/audio_end.wav differ diff --git a/res/raw/audio_initiate.wav b/res/raw/audio_initiate.wav new file mode 100644 index 0000000..e335d0d Binary files /dev/null and b/res/raw/audio_initiate.wav differ diff --git a/res/raw/db_op_debug.wav b/res/raw/db_op_debug.wav new file mode 100644 index 0000000..b807636 Binary files /dev/null and b/res/raw/db_op_debug.wav differ diff --git a/res/raw/message_failure.wav b/res/raw/message_failure.wav new file mode 100644 index 0000000..270a0b7 Binary files /dev/null and b/res/raw/message_failure.wav differ diff --git a/res/raw/message_inc_thread.wav b/res/raw/message_inc_thread.wav new file mode 100644 index 0000000..7dce364 Binary files /dev/null and b/res/raw/message_inc_thread.wav differ diff --git a/res/raw/message_sent.wav b/res/raw/message_sent.wav new file mode 100644 index 0000000..1d45718 Binary files /dev/null and b/res/raw/message_sent.wav differ diff --git a/res/raw/server_request_debug.wav b/res/raw/server_request_debug.wav new file mode 100644 index 0000000..864a7dd Binary files /dev/null and b/res/raw/server_request_debug.wav differ diff --git a/res/transition/explode.xml b/res/transition/explode.xml new file mode 100644 index 0000000..a3a94ba --- /dev/null +++ b/res/transition/explode.xml @@ -0,0 +1,17 @@ + + + \ No newline at end of file diff --git a/res/transition/fade.xml b/res/transition/fade.xml new file mode 100644 index 0000000..e3617dc --- /dev/null +++ b/res/transition/fade.xml @@ -0,0 +1,17 @@ + + + \ No newline at end of file diff --git a/res/values-af/arrays.xml b/res/values-af/arrays.xml new file mode 100644 index 0000000..d780ddc --- /dev/null +++ b/res/values-af/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "geen onderwerp nie" + "geen onderwerp nie" + + + "Ja" + "Nee" + "Goed" + "Hê-hê" + "Dankie" + "Ek stem saam" + "Lekker" + "Op pad" + "OK, laat ek jou later antwoord" + ":)" + ":(" + + diff --git a/res/values-af/strings.xml b/res/values-af/strings.xml new file mode 100644 index 0000000..ebe9d4d --- /dev/null +++ b/res/values-af/strings.xml @@ -0,0 +1,519 @@ + + + + + "Boodskappe" + "Boodskappe" + "Kies gesprek" + "Instellings" + "Stuur boodskap" + "Voeg \'n aanhegsel by" + "Hulp" + "Welkom" + "Slaan oor" + "Volgende >" + "Volgende" + "Gaan uit" + "Instellings >" + "Instellings" + "Boodskappe het toestemming nodig vir SMS, foon en kontakte." + "Jy kan toestemmings verander in Instellings > Programme > Boodskappe > Toestemmings." + "Jy kan toestemmings verander in Instellings, Programme, Boodskappe, Toestemmings." + "Gereeldes" + "Alle kontakte" + "Stuur na %s" + "Neem foto\'s of video" + "Kies prente van hierdie toestel af" + "Neem oudio op" + "Kies foto" + "Die media is gekies." + "Die media is nie gekies nie." + "%d gekies" + "prent %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "prent" + "Neem oudio op" + "Deel" + "Nou net" + "Nou" + + %d min. + %d min. + + + %d uur + %d uur + + + %d dae + %d dag + + + %d weke + \'n week + + + %d maande + \'n maand + + + %d jaar + \'n jaar + + "Klas 0-boodskap" + "Stoor" + "Toestel het min spasie. Boodskappe sal ouer boodskappe outomaties uitvee om spasie beskikbaar te stel." + "Bergingspasie word min" + "Boodskappe sal dalk nie boodskappe stuur of ontvang voordat meer spasie op jou toestel beskikbaar is nie." + "Min SMS-berging. Jy sal dalk boodskappe moet uitvee." + "Bevestig jou foonnommer" + "Hierdie eenmalige stap sal sorg dat Boodskappe jou groepboodskappe toepaslik aflewer." + "Foonnommer" + "Vee alle boodskappe met media uit" + "Vee boodskappe ouer as %s uit" + "Vee boodskappe ouer as %s outomaties uit" + "Ignoreer" + "Vee alle boodskappe met media uit?" + "Vee boodskappe ouer as %s uit?" + "Vee boodskappe ouer as %s uit en skakel outomatiese uitvee aan?" + "%s het gesê" + "Jy het gesê" + "Boodskap van %s af" + "Jy het \'n boodskap gestuur" + "Stuur tans …" + "Nie gestuur nie. Raak om weer te probeer." + "Nie gestuur nie. Probeer tans weer …" + "Herstuur of vee uit" + "Maak asseblief \'n stemoproep na nooddienste. Jou SMS kon nie in hierdie stadium afgelewer word nie." + "Het misluk" + "Nuwe MMS-boodskap om af te laai" + "Nuwe MMS-boodskap" + "Kon nie aflaai nie" + "Raak om weer te probeer" + "Raak om af te laai" + "Laai af of vee uit" + "Laai tans af …" + "Boodskap het verval of is nie beskikbaar nie" + "grootte: %1$s, verval: %2$s" + "Kan nie stuur nie. Ontvanger nie geldig nie." + "Diens nie in netwerk geaktiveer nie" + "Kon weens netwerkprobleem nie stuur nie" + "Boodskap het verval of is nie beskikbaar nie" + "(Geen onderwerp nie)" + "Onbekende afsender" + "Afgelewer" + "Kon nie boodskap %1$s van %2$s aflaai nie." + "Kon nie databasishandeling voltooi nie weens lae geheue" + "Boodskap nie gestuur nie" + "Sommige boodskappe is nie in Boodskappe gestuur nie" + + %d boodskappe in %d gesprekke + %d boodskappe in een gesprek + + "Boodskap het nie afgelaai nie" + "Sommige boodskappe is nie in Boodskappe afgelaai nie" + + %d boodskappe in %d gesprekke + %d boodskappe in een gesprek + + "Boodskap aan %1$s nie gestuur nie" + "Maak asseblief \'n stemoproep na nooddienste. Jou SMS na %1$s kon nie in hierdie stadium afgelewer word nie." + + %d nuwe boodskappe + Nuwe boodskap + + "Begin" + "Kamera nie beskikbaar nie" + "Kamera nie beskikbaar nie" + "Video-opname nie beskikbaar nie" + "Kan nie media stoor nie" + "Kan nie foto neem nie" + "Terug" + "Geargiveer" + "Vee uit" + "Argiveer" + "Deargiveer" + "Skakel kennisgewings af" + "Skakel kennisgewings aan" + "Voeg kontak by" + "Laai af" + "Stuur" + "Vee uit" + "Vee hierdie boodskap uit?" + "Hierdie handeling kan nie ontdoen word nie." + "Vee uit" + + Vee hierdie gesprekke uit? + Vee hierdie gesprek uit? + + "Vee uit" + "Kanselleer" + "Aan" + "Kies verskeie prente" + "Bevestig keuse" + "+%d" + "Kan nie oudio opneem nie. Probeer weer." + "Kan nie oudio speel nie. Probeer weer." + "Kon nie oudio stoor nie. Probeer weer." + "Raak en hou" + ", " + " " + ": " + " " + "Prent" + "Oudiosnit" + "video" + "Kontakkaartjie" + "Laai af" + "Antwoord via SMS" + "Antwoord via MMS" + "Antwoord" + + %d deelnemers + %d deelnemer + + "Ek" + "Kontak geblokkeer en geargiveer" + "Kontak gedeblokkeer en gedeargiveer" + "%d geargiveer" + "%d gedeargiveer" + "Kennisgewings afgeskakel" + "Kennisgewings aangeskakel" + "Alles is gereed. Raak om weer te stuur." + "Boodskappe is suksesvol as die verstek-SMS-program gestel." + + Gooi aanhegsels weg + Gooi aanhegsel weg + + "Oudio-aanhegsel" + "Speel oudio-aanhegsel" + "Wag" + "Boodskap vanaf %s: %s." + "Mislukte boodskap van %s af: %s. Tyd: %s" + "Boodskap van %s af: %s. Tyd: %s" + "Ongestuurde boodskap aan %s: %s. Tyd: %s" + "Stuur tans boodskap aan %s: %s. Tyd: %s" + "Kon nie boodskap aan %s stuur nie: %s. Tyd: %s" + "Boodskap aan %s: %s. Tyd: %s" + "Mislukte boodskap van %s af: %s. Tyd: %s. %s" + "Boodskap van %s af: %s. Tyd: %s. %s" + "Ongestuurde boodskap aan %s: %s. Tyd: %s" + "Stuur tans boodskap aan %s: %s. Tyd: %s" + "Kon nie boodskap aan %s stuur nie: %s. Tyd: %s" + "Boodskap aan %s: %s. Tyd: %s" + "Kon nie boodskap stuur nie. Raak om weer te probeer." + "Gesprek met %s" + "Vee onderwerp uit" + "Neem video op" + "Vang \'n stilprent vas" + "Neem foto" + "Begin video opneem" + "Skakel na volskerm-kamera oor" + "Wissel tussen voorste en agterste kamera" + "Staak opname en heg video aan" + "Stop video-opname" + "Foto\'s uit Boodskappe" + + %d foto\'s is na \"%s\"-album gestoor + %d foto is na \"%s\"-album gestoor + + + %d video\'s is na \"%s\"-album gestoor + %d video is na \"%s\"-album gestoor + + + %d aanhegsels is na \"%s\"-album gestoor + %d aanhegsel is na \"%s\"-album gestoor + + + %d aanhegsels is na \"Aflaaie\" gestoor + %d aanhegsel is na \"Aflaaie\" gestoor + + + %d aanhegsels is gestoor + %d aanhegsel is gestoor + + + Kon %d aanhegsels nie stoor nie + Kon %d aanhegsel nie stoor nie + + "MMS-aanhegsel gestoor" + "Instellings" + "Geargiveer" + "Maak toe" + "MMS" + "Gevorderd" + "Ontfout" + "Kennisgewings" + "Klank" + "Stil" + "Vibreer" + "Geblokkeer" + "SMS-afleweringverslae" + "Versoek \'n afleweringsverslag vir elke SMS wat jy stuur" + "Haal outomaties op" + "Gaan haal MMS-boodskappe outomaties" + "Outoherwinning wanneer swerf" + "Laai MMS outomaties af terwyl geswerf word" + "Groepboodskappe" + "Gebruik MMS om \'n enkele boodskap te stuur wanneer daar veelvuldige ontvangers is" + "Verstek-SMS-program" + "Verstek-SMS-program" + + + "Jou foonnommer" + "Onbekend" + "Klanke vir uitgaande boodskappe" + "Gooi SMS weg" + "Stort rou data van ontvangde SMS in eksterne berginglêer" + "Gooi MMS weg" + "Stort rou data van ontvangde MMS in eksterne berginglêer" + "Draadlose waarskuwings" + "Boodskapopsies" + "Kopieer teks" + "Bekyk besonderhede" + "Vee uit" + "Stuur aan" + "Boodskapbesonderhede" + "Tipe: " + "Teksboodskap" + "Multimediaboodskap" + "Van: " + "Aan: " + "Gestuur: " + "Ontvang: " + "Onderwerp: " + "Grootte: " + "Prioriteit: " + "SIM: " + "Hoog" + "Normaal" + "Laag" + "SIM %s" + "Versteekte afsenderadres" + "Kan nie boodskap stuur terwyl aanhegsels gelaai word nie." + "Kan nie aanhegsel laai nie. Probeer weer." + "Netwerk is nie gereed nie. Probeer weer." + "Vee teks uit" + "Wissel tussen invoer van teks en syfers" + "Voeg meer deelnemers by" + "Bevestig deelnemers" + "Begin nuwe gesprek" + "Kies hierdie item" + "Speel video" + "Mense en opsies" + "Ontfout" + "Mense en opsies" + "Algemeen" + "Mense in hierdie gesprek" + "Maak \'n oproep" + "Stuur boodskap" + "Stuur boodskap<br/><small>van %s</small> af" + + Stuur foto\'s + Stuur foto + + + Stuur oudio\'s + Stuur oudio + + + Stuur video\'s + Stuur video + + + Stuur kontakkaarte + Stuur kontakkaart + + + Stuur aanhegsels + Stuur aanhegsel + + + %d aanhegsels is gereed om te stuur + Een aanhegsel is gereed om te stuur + + "Stuur terugvoer" + "Bekyk in Google Play Winkel" + "Weergawe-inligting" + "Weergawe %1$s" + "Oopbronlisensies" + "Kennisgewings" + "Aanhegsellimiet is bereik" + "Kon nie aanhegsel laai nie." + "Voeg by kontakte?" + "Voeg kontak by" + "Onderwerp" + "Onderwerp: " + "%s%s" + "Laai tans kontakkaart" + "Kon nie kontakkaart laai nie" + "Bekyk kontakkaart" + + %d kontakte + %d kontak + + "Kontakkaarte" + "Verjaardag" + "Notas" + "Aanstuurboodskap" + "Antwoord" + "+1" + "+%d" + "SMS gedeaktiveer" + "Stel Boodskappe as die verstek-SMS-program om te stuur" + "Stel Boodskappe as die verstek-SMS-program" + "Verander" + "Stel Boodskappe as die verstek-SMS-program om boodskappe te ontvang" + "Geen voorkeur-SIM vir die stuur van SMS-boodskappe gekies nie" + "Hierdie program word nie deur die toesteleienaar toegelaat nie." + "Goed" + "Te veel deelnemers aan \'n gesprek" + + Ongeldige kontakte + Ongeldige kontak + + "Kon nie kameraprent laai nie" + "Jy: " + "%s: " + "Konsep" + "Sodra jy \'n nuwe gesprek begin, sal jy dit hier gelys sien" + "Geargiveerde gesprekke verskyn hier" + "Laai tans gesprekke …" + "Prent" + "Oudiosnit" + "Video" + "Kontakkaartjie" + "MMS" + "Ontdoen" + "Herprobeer" + "Voer \'n kontaknaam of foonnommer in om \'n nuwe boodskap te begin" + "Blokkeer" + "Blokkeer %s" + "Deblokkeer %s" + "Blokkeer %s?" + "Jy sal steeds boodskappe van hierdie nommer af ontvang, maar sal nie meer in kennis gestel word nie. Hierdie gesprek sal geargiveer word." + "Geblokkeerde kontakte" + "DEBLOKKEER" + "Geblokkeerde kontakte" + "Kies prent uit dokumentbiblioteek" + "Stuur tans boodskap" + "Boodskap gestuur" + "Sellulêre data is afgeskakel. Gaan jou instellings na." + "Kan nie boodskappe in vliegtuigmodus stuur nie" + "Kon nie boodskap stuur nie" + "Boodskap afgelaai" + "Sellulêre data is afgeskakel. Gaan jou instellings na." + "Kan nie boodskappe in vliegtuigmodus aflaai nie" + "Boodskap kon nie afgelaai word nie" + "Nul" + "Een" + "Twee" + "Drie" + "Vier" + "Vyf" + "Ses" + "Sewe" + "Agt" + "Nege" + "Kan nie boodskap met %1$s stuur nie, fout %2$d" + "Kan nie boodskap met onbekende diensverskaffer stuur nie, fout %1$d" + "Fwd: %s" + "Boodskap nie gestuur nie: diens nie in netwerk geaktiveer nie" + "Boodskap nie gestuur nie: ongeldige bestemmingsadres" + "Boodskap nie gestuur nie: ongeldige boodskap" + "Boodskap nie gestuur nie: inhoud word nie gesteun nie" + "Boodskap nie gestuur nie: boodskap word nie gesteun nie" + "Boodskap nie gestuur nie; te groot" + "Nuwe boodskap" + "Bekyk" + "Prent" + "Kon nie \'n toepaslike program vind nie" + "Verwyder ontvanger" + "Nuwe boodskap" + "Kanselleer" + "Wysig toegangspunt" + "Nie gestel nie" + "Naam" + "APN" + "MMSC" + "MMS-volmag" + "MMS-poort" + "MCC" + "MNC" + "APN-tipe" + "Vee APN uit" + "Nuwe APN" + "Stoor" + "Gooi weg" + "Die naamveld kan nie leeg wees nie." + "Die APN kan nie leeg wees nie." + "MCC-veld moet 3 syfers wees." + "Die MNC-veld moet uit 2 of 3 syfers bestaan." + "Stel tans verstekinstellings vir APN terug." + "Stel terug na verstek" + "Terugstel van verstek-APN-instellings voltooi." + "Titelloos" + "APN\'e" + "APN\'e" + "Nuwe APN" + "APN-instellings is nie vir hierdie gebruiker beskikbaar nie" + "Kopieer na knipbord?" + "Kopieer" + "aan %s" + "Algemeen" + "Gevorderd" + "Algemene instellings" + "Gevorderde instellings" + "\"%s\"-SIM" + "Stuur individuele SMS-boodskappe aan alle ontvangers. Net jy sal enige antwoorde kry" + "Stuur \'n enkele MMS aan alle ontvangers" + "Onbekende nommer" + "Nuwe boodskap" + "Nuwe boodskap." + "SIM-selekteerder" + "%1$s gekies, SIM-selekteerder" + "Wysig onderwerp" + "Kies SIM of wysig onderwerp" + "Raak en hou om oudio op te neem" + "Begin nuwe gesprek" + "Boodskappe" + "Boodskappe-lys" + "Boodskappe" + "Nuwe boodskap" + "Gespreklys" + "Laai tans gesprekke" + "Laai tans boodskappe" + "Bekyk nog gesprekke" + "Bekyk nog boodskappe" + "Gesprek uitgevee" + "Gesprek is uitgevee. Raak om \'n ander Boodskappe-gesprek te wys" + "Geblokkeer" + "Gedeblokkeer" + "Daar is min bergingspasie. Sommige data kan verloor word." + "Kies aanhegsels" + "Bevestig keuse" + "%d gekies" + "Verwyder asseblief een of meer aanhegsels en probeer weer." + "Jy kan probeer om jou boodskap te stuur, maar dit sal dalk nie afgelewer word tensy jy een of meer aanhegsels verwyder nie." + "Jy kan net een video per boodskap stuur. Verwyder bykomende video\'s en probeer weer." + "Boodskappe kon nie aanhegsel laai nie." + "Stuur in elk geval" + "Kon nie gesprek begin nie" + "%1$s (%2$s)" + "%s gekies" + diff --git a/res/values-am/arrays.xml b/res/values-am/arrays.xml new file mode 100644 index 0000000..225008e --- /dev/null +++ b/res/values-am/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "ርዕሰ-ጉዳይ የሌለው" + "ምንም ርእስ የለውም" + + + "አዎ" + "አይ" + "እሺ" + "ሃሃ" + "እናመሰግናለን" + "እስማማለሁ" + "በጣም ጥሩ" + "እየመጣሁ ነኝ" + "እሺ፣ በኋላ ላይ ላግኝዎት" + ":)" + ":(" + + diff --git a/res/values-am/strings.xml b/res/values-am/strings.xml new file mode 100644 index 0000000..9707157 --- /dev/null +++ b/res/values-am/strings.xml @@ -0,0 +1,519 @@ + + + + + "መልዕክት መላኪያ" + "መልዕክት መላኪያ" + "ውይይት ምረጥ" + "ቅንብሮች" + "መልዕክት ላከ" + "ዓባሪ አክል" + "እገዛ" + "እንኳን በደህና መጡ" + "ዝለል" + "ቀጣይ >" + "ቀጣይ" + "ውጣ" + "ቅንብሮች >" + "ቅንብሮች" + "አላላክ የኤስኤምኤስ፣ ስልክ እና እውቂያዎች ፍቃድ ያስፈልገዋል።" + "ፍቃዶችን በቅንብሮች > መተግበሪያዎች > አላላክ > ፍቃዶች ውስጥ መለወጥ ይችላሉ።" + "ፍቃዶችን በቅንብሮች፣ መተግበሪያዎች፣ አላላክ፣ ፍቃዶች ውስጥ መለወጥ ይችላሉ።" + "ተደጋጋሚዎች" + "ሁሉም ዕውቂያዎች" + "ወደ %s ላክ" + "ስዕሎችን አንሳ ወይም ቪዲዮ ቅረጽ" + "ከዚህ መሣሪያ ላይ ምስሎችን ይምረጡ" + "ተሰሚ ቅረጽ" + "ፎቶ ይምረጡ" + "ሚዲያው ተመርጧል።" + "ሚዲያው አልተመረጠም።" + "%d ተመርጠዋል" + "ምስል %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "ምስል" + "ተሰሚ ቅረጽ" + "አጋራ" + "ገና አሁን" + "አሁን" + + %d ደቂቃዎች + %d ደቂቃዎች + + + %d ሰዓቶች + %d ሰዓቶች + + + %d ቀኖች + %d ቀኖች + + + %d ሳምንቶች + %d ሳምንቶች + + + %d ወሮች + %d ወሮች + + + %d ዓመቶች + %d ዓመቶች + + "የመደብ 0 መልዕክት" + "አስቀምጥ" + "መሣሪያው አነስተኛ ቦታ ነው ያለው። አላላክ ቦታን ለማስለቀቅ የቆዩ መልዕክቶችን በራስ ሰር ይሰርዛል።" + "የማከማቻ ቦታ እያለቀ ነው" + "መሣሪያዎ ላይ ተጨማሪ ቦታ እስኪገኝ ድረስ አላላክ መልዕክቶችን ላይልክ ወይም ላይቀበል ይችላል።" + "ዝቅተኛ የኤስኤምኤስ ማከማቻ። መልዕክቶችን መሰረዝ ሊኖርብዎ ይችላል።" + "ስልክ ቁጥርዎን ያረጋግጡ" + "ይሄ የአንድ ጊዜ እርምጃ አላላክ የቡድን መልዕክቶችዎን በአግባቡ እንደሚልክ ያረጋግጣል።" + "ስልክ ቁጥር" + "ማህደረመረጃ ያላቸው ሁሉንም መልዕክቶች ሰርዝ" + "ከ%s በላይ የቆዩ መልዕክቶችን ሰርዝ" + "ከ%s በላይ የቆዩ መልዕክቶችን በራስ-ሰር ሰርዝ" + "ችላ በል" + "ማህደረመረጃ ያላቸው ሁሉም መልዕክቶች ይሰረዙ?" + "ከ%s በላይ የቆዩ መልዕክቶች ይሰረዙ?" + "ከ%s በላይ የቆዩ መልዕክቶች ይሰረዙ እና ራስ-ሰር ሰራዥ ይብራ?" + "%s እንዲህ ብለዋል፦" + "እርስዎ እንዲህ ብለዋል፦" + "መልዕክት ከ%s" + "እርስዎ መልዕክት ልከዋል" + "በመላክ ላይ…" + "አልተላከም። እንደገና ለመሞከር ይንኩ።" + "አልተላከም። እንደገና በመሞከር ላይ…" + "ዳግም ላክ ወይም ሰርዝ" + "ወደ ድንገተኛ አገልግሎቶች እባክዎ የድምፅ ጥሪ ያድርጉ። የእርስዎ የጽሑፍ መልዕክት አሁን ላይ ሊደርስ አይችልም።" + "አልተሳካም" + "አዲስ የሚወርድ የኤምኤምኤስ መልዕክት" + "አዲስ የኤምኤምኤስ መልዕክት" + "ማውረድ አልተቻለም" + "እንደገና ለመሞከር ይንኩ" + "ለማውረድ ይንኩ" + "አውርድ ወይም ሰርዝ" + "በማውረድ ላይ…" + "መልዕክቱ ጊዜው አልፎበታል ወይም አይገኝም።" + "መጠን፦ %1$s፣ ጊዜው የሚያልፍበት፦ %2$s" + "መላክ አይቻልም። ተቀባይ ልክ አይደለም።" + "አገልግሎት በአውታረ መረብ ላይ አልገበረም" + "በአውታረ መረብ ችግር ምክንያት መላክ አልተቻለም" + "መልዕክቱ ጊዜው አልፎበታል ወይም አይገኝም።" + "(ርዕስ ጉዳይ የለውም)" + "ያልታወቀ ላኪ" + "ደርሷል" + "መልዕክት %1$s%2$s ማውረድ አልተቻለም።" + "በአነስተኛ ማህደረ ትውስታ ምክንያት የውሂብ ጎታ ክወና ማጠናቀቅ አልተቻለም" + "መልዕክት አልተላከም" + "አንዳንድ መልዕክቶች በአላላክ ውስጥ አልተላኩም" + + %d መልዕክቶች በ%d ውይይቶች ውስጥ + %d መልዕክቶች በ%d ውይይቶች ውስጥ + + "መልዕክት አልወረደም" + "አንዳንድ መልዕክቶች በአላላክ ውስጥ አልወረዱም" + + %d መልዕክቶች በ%d ውይይቶች ውስጥ + %d መልዕክቶች በ%d ውይይቶች ውስጥ + + "ወደ %1$s የሚሄድ መልዕክት አልተላከም" + "ወደ ድንገተኛ አገልግሎቶች እባክዎ የድምፅ ጥሪ ያድርጉ። የእርስዎ ወደ %1$s የጽሑፍ መልዕክት አሁን ላይ ሊደርስ አይችልም።" + + %d አዲስ መልዕክቶች + %d አዲስ መልዕክቶች + + "ጀምር" + "ካሜራ ሊገኝ አይችልም" + "ካሜራ ሊገኝ አይችልም" + "የቪዲዮ ቀረጻ ሊገኝ አይችልም" + "ማህደረ መረጃን ማስቀመጥ አይቻልም" + "ስዕል ማንሳት አልተቻለም" + "ተመለስ" + "በማህደር ተቀምጧል" + "ሰርዝ" + "በማህደር አስቀምጥ" + "ከማህደር አስወጣ" + "ማሳወቂያዎችን አጥፋ" + "ማሳወቂያዎችን አብራ" + "እውቂያ አክል" + "አውርድ" + "ላክ" + "ሰርዝ" + "ይህን መልዕክት ይሰረዝ?" + "ይህ እርምጃ ሊቀለበስ አይችልም።" + "ሰርዝ" + + እነዚህ ውይይቶች ይሰርዙ? + እነዚህ ውይይቶች ይሰርዙ? + + "ሰርዝ" + "ይቅር" + "ለ" + "በርካታ ምስሎችን ምረጥ" + "ምርጫ አረጋግጥ" + "+%d" + "ድምፅን መቅዳት አልተቻለም። እንደገና ይሞክሩ።" + "ድምፅን ማጫወት አልተቻለም። እንደገና ይሞክሩ።" + "ድምፅን ማስቀመጥ አልተቻለም። እንደገና ይሞክሩ።" + "ነካ ያድርጉ እና ይያዙ" + "፣ " + " " + "፦ " + " " + "ስዕል" + "የተሰሚ ቅንጥብ" + "ቪዲዮ" + "የእውቂያ ካርድ" + "አውርድ" + "በኤስኤምኤስ በኩል መልስ ስጥ" + "በኤምኤምኤስ በኩል ምላሽ ስጥ" + "ምላሽ ስጥ" + + %d ተሳታፊዎች + %d ተሳታፊዎች + + "እኔ" + "እውቂያ ታግዶ በማህደር ውስጥ ተቀምጧል" + "እውቂያ እገዳው ተነስቷል እና ከቤተማከማቻ ወጥቷል" + "%d በማህደር ተቀምጧል" + "%d ከማህደር ወጥተዋል" + "ማሳወቂያዎች ጠፍተዋል" + "ማሳወቂያዎች በርተዋል" + "ሁሉም ተቀናብሯል። እንደገና ላክ የሚለውን ይንኩ።" + "የአላላክ በተሳካ ሁኔታ እንደ ነባሪ የኤስኤምኤስ መተግበሪያ ተዘጋጅቷል።" + + ዓባሪዎችን አስወግድ + ዓባሪዎችን አስወግድ + + "የኦዲዮ ዓባሪ" + "የኦዲዮ ዓባሪ ያጫውቱ" + "ለአፍታ አቁም" + "መልእክት ከ %s%s።" + "ያልተሳካ መልዕክት ከ%s%s። ሰዓት፦ %s።" + "ከ%s የመጣ መልዕክት፦ %s። ሰዓት፦ %s።" + "ወደ %s የሚሄድ ያልተላከ መልዕክት፦ %s። ሰዓት፦ %s።" + "መልዕክት ወደ %s በመላክ ላይ፦ %s። ሰዓት፦ %s።" + "ያልተሳካ ወደ %s የሚሄድ መልዕክት፦ %s። ሰዓት፦ %s።" + "ወደ %s የሚሄድ መልዕክት፦ %s። ሰዓት፦ %s።" + "ያልተሳካ መልዕክት ከ%s%s። ሰዓት፦ %s%s።" + "መልዕክት ከ%s%s። ሰዓት፦ %s%s።" + "ወደ %s የሚሄድ ያልተላከ መልዕክት፦ %s። ሰዓት፦ %s።" + "መልዕክት ወደ %s በመላክ ላይ፦ %s። ሰዓት፦ %s።" + "ያልተሳካ ወደ %s የሚሄድ መልዕክት፦ %s። ሰዓት፦ %s።" + "ወደ %s የሚሄድ መልዕክት፦ %s። ሰዓት፦ %s።" + "ያልተሳካ መልዕክት። ዳግም ለመሞከር ይንኩ።" + "ከ%s ጋር ውይይት" + "ርዕሰ ጉዳይ ሰርዝ" + "ቪዲዮ ቅረጽ" + "የቆመ ምስል አንሳ" + "ፎቶ አንሳ" + "ቪዲዮ መቅረጽ ጀምር" + "ወደ የሙሉ ማያ ገጽ ካሜራ ቀይር" + "በፊት እና ኋላ ካሜራ መካከል ቀያይር" + "መቅረጽ አቁም እና ቪዲዮ አያይዝ" + "ቪዲዮ መቅዳትን አቁም" + "ፎቶዎችን በመልዕክት መላክ" + + %d ፎቶዎች ወደ የ«%s» አልበም ተቀምጠዋል + %d ፎቶዎች ወደ የ«%s» አልበም ተቀምጠዋል + + + %d ቪዲዮዎች ወደ የ«%s» አልበም ተቀምጠዋል + %d ቪዲዮዎች ወደ የ«%s» አልበም ተቀምጠዋል + + + %d ዓባሪዎች ወደ «%s» አልበም ተቀምጠዋል + %d ዓባሪዎች ወደ «%s» አልበም ተቀምጠዋል + + + %d ዓባሪዎች ወደ «ውርዶች» ተቀምጠዋል + %d ዓባሪዎች ወደ «ውርዶች» ተቀምጠዋል + + + %d ዓባሪዎች ተቀምጠዋል + %d ዓባሪዎች ተቀምጠዋል + + + %d ዓባሪዎችን ማስቀመጥ አልተቻለም + %d ዓባሪዎችን ማስቀመጥ አልተቻለም + + "የኤምኤምኤስ ዓባሪ ተቀምጧል" + "ቅንብሮች" + "በማህደር ተቀምጧል" + "ዝጋ" + "ኤምኤምኤስ" + "የላቀ" + "አርም" + "ማሳወቂያዎች" + "ድምፅ" + "ፀጥታ" + "ንዘር" + "ታግዷል" + "የኤስኤምኤስ መላክ ሪፖርቶች" + "ለሚልኩት እያንዳንዱ ኤስኤምኤስ የመላኪያ ሪፖርት ጠይቅ" + "በራስ- ሰርስረህ አውጣ" + "የኤምኤምኤስ መልዕክቶችን በራስ-ሰር ሰርስረህ አውጣ" + "በእንቅስቃሴ ላይ በራስ-ሰር ሰርስረህ አውጣ" + "የስልክ አገልግሎት ሰጪ በሚቀየርበት ጊዜ ኤምኤምኤስን በራስ-ሰር ሰርስረህ አውጣ" + "የቡድን መልዕክት መላላኪያ" + "ብዙ ተቀባዮች ሲኖሩ አንዲት መልዕክት ለመላክ ኤምኤምኤስ ይጠቀሙ" + "ነባሪ የኤስኤምኤስ መተግበሪያ" + "ነባሪ የኤስኤምኤስ መተግበሪያ" + + + "ስልክ ቁጥርዎ" + "የማይታወቅ" + "የወጪ መልዕክት ድምጾች" + "ኤስኤምኤስ አራግፍ" + "የመጡ የኤስኤምኤስ ጥሬ ውሂብ ወደ ውጫዊ የማከማቻ ፋይል ላይ አራግፍ" + "ኤምኤምኤስ አራግፍ" + "የመጡ የኤምኤምኤስ ጥሬ ውሂብ ወደ ውጫዊ የማከማቻ ፋይል ላይ አራግፍ" + "ገመድ አልባ ማንቂያዎች" + "የመልዕክት አማራጮች" + "ጽሁፍ ገልብጥ" + "ዝርዝሮችን ይመልከቱ" + "ሰርዝ" + "ያስተላልፉ" + "የመልዕክት ዝርዝሮች" + "ዓይነት፦ " + "የፅሁፍ መልዕክት" + "ማህደረ ብዙ መረጃመልዕክት" + "ከ፦ " + "ለ፦ " + "የተላከው፦ " + "የደረሰው፦ " + "ርዕሰ ጉዳይ፦ " + "መጠን፦ " + "ቅድሚያ የሚሰጠው፦ " + "ሲም፦ " + "ከፍተኛ" + "መደበኛ" + "ዝቅ ያለ" + "ሲም %s" + "የተደበቀ የላኪ አድራሻ" + "አባሪዎችን በመጫን ላይ እያለ መልዕክት መላክ አይችልም።" + "ዓባሪን መጫን አልተቻለም። እንደገና ይሞክሩ።" + "አውታረ መረቡ ዝግጁ አይደለም። እንደገና ይሞክሩ።" + "ጽሑፍ ሰርዝ" + "ጽሑፍ እና ቁጥሮችን በማስገባት መካከል ይቀያይሩ" + "ተጨማሪ ተሳታፊዎችን ያክሉ" + "ተሳታፊዎችን ያረጋግጡ" + "አዲስ ውይይት ጀምር" + "ይህን ንጥል ምረጥ" + "ቪዲዮ አጫውት" + "ሰዎች እና አማራጮች" + "አርም" + "ሰዎች እና አማራጮች" + "አጠቃላይ" + "በዚህ ውይይት ውስጥ ያሉ ሰዎች" + "ደውል" + "መልዕክት ላክ" + "መልዕክት ይላኩ<br/><small>from %s</small>" + + ፎቶዎችን ይላኩ + ፎቶዎችን ይላኩ + + + ኦዲዮዎች ይላኩ + ኦዲዮዎች ይላኩ + + + ቪዲዮዎችን ይላኩ + ቪዲዮዎችን ይላኩ + + + የእውቂያ ካርዶችን ይላኩ + የእውቂያ ካርዶችን ይላኩ + + + ዓባሪዎችን ይላኩ + ዓባሪዎችን ይላኩ + + + %d ዓባሪዎችን ለመላክ የተዘጋጁ ዓባሪዎች + %d ዓባሪዎችን ለመላክ የተዘጋጁ ዓባሪዎች + + "ግብረመልስ ላክ" + "በGoogle Play መደብር ውስጥ ይመልከቱ" + "የስሪት መረጃ" + "ስሪት %1$s" + "የክፍት ምንጭ ፍቃዶች" + "ማሳወቂያዎች" + "የዓባሪ ገደብ ተደርሷል" + "ዓባሪን መጫን አልተሳካም።" + "ወደ እውቂያ ይታከል?" + "እውቅያ ያክሉ" + "ርዕሰ ጉዳይ" + "ርዕሰ ጉዳይ፦ " + "%s%s" + "የእውቂያ ካርድ በመጫን ላይ" + "የእውቂያ ካርድን መጫን አልተቻለም" + "የእውቂያ ካርድ ይመልከቱ" + + %d እውቂያዎች + %d እውቂያዎች + + "የአውቂያ ካርዶች" + "የልደት ቀን" + "ማስታወሻዎች" + "መልዕክት አስተላልፍ" + "ምላሽ ስጥ" + "+1" + "+%d" + "ኤስኤምኤስ ተሰናክሏል" + "ለመላክ፣ የአላላክን እንደ ነባሪ የኤስኤምኤስ መተግበሪያ ያዘጋጁት" + "አላላክን እንደ ነባሪ የኤስኤምኤስ መተግበሪያ ያዘጋጁት" + "ለውጥ" + "መልዕክቶችን ለመቀበል የአላላክን እንደ ነባሪ የኤስኤምኤስ መተግበሪያ አድርገው ያዘጋጁት" + "ኤስ ኤም ኤስ መልዕክቶችን ለመላክ ምንም ተመራጭ ሲም አልተመረጠም" + "ይህ መተግበሪያ በመሣሪያው ባለቤት አይፈቀድም።" + "እሺ" + "በአንድ ውይይት ውስጥ በጣም ብዙ ተሳታፊዎች" + + ልክ ያልሆኑ እውቂያዎች + ልክ ያልሆኑ እውቂያዎች + + "የካሜራ ምስልን መጫን አልተቻለም" + "እርስዎ፦ " + "%s፦ " + "ረቂቅ" + "አንዴ አዲስ ውይይት ከጀመሩ በኋላ እዚህ ተዘርዝሮ ያዩታል" + "በቤተመዛግብት እንዲቀመጡ የተደረጉ ውይይቶች እዚህ ላይ ብቅ ያላሉ" + "ውይይቶች በመስቀል ላይ..." + "ስዕል" + "የተሰሚ ቅንጥብ" + "ቪዲዮ" + "የእውቂያ ካርድ" + "ኤምኤምኤስ" + "ቀልብስ" + "እንደገና ይሞክሩ" + "አዲስ መልዕክት ለመጀመር የእውቂያ ስም ወይም ስልክ ቁጥር ያስገቡ" + "አግድ" + "%sን አግድ" + "የ%s እገዳን አንሳ" + "%s ይታገድ?" + "ከዚህ ቁጥር መልዕክቶችን መቀበልዎን ይቀጥላሉ ነገርግን ከእንግዲህ እንዲያውቁት አይደረጉም። ይህ ውይይት በቤተመዛግብት ይቀመጣል።" + "የታገዱ እውቂያዎች" + "እገዳ አንሳ" + "የታገዱ እውቂያዎች" + "ከሰነዶች ቤተመዛግብት ምስል ይምረጡ" + "መልዕክት በመላክ ላይ" + "መልዕክት ተልኳል" + "የተንቀሳቃሽ ስልክ ውሂብ ጠፍቷል። ቅንብሮችዎን ያረጋግጡ።" + "በአይሮፕላን ሁኔታ ላይ እያለ መልዕክቶችን መላክ አይቻልም" + "መልዕክቱ ሊላክ አልቻለም" + "መልዕክት ወርዷል" + "የተንቀሳቃሽ ስልክ ውሂብ ጠፍቷል። ቅንብሮችዎን ያረጋግጡ።" + "መልዕክቶችን በአውሮፕላን ሁነታ ላይ ማውረድ አይቻልም" + "መልዕክት ሊወርድ አልቻለም" + "ዜሮ" + "አንድ" + "ሁለት" + "ሶስት" + "አራት" + "አምስት" + "ስድስት" + "ሰባት" + "ስምንት" + "ዘጠኝ" + "መልዕክት በ%1$s መላክ አልተቻለም፣ ስህተት %2$d" + "መልዕክት ባልታወቀ አገልግሎት አቅራቢ መላክ አልተቻለም፣ ስህተት %1$d" + "የተላለፈ፦ %s" + "መልዕክት አልተላከም፦ አገልግሎቱ በአውታረ መረብ ላይ አልገበረም።" + "መልዕክት አልተላከም፦ ልክ ያልሆነ የመድረሻ አድራሻ" + "መልዕክት አልተላከም፦ ልክ ያልሆነ መልዕክት" + "መልዕክት አልተላከም፦ የማይደገፍ ይዘት" + "መልዕክት አልተላከም፦ የማይደገፍ መልዕክት" + "መልዕክት አልተላከም፦ በጣም ትልቅ ነው" + "አዲስ መልዕክት" + "ይመልከቱ" + "ምስል" + "አግባብ የሆነውን መተግበሪያ ማግኘት አልተቻለም" + "ተቀባይ አስወግድ" + "አዲስ መልዕክት" + "ተወው" + "የመድረሻ ነጥብ ያርትዑ" + "አልተዘጋጀም" + "ስም" + "ኤ.ፒ.ኤን." + "MMSC" + "የMMS እጅ አዙር" + "የMMS ወደብ" + "MCC" + "MNC" + "የኤ.ፒ.ኤን አይነት" + "ኤ.ፒ.ኤን. ሰርዝ" + "አዲስ ኤ.ፒ.ኤን." + "አስቀምጥ" + "ጣለው" + "የስም መስክ ባዶ ሊሆን አይችልም" + "ኤ.ፒ.ኤን. ባዶ መሆን አይችልም።" + "የMCC መስክ 3 አሀዝ መሆን አለበት።" + "የMNC መስክ 2 ወይም 3 አሀዝ መሆን አለበት።" + "ነባሪ የኤ.ፒ.ኤን. ቅንብሮችን እነበረበት መመለስ።" + "ወደ ነባሪ ዳግም አስጀምር" + "የዳግም አስጀምር ነባሪ ኤ.ፒ.ኤን. ቅንብሮች ተጠናቀዋል።" + "ርዕስ አልባ" + "የመዳረሻ ነጥብ ስም" + "ኤ.ፒ.ኤኖች." + "አዲስ ኤ.ፒ.ኤን." + "የመዳረሻ ነጥብ ስም ቅንብሮች ለዚህ ተጠቃሚ አይገኙም" + "ወደ የቅንጥብ ሰሌዳ ይቀዳ?" + "ቅዳ" + "ለ%s" + "አጠቃላይ" + "የላቀ" + "አጠቃላይ ቅንብሮች" + "የላቁ ቅንብሮች" + "የ«%s» ሲም" + "የነጠላ ኤስኤምኤስ መልዕክቶችን ለሁሉም ተቀባዮች ይላኩ። እርስዎ ብቻ ነዎት ማንኛቸውም ምላሾችን የሚቀበሉት" + "አንዲት ኤምኤምኤስ ለሁሉም ተቀባዮች ይላኩ" + "ያልታወቀ ቁጥር" + "አዲስ መልዕክት" + "አዲስ መልዕክት" + "ሲም መራጭ" + "%1$s ተመርጧል፣ የሲም መራጭ" + "ርዕሰ ጉዳይ ያርትዑ" + "ሲም ይምረጡ ወይም ርዕሰ ጉዳይን ያርትዑ" + "ተሰሚን ለመቅረጽ ይንኩና ይያዙ" + "አዲስ ውይይት ጀምር" + "አላላክ" + "የአላላክ ዝርዝር" + "አላላክ" + "አዲስ መልዕክት" + "የውይይት ዝርዝር" + "ውይይቶችን በመጫን ላይ" + "መልዕክቶች በመስቀል ላይ" + "ተጨማሪ ውይይቶችን ይመልከቱ" + "ተጨማሪ መልዕክቶችን አሳይ" + "ውይይት ተሰርዟል" + "ውይይት ተሰርዟል። የተለየ የአላላክ ውይይት ለማሳየት ይንኩ" + "ታግዷል" + "አልታገደም" + "የማከማቻ ቦታ ዝቅተኛ ነው። አንዳንድ ውሂብ ሊጠፋ ይችላል።" + "ዓባሪዎችን ይምረጡ" + "ምርጫ አረጋግጥ" + "%d ተመርጠዋል" + "እባክዎ አንድ ወይም ተጨማሪ ዓባሪዎችን ያስወግዱና እንደገና ይሞክሩ።" + "መልዕክትዎን ለመላክ መሞከር ይችላሉ፣ ነገር ግን አንድ ወይም ተጨማሪ ዓባሪዎችን እስኪያስወግዱ ድረስ ላይደርስ ይችላል።" + "በአንድ መልዕክት አንድ ቪዲዮ ብቻ ለመላክ ይችላሉ። እባክዎ ተጨማሪ ቪዲዮዎችን ያስወግዱ እና እንደገና ይሞክሩ።" + "አላላክ አባሪን መጫን አልቻለም።" + "ለማንኛውም ላክ" + "ውይይት መጀመር አልተቻለም" + "%1$s (%2$s)" + "%s ተመርጧል" + diff --git a/res/values-ar/arrays.xml b/res/values-ar/arrays.xml new file mode 100644 index 0000000..0cf1702 --- /dev/null +++ b/res/values-ar/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "بدون موضوع" + "بدون موضوع" + + + "نعم" + "لا" + "موافق" + "هاها" + "شكرًا" + "أوافق" + "لطيف" + "في الطريق" + "حسنًا، سأتناول ذلك لاحقًا" + ":)" + ":(" + + diff --git a/res/values-ar/strings.xml b/res/values-ar/strings.xml new file mode 100644 index 0000000..8a58c4d --- /dev/null +++ b/res/values-ar/strings.xml @@ -0,0 +1,623 @@ + + + + + "المراسلة" + "المراسلة" + "تحديد محادثة" + "الإعدادات" + "إرسال رسالة" + "إضافة مرفق" + "مساعدة" + "مرحبًا" + "تخطٍ" + "‏التالي >" + "التالي" + "خروج" + "‏الإعدادات >" + "الإعدادات" + "‏يحتاج تطبيق المراسلة إلى إذن للوصول إلى الرسائل القصيرة SMS والهاتف وجهات الاتصال." + "‏يمكنك تغيير الأذونات من خلال الإعدادات > التطبيقات > المراسلة > الأذونات." + "يمكنك تغيير الأذونات من خلال الانتقال إلى الإعدادات، التطبيقات، المراسلة، الأذونات." + "من يتم الاتصال بهم بشكل متكرر" + "كل جهات الاتصال" + "إرسال إلى %s" + "التقاط الصور أو الفيديو" + "اختيار صور من هذا الجهاز" + "تسجيل الصوت" + "اختيار صورة" + "تم تحديد الوسائط." + "تم إلغاء تحديد الوسائط." + "تم تحديد %d" + "صورة %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "صورة" + "تسجيل الصوت" + "المشاركة" + "للتو" + "الآن" + + %d دقيقة + دقيقتان (%d) + %d دقائق + %d دقيقة + %d من الدقائق + %d دقيقة + + + %d ساعة + ساعتان (%d) + %d ساعات + %d ساعة + %d من الساعات + %d ساعة + + + %d يوم + يومان (%d) + %d أيام + %d يومًا + %d من الأيام + يوم واحد (%d) + + + %d أسبوع + أسبوعان (%d) + %d أسابيع + %d أسبوعًا + %d من الأسابيع + أسبوع + + + %d شهر + شهران (%d) + %d أشهر + %d شهرًا + %d من الأشهر + شهر + + + %d عام + عامان (%d) + %d أعوام + %d عامًا + %d من الأعوام + عام + + "رسالة من الفئة 0" + "حفظ" + "مساحة الجهاز منخفضة. سيحذف تطبيق المراسلة الرسائل القديمة تلقائيًا لتحرير مساحة." + "مساحة التخزين منخفضة" + "ربما لا يرسل تطبيق المراسلة رسائل أو يتلقاها ما لم تتوفر المزيد من المساحة على جهازك." + "‏سعة تخزين الرسائل القصيرة SMS منخفضة. ربما تحتاج إلى حذف بعض الرسائل." + "تأكيد رقم الهاتف" + "ستضمن هذه الخطوة - التي يتم تنفيذها مرة واحدة فقط - تسليم تطبيق المراسلة لرسائلك الجماعية بشكل صحيح." + "رقم الهاتف" + "حذف جميع الرسائل التي تتضمن وسائط" + "حذف الرسائل الأقدم من %s" + "حذف الرسائل الأقدم من %s تلقائيًا" + "تجاهل" + "هل تريد حذف كل الرسائل التي تتضمن ملفات وسائط؟" + "هل تريد حذف الرسائل الأقدم من %s؟" + "هل تريد حذف الرسائل الأقدم من %s وتشغيل الحذف التلقائي؟" + "قال %s" + "أنت قلت" + "رسالة من %s:" + "لقد أرسلت رسالة" + "جارٍ الإرسال..." + "لم يتم إرسال الرسالة. المس لإعادة المحاولة." + "لم يتم إرسال الرسالة. جارٍ إعادة المحاولة…" + "إعادة إرسال أو حذف" + "الرجاء إجراء مكالمة صوتية لخدمات الطوارئ. يتعذر توصيل رسالتك النصية في الوقت الحالي." + "أخفق الإرسال" + "هناك رسالة وسائط متعددة جديدة للتنزيل" + "رسالة وسائط متعددة جديدة" + "تعذر التنزيل" + "المس لإعادة المحاولة" + "المس للتنزيل" + "تنزيل أو حذف" + "جارٍ التنزيل…" + "انتهت صلاحية الرسالة أو ليست متاحة" + "الحجم: %1$s، تاريخ انتهاء الصلاحية: %2$s" + "يتعذر الإرسال. المستلم غير صالح." + "لم يتم تنشيط الخدمة على الشبكة" + "تعذر الإرسال بسبب حدوث مشكلة في الشبكة" + "انتهت صلاحية الرسالة أو ليست متاحة" + "(بلا موضوع)" + "مرسل غير معروف" + "تم التسليم" + "تعذر تنزيل الرسالة %1$s من %2$s." + "تعذر إتمام تشغيل قاعدة البيانات نظرًا لانخفاض الذاكرة" + "لم يتم إرسال الرسالة" + "لم يتم إرسال بعض الرسائل في تطبيق المراسلة" + + %d من الرسائل في %d محادثة + %d من الرسائل في محادثتين (%d) + %d من الرسائل في %d محادثات + %d من الرسائل في %d محادثة + %d من الرسائل في %d من المحادثات + %d من الرسائل في محادثة واحدة + + "لم يتم تنزيل الرسالة" + "لم يتم تنزيل بعض الرسائل في تطبيق المراسلة" + + %d من الرسائل في %d محادثة + %d من الرسائل في محادثتين (%d) + %d من الرسائل في %d محادثات + %d من الرسائل في %d محادثة + %d من الرسائل في %d من المحادثات + %d من الرسائل في محادثة واحدة + + "لم يتم إرسال رسالة إلى %1$s" + "الرجاء إجراء مكالمة صوتية لخدمات الطوارئ. يتعذر إرسال رسالتك النصية إلى %1$s في الوقت الحالي." + + ليست هناك أية رسالة جديدة (%d) + رسالتان (%d) جديدتان + %d رسائل جديدة + %d رسالة جديدة + %d من الرسائل الجديدة + رسالة جديدة + + "بدء" + "لا تتوفر كاميرا" + "لا تتوفر كاميرا" + "ميزة التقاط الفيديو غير متاحة" + "يتعذر حفظ ملف الوسائط" + "لا يمكن التقاط الصورة" + "رجوع" + "المؤرشفة" + "حذف" + "أرشيف" + "إزالة من الأرشيف" + "إيقاف الإشعارات" + "تشغيل الإشعارات" + "إضافة جهة اتصال" + "تنزيل" + "إرسال" + "حذف" + "هل تريد حذف هذه الرسالة؟" + "لا يمكن التراجع عن هذا الإجراء." + "حذف" + + ليست هناك محادثات. + هل تريد حذف هاتين المحادثتين؟ + هل تريد حذف هذه المحادثات؟ + هل تريد حذف هذه المحادثات؟ + هل تريد حذف هذه المحادثات؟ + هل تريد حذف هذه الدردشة؟ + + "حذف" + "إلغاء" + "إلى" + "تحديد صور متعددة" + "تأكيد الاختيار" + "+%d" + "يتعذر تسجيل الصوت. أعد المحاولة." + "يتعذر تشغيل الصوت. أعد المحاولة." + "تعذر حفظ الصوت. أعد المحاولة." + "اللمس مع الاستمرار" + "، " + " " + ": " + " " + "الصورة" + "مقطع صوتي" + "الفيديو" + "بطاقة جهة الاتصال" + "تنزيل" + "‏الرد عبر رسالة قصيرة SMS" + "الرد عبر رسالة وسائط متعددة" + "الرد" + + ليس هناك أي مشارك (%d) + مشاركان (%d) + %d مشاركين + %d مشاركًا + %d من المشاركين + مشارك واحد (%d) + + "أنا" + "تم حظر جهة الاتصال وأرشفتها" + "تم إلغاء حظر جهة الاتصال وإلغاء أرشفتها" + "تم وضع %d في الأرشيف" + "تمت إزالة %d من الأرشيف" + "تم تعطيل الإشعارات" + "تم تشغيل الإشعارات" + "اكتمل الإعداد. المس \"إرسال\" مرة أخرى." + "‏تم تعيين تطبيق المراسلة كتطبيق الرسائل القصيرة SMS الافتراضي بنجاح." + + ليست هناك مرفقات + تجاهل المرفقين + تجاهل المرفقات + تجاهل المرفقات + تجاهل المرفقات + تجاهل المرفق + + "مرفق صوتي" + "تشغيل المرفق الصوتي" + "إيقاف مؤقت" + "رسالة من %s: %s." + "رسالة تعذر إرسالها من %s: %s. الوقت: %s." + "رسالة من %s: %s. الوقت: %s." + "رسالة لم يتم إرسالها إلى %s: %s. الوقت: %s." + "جارٍ إرسال رسالة إلى %s: %s. الوقت: %s." + "رسالة تعذر إرسالها إلى %s: %s. الوقت: %s." + "رسالة إلى %s: %s. الوقت: %s." + "رسالة تعذر إرسالها من %s: %s. الوقت: %s. %s." + "رسالة من %s: %s. الوقت: %s. %s." + "رسالة لم يتم إرسالها إلى %s: %s. الوقت: %s." + "جارٍ إرسال رسالة إلى %s: %s. الوقت: %s." + "رسالة تعذر إرسالها إلى %s: %s. الوقت: %s." + "رسالة إلى %s: %s. الوقت: %s." + "رسالة تعذر إرسالها. المس لإعادة المحاولة." + "محادثة مع %s" + "حذف الموضوع" + "التقاط فيديو" + "التقاط صورة ثابتة" + "التقاط صورة" + "بدء تسجيل الفيديو" + "التبديل إلى الكاميرا بملء الشاشة" + "التبديل بين الكاميرا الأمامية والخلفية" + "وقف التسجيل وإرفاق مقطع الفيديو" + "إيقاف تسجيل الفيديو" + "صور المراسلة" + + لم يتم حفظ أية صورة (%d) في الألبوم \"%s\" + تم حفظ صورتين (%d) في الألبوم \"%s\" + تم حفظ %d صور في الألبوم \"%s\" + تم حفظ %d صورة في الألبوم \"%s\" + تم حفظ %d من الصور في الألبوم \"%s\" + تم حفظ صورة واحدة (%d) في الألبوم \"%s\" + + + لم يتم حفظ أي مقطع فيديو (%d) في الألبوم \"%s\" + تم حفظ مقطعي فيديو (%d) في الألبوم \"%s\" + تم حفظ %d مقاطع فيديو في الألبوم \"%s\" + تم حفظ %d مقطع فيديو في الألبوم \"%s\" + تم حفظ %d من مقاطع الفيديو في الألبوم \"%s\" + تم حفظ مقطع فيديو واحد (%d) في الألبوم \"%s\" + + + لم يتم حفظ أي مرفق (%d) في ألبوم \"%s\" + تم حفظ مرفقين (%d) في ألبوم \"%s\" + تم حفظ %d مرفقات في ألبوم \"%s\" + تم حفظ %d مرفقًا في ألبوم \"%s\" + تم حفظ %d من المرفقات في ألبوم \"%s\" + تم حفظ مرفق %d في ألبوم \"%s\" + + + لم يتم حفظ أي مرفق (%d) في \"التنزيلات\" + تم حفظ مرفقين (%d) في \"التنزيلات\" + تم حفظ %d مرفقات في \"التنزيلات\" + تم حفظ %d مرفقًا في \"التنزيلات\" + تم حفظ %d من المرفقات في \"التنزيلات\" + تم حفظ مرفق واحد (%d) في \"التنزيلات\" + + + لم يتم حفظ أية مرفقات (%d) + تم حفظ مرفقين (%d) + تم حفظ %d مرفقات + تم حفظ %d مرفقًا + تم حفظ %d من المرفقات + تم حفظ مرفق واحد %d + + + لم يتعذر حفظ أية مرفقات (%d) + تعذر حفظ مرفقين (%d) + تعذر حفظ %d مرفقات + تعذر حفظ %d مرفقًا + تعذر حفظ %d من المرفقات + تعذر حفظ مرفق واحد (%d) + + "تم حفظ مرفق رسالة الوسائط المتعددة" + "الإعدادات" + "المؤرشفة" + "إغلاق" + "رسالة وسائط متعددة" + "إعدادات متقدمة" + "تصحيح الأخطاء" + "الإشعارات" + "الصوت" + "صامت" + "اهتزاز" + "محظور" + "‏تقارير تسليم الرسائل القصيرة SMS" + "‏طلب تقرير تسليم لكل رسالة قصيرة SMS ترسلها" + "استرداد تلقائي" + "استرداد رسائل الوسائط المتعددة تلقائيًا" + "استرداد تلقائي للتجوال" + "استرداد رسائل الوسائط المتعددة عند التجوال تلقائيًا" + "المراسلة الجماعية" + "يمكنك استخدام رسائل الوسائط المتعددة لإرسال رسالة واحدة عندما يكون هناك عدة مستلمين" + "‏تطبيق الرسائل القصيرة SMS الافتراضي" + "‏تطبيق الرسائل القصيرة SMS الافتراضي" + + + "رقم هاتفك" + "غير معروف" + "أصوات الرسائل الصادرة" + "‏تفريغ الرسائل القصيرة SMS" + "‏تفريغ البيانات الأولية للرسائل القصيرة SMS في ملف تخزين خارجي" + "تفريغ رسائل الوسائط المتعددة" + "تفريغ البيانات الأولية لرسائل الوسائط المتعددة في ملف تخزين خارجي" + "تنبيهات لاسلكية" + "خيارات الرسالة" + "نسخ النص" + "عرض التفاصيل" + "حذف" + "إعادة توجيه" + "تفاصيل الرسالة" + "النوع: " + "رسالة نصية" + "رسالة وسائط متعددة" + "من: " + "إلى: " + "تاريخ الإرسال: " + "تاريخ التلقي: " + "الموضوع: " + "الحجم: " + "الأولوية: " + "SIM: " + "عالية" + "عادية" + "منخفضة" + "SIM %s" + "عنوان المرسل المخفي" + "يتعذر إرسال رسالة أثناء تحميل المرفقات." + "يتعذر تحميل المرفق. أعد المحاولة." + "الشبكة ليست جاهزة بعد. يمكنك إعادة المحاولة." + "حذف النص" + "التبديل بين إدخال النص والأرقام" + "إضافة مزيد من المشاركين" + "تأكيد المشاركين" + "بدء محادثة جديدة" + "تحديد هذا العنصر" + "تشغيل الفيديو" + "الأشخاص والخيارات" + "تصحيح الأخطاء" + "الأشخاص والخيارات" + "عام" + "الأشخاص في هذه المحادثة" + "إجراء مكالمة" + "إرسال رسالة" + "‏إرسال رسالة<br/><small>من %s</small>" + + ليست هناك صور لإرسالها + إرسال الصورتين + إرسال الصور + إرسال الصور + إرسال الصور + إرسال الصورة + + + ليست هناك ملفات صوت لإرسالها + إرسال ملفيّ الصوت + إرسال ملفات الصوت + إرسال ملفات الصوت + إرسال ملفات الصوت + إرسال ملف الصوت + + + ليست هناك ملفات فيديو لإرسالها + إرسال ملفي الفيديو + إرسال ملفات الفيديو + إرسال ملفات الفيديو + إرسال ملفات الفيديو + إرسال ملف الفيديو + + + ليست هناك بطاقات جهة اتصال لإرسالها + إرسال بطاقتي جهة الاتصال + إرسال بطاقات جهة الاتصال + إرسال بطاقات جهة الاتصال + إرسال بطاقات جهة الاتصال + إرسال بطاقة جهة الاتصال + + + ليست هناك مرفقات لإرسالها + إرسال المرفقين + إرسال المرفقات + إرسال المرفقات + إرسال المرفقات + إرسال المرفق + + + لم يتم تحديد أي مرفقات + مرفقان (%d) جاهزان للإرسال + %d مرفقات جاهزة للإرسال + %d مرفقًا جاهزًا للإرسال + %d من المرفقات الجاهزة للإرسال + مرفق واحد جاهز للإرسال + + "إرسال تعليقات" + "‏عرض في متجر Google Play" + "معلومات الإصدار" + "‏الإصدار %1$s" + "تراخيص برامج مفتوحة مصدر" + "الإشعارات" + "تم بلوغ حد المرفقات" + "أخفق تحميل المرفق." + "إضافة إلى جهات الاتصال؟" + "إضافة جهة اتصال" + "الموضوع" + "الموضوع: " + "%s%s" + "جارٍ تحميل بطاقة جهة الاتصال" + "تعذر تحميل بطاقة جهة الاتصال" + "عرض بطاقة جهة الاتصال" + + ليست هناك أية جهة اتصال (%d) + جهتا اتصال (%d) + %d جهات اتصال + %d جهة اتصال + %d من جهات الاتصال + جهة اتصال واحدة (%d) + + "بطاقات جهات الاتصال" + "تاريخ الميلاد" + "ملاحظات" + "إعادة توجيه الرسالة" + "رد" + "1+" + "+%d" + "‏تم تعطيل الرسائل القصيرة SMS" + "‏للإرسال، عيّن تطبيق المراسلة كتطبيق الرسائل القصيرة SMS الافتراضي" + "‏يجب تعيين تطبيق المراسلة كتطبيق الرسائل القصيرة SMS الافتراضي" + "تغيير" + "‏لاستلام رسائل، عيّن تطبيق المراسلة كتطبيق الرسائل القصيرة SMS الافتراضي" + "‏لم يتم تحديد شريحة SIM المفضلة لإرسال رسائل قصيرة SMS" + "لم يتم السماح لهذا التطبيق بواسطة مالك الجهاز." + "موافق" + "هناك عدد كبير جدًا من المشاركين في محادثة" + + ليست هناك جهات اتصال غير صالحة + جهتا اتصال غير صالحتين + جهات اتصال غير صالحة + جهات اتصال غير صالحة + جهات اتصال غير صالحة + جهة اتصال غير صالحة + + "يتعذر تحميل صورة الكاميرا" + "أنت: " + "%s: " + "مسودة" + "بعد بدء دردشة جديدة، ستظهر مدرجة هنا" + "الإشعارات المؤرشفة تظهر هنا" + "جارٍ تحميل المحادثات..." + "صورة" + "مقطع صوتي" + "فيديو" + "بطاقة جهة الاتصال" + "رسالة وسائط متعددة" + "تراجع" + "إعادة المحاولة" + "أدخل اسم جهة اتصال أو رقم هاتف لبدء رسالة جديدة" + "حظر" + "حظر %s" + "إلغاء حظر %s" + "هل تريد حظر %s؟" + "ستستمر في استلام الرسائل من هذا الرقم ولكن لن يتم إرسال إشعارات إليك مرة أخرى. ستتم أرشفة هذه المحادثة." + "جهات الاتصال المحظورة" + "إلغاء الحظر" + "جهات الاتصال المحظورة" + "اختيار صورة من مكتبة المستندات" + "جارٍ إرسال الرسالة" + "تم إرسال الرسالة" + "تم إيقاف اتصال بيانات الجوّال. تحقق من إعداداتك." + "يتعذر إرسال رسائل في وضع الطائرة" + "تعذر إرسال الرسالة" + "تم تنزيل الرسالة" + "تم إيقاف اتصال بيانات الجوّال. تحقق من إعداداتك." + "يتعذر تنزيل الرسائل في وضع الطائرة" + "تعذر تنزيل الرسالة" + "صفر" + "واحد" + "اثنان" + "ثلاثة" + "أربعة" + "خمسة" + "ستة" + "سبعة" + "ثمانية" + "تسعة" + "يتعذر إرسال الرسالة مع %1$s، الخطأ %2$d" + "يتعذر إرسال الرسالة مع مشغِّل شبكة جوّال غير معروف، الخطأ %1$d" + "إعادة التوجيه: %s" + "لم يتم إرسال الرسالة: لم يتم تنشيط الخدمة على الشبكة" + "لم يتم إرسال الرسالة: عنوان الوجهة غير صالح" + "لم يتم إرسال الرسالة: رسالة غير صالحة" + "لم يتم إرسال الرسالة: محتوى غير متوافق" + "لم يتم إرسال الرسالة: رسالة غير متوافقة" + "لم يتم إرسال الرسالة: الحجم كبير جدًا" + "رسالة جديدة" + "عرض" + "صورة" + "تعذر العثور على تطبيق مناسب" + "إزالة المستلم" + "رسالة جديدة" + "إلغاء" + "تعديل نقطة الدخول" + "لم يتم التعيين" + "الاسم" + "APN" + "MMSC" + "الخادم الوكيل لرسائل الوسائط المتعددة" + "منفذ رسائل الوسائط المتعددة" + "MCC" + "MNC" + "‏نوع APN" + "‏حذف APN" + "‏APN جديد" + "حفظ" + "تجاهل" + "لا يمكن أن يكون حقل الاسم فارغًا." + "‏لا يمكن أن يكون APN فارغًا." + "‏يجب أن يكون حقل MCC من ثلاثة أرقام." + "‏يجب أن يتكون حقل MNC من رقمين أو ثلاثة أرقام." + "‏جارٍ استعادة إعدادات APN الافتراضية." + "إعادة تعيين إلى الإعداد الافتراضي" + "‏اكتملت إعادة تعيين إعدادات APN الافتراضية." + "بلا عنوان" + "أسماء نقاط الدخول" + "‏أسماء نقاط الدخول (APN)" + "‏APN جديد" + "إعدادات اسم نقطة الدخول ليست متوفرة لهذا المستخدم" + "هل تريد النسخ في الحافظة؟" + "نسخ" + "إلى %s" + "عام" + "إعدادات متقدمة" + "الإعدادات العامة" + "إعدادات متقدمة" + "‏شريحة SIM من \"%s\"" + "إرسال رسالة قصيرة واحدة إلى جميع المستلمين. ولن تصل الردود إلا إليك" + "إرسال رسالة وسائط متعددة إلى جميع المستلمين" + "رقم غير معروف" + "رسالة جديدة" + "رسالة جديدة." + "‏محدد SIM" + "‏تم اختيار %1$s، أداة اختيار شريحة SIM" + "تعديل الموضوع" + "‏تحديد SIM أو تعديل الموضوع" + "المس مع الاستمرار لتسجيل الصوت" + "بدء محادثة جديدة" + "المراسلة" + "قائمة المراسلة" + "المراسلة" + "رسالة جديدة" + "قائمة المحادثات" + "جارٍ تحميل المحادثات." + "جارٍ تحميل الرسائل" + "عرض المزيد من المحادثات" + "عرض المزيد من الرسائل" + "تم حذف المحادثة" + "تم حذف المحادثة. المس لعرض محادثة مختلفة في تطبيق المراسلة" + "تم الحظر" + "تم إلغاء الحظر" + "مساحة التخزين منخفضة. ربما يتم فقد بعض البيانات." + "تحديد المرفقات" + "تأكيد التحديد" + "تم تحديد %d" + "يُرجى إزالة مرفق أو أكثر وإعادة المحاولة." + "يمكنك محاولة إرسال الرسالة، ولكن ربما لا يتم تسليمها ما لم تتم إزالة مرفق أو أكثر من هذه الرسالة." + "لا يمكنك إرسال أكثر من مقطع فيديو واحد لكل رسالة. يُرجى إزالة مقاطع الفيديو الإضافية وإعادة المحاولة." + "أخفق تطبيق المراسلة في تحميل المرفق." + "إرسال على أية حال" + "لا يمكن بدء المحادثة" + "%1$s (%2$s)" + "تم اختيار %s" + diff --git a/res/values-az-rAZ/arrays.xml b/res/values-az-rAZ/arrays.xml new file mode 100644 index 0000000..f4b43e4 --- /dev/null +++ b/res/values-az-rAZ/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "mövzu yoxdur" + "mövzusuz" + + + "Hə" + "Yox" + "Ok" + "Heh" + "Çox sağ olun" + "Razıyam" + "Nizza" + "Yolumda" + "Ok, Sizinlə sonra əlaqə saxlayacam" + ":)" + ":(" + + diff --git a/res/values-az-rAZ/strings.xml b/res/values-az-rAZ/strings.xml new file mode 100644 index 0000000..0bb8ef8 --- /dev/null +++ b/res/values-az-rAZ/strings.xml @@ -0,0 +1,519 @@ + + + + + "Mesajlaşma" + "Mesajlaşma" + "Söhbəti seç" + "Ayarlar" + "Mesaj göndər" + "Qoşma əlavə edin" + "Yardım" + "Xoş gəlmisiniz" + "Ötürün" + "Sonrakı >" + "Növbəti" + "Çıxış" + "&gt ayarlar;" + "Ayarlar" + "Mesajlaşma SMS, Telefon və Kontaktlara icazə istəyir." + "İcazələri Ayarlar > Tətbiqlər > Mesajlaşma > İcazələr bölməsindən dəyişə bilərsiniz." + "İcazələri Ayarlar, Tətbiqlər, Mesajlaşma, İcazələr bölməsindən dəyişə bilərsiniz." + "Tez-tez olanlar" + "Bütün kontaktlar" + "%s ünvanına göndərin" + "Şəkilləri və ya videonu çəkin" + "Bu cihazdan şəkillər seçin" + "Səs yazın" + "Foto seçin" + "Media seçildi." + "Media seçilmədi." + "%d seçilib" + "təsvir %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "təsvir" + "Səs yazın" + "Paylaş" + "İndicə" + "İndi" + + %d dəq + %d dəq + + + %d saat + %d saat + + + %d gün + %d gün + + + %d həftə + bir həftə + + + %d ay + bir ay + + + %d il + bir il + + "Klass 0 mesaj" + "Yadda saxlayın" + "Cihazda boş yer azdır. Yer boşaltmaq üçün Mesajlaşma köhnə mesajları avtomatik siləcək." + "Yaddaş ehtiyatı qurtarır" + "Mesajlaşma cihazınızda kifayət qədər yaddaş olmayana kimi mesaj göndərə və qəbul etməyə bilər." + "SMS yaddaşı azdır. Mesajları silmə ehtiyacı hiss edə bilərsiniz." + "Telefon nömrənizi təsdiq edin" + "Bu bir addımlı mərhələ Mesajlaşmanın qrup mesajlarınızı düzgün şəkildə çatdıracağını təmin edəcək." + "Telefon nömrəsi" + "Media ilə olan bütün mesajları silin" + "%s müddətindən əvvəl köhnə mesajları silin" + "%s müddətindən köhnə mesajlar avtomatik silinsin" + "İqnor edin" + "Bütün mesajlar media ilə birlikdə silinsin?" + "%s müddətindən köhnə mesajlar silinsin?" + "%s müddətindən köhnə mesajlar avtomatik silinsin və avtomatik silinmə aktiv olsun?" + "%s bildirib" + "Bildirdiniz" + "%s tərəfindən mesaj:" + "Mesajı göndərdiniz" + "Göndərilir..." + "Göndərilmədi. Yenidən cəhd etmək üçün toxunun." + "Göndərilmədi. Yenidən cəhd edilir..." + "Resend or delete" + "Lütfən, təcili yardım xidmətlərinə səsli zəng edin. Mətn mesajınız bu vaxt çatdırıla bilməz." + "Alınmadı" + "Yükləmək üçün yeni MMS mesajı" + "Yeni MMS mesajı" + "Yüklənmədi" + "Yenidən cəhd etmək üçün toxun" + "Yükləmək üçün toxun" + "Yüklə və ya sil" + "Yüklənir..." + "Mesajın vaxtı bitib və ya mövcud deyil" + "size: %1$s, expiration: %2$s" + "Göndərə bilmir. Alıcı etibarsızdır." + "Xidmət şəbəkədə aktiv deyil" + "Şəbəkə probleminə görə göndərmək olmadı" + "Mesajın vaxtı keçib və ya əlçatmazdır" + "(Mövzusuz)" + "Naməlum göndərən" + "Çatdırılıb" + "%1$s mesajı %2$s bölməsindən endirilə bilmir." + "Az yaddaş ilə əlaqədar olaraq verilənlər bazası əməliyyatı tamamlana bilmədi" + "Mesaj göndərilmədi" + "Bəzi mesajlar Mesajlaşmada göndərilməyib" + + %d söhbətdə %d mesaj + bir söhbətdə %d mesaj + + "Mesaj endirilmədi" + "Bəzi mesajlar Mesajlaşmada endirilməyib" + + %d söhbətdə %d mesaj + bir söhbətdə %d mesaj + + "%1$s nömrəsinə mesaj göndərilmədi" + "Lütfən, təcili yardım xidmətlərinə səsli zəng edin. Mətn mesajınız %1$s nömrəsinə bu vaxt çatdırıla bilməz." + + %d yeni mesaj + Yeni mesaj + + "Başla" + "Kamera əlçatmazdır" + "Kamera əlçatmazdır" + "Video çəkiliş əlçatmazdır" + "Medianı yadda saxlamaq olmur" + "Şəkil çəkmək mümkün deyil" + "Geri" + "Arxivləşmiş" + "Sil" + "Arxiv" + "Arxivdən çıxarın" + "Bildirişləri deaktiv edin" + "Bildirişləri aktivləşdirin" + "Kontakt əlavə edin" + "Endirin" + "Göndər" + "Sil" + "Bu mesaj silinsin?" + "Bu əməliyyat geri qaytarıla bilməz." + "Sil" + + Bu söhbətlər silinsin? + Bu söhbət silinsin? + + "Sil" + "Ləğv et" + "Kimə" + "Çox şəkil seçin" + "Seçimi təsdiq edin" + "+%d" + "Audionu qeydə almaq olmur. Yenidən cəhd edin." + "Audionu oxutmaq olmur. Yenidən cəhd edin." + "Audionu saxlamaq olmadı. Yenidən cəhd edin." + "Toxunun və saxlayın" + ", " + " " + ": " + " " + "Şəkil" + "Audio klip" + "Videolar" + "Kontakt kartı" + "Endirin" + "SMS ilə cavab verin" + "MMS vasitəsilə cavab verin" + "Cavabla" + + %d iştirakçı + %d iştirakçı + + "Mən" + "Kontakt bloklandı və arxivləndi" + "Kontakt blokdan və arxivdən çıxarıldı" + "%d arxivlənib" + "%d arxivdən çıxarıldı" + "Bildirişlər deaktiv edildi" + "Bildirişlər aktiv edildi" + "Hamısı hazırdır. Göndərmək üçün toxunun." + "Mesajlaşma uğurla defolt SMS tətbiqi seçildi." + + Qoşmaları ləğv edin + Qoşmanı ləğv edin + + "Audio qoşma" + "Audio qoşmanı ifa et" + "Pauza" + "%s adlı göndərəndən mesaj: %s." + "%s adlı göndərənin mesajı getmədi: %s. Vaxt: %s." + "%s adlı göndərən tərəfindən mesaj: %s. Vaxt: %s." + "%s adlı əlaqəyə göndərilməmiş mesaj: %s. Vaxt: %s." + "%s adlı əlaqəyə mesaj göndərilir: %s. Vaxt: %s." + "%s adlı əlaqəyə mesaj göndərilmədi: %s. Vaxt: %s." + "%s adlı əlaqəyə mesaj: %s. Vaxt: %s." + "%s adlı göndərənin mesajı getmədi: %s. Vaxt: %s. %s." + "%s adlı göndərən tərəfindən mesaj: %s. Vaxt: %s. %s." + "%s istifadəçisinə göndərilməmiş mesaj: %s. Vaxt: %s." + "%s istifadəçisinə mesaj göndərilir: %s. Vaxt: %s." + "%s istifadəçisinə mesaj göndərilmədi: %s. Vaxt: %s." + "%s istifadəçisinə mesaj: %s. Vaxt: %s." + "Mesaj göndərilmədi. Təkrar cəhd etmək üçün toxunun." + "%s ilə söhbət" + "Mövzunu silin" + "Video çəkin" + "Durğun şəkli çəkin" + "Şəkil çəkin" + "Video çəkilişə başlayın" + "Tam ekranlı kameraya keçin" + "Ön və arxa kameralar arasında seçim edin" + "Çəkilişi dayandırın və videonu qoşun" + "Video qeydi dayandırın" + "Foto mesajlaşma" + + %d foto \"%s\" albomunda yadda saxlanıldı + %d foto \"%s\" albomunda yadda saxlanıldı + + + %d video \"%s\" albomunda yadda saxlanıldı + %d video \"%s\" albomunda yadda saxlanıldı + + + %d qoşma \"%s\" albomunda yadda saxlanıldı + %d qoşma \"%s\" albomunda yadda saxlanıldı + + + %d qoşma \"Endirilənlər\" qovluğunda yadda saxlanıldı + %d qoşma \"Endirilənlər\" qovluğunda yadda saxlanıldı + + + %d qoşma yadda saxlanıldı + %d qoşma yadda saxlanıldı + + + %d qoşma yadda saxlanıla bilmədi + %d qoşma yadda saxlanıla bilmədi + + "MMS qoşması yadda saxlandı" + "Ayarlar" + "Arxivləşmiş" + "Bağla" + "MMS" + "Qabaqcıl" + "Sazlama" + "Bildirişlər" + "Səs" + "Səssiz" + "Titrət" + "Bloklanmış" + "SMS çatdırma xətaları" + "Hər göndərdiyiniz SMS üçün çatdırılma raportu tələb edin" + "Avtomatik əldə edin" + "MMS mesajları avtomatik əldə edin" + "Rouminq zamanı avtomatik əldə edin" + "Rominq zamanı MMS-i avtomatim bərpa edin" + "Qrup mesajlaşması" + "Bir neçə alıcının olduğu halda bir mesaj göndərmək üçün MMS istifadə edin" + "Defolt SMS proqramı" + "Defolt SMS proqramı" + + + "Telefon nömrəniz" + "Naməlum" + "Gedən mesaj səsləri" + "Damp SMS" + "Damp xarici saxlanma faylına emal edilməmiş SMS datası qəbul etdi" + "Damp MMS" + "Damp xarici saxlanma faylına emal edilməmiş MMS datası qəbul etdi" + "Simsiz siqnallar" + "Mesaj seçimləri" + "Mətni kopyalayın" + "Detallara baxın" + "Sil" + "Yönləndir" + "Mesaj detalları" + "Növ: " + "Mətn mesajı" + "Multimedia mesajı" + "Kimdən: " + "Kimə: " + "Göndərilən: " + "Qəbul edilən: " + "Mövzu: " + "Ölçü: " + "Prioritet: " + "SIM: " + "Yüksək" + "Normal" + "Aşağı" + "SIM %s" + "Gizli göndərici ünvanı" + "Qoşmalar yüklənən zaman mesaj göndərmək olmur." + "Qoşma yüklənə bilmir. Yenidən cəhd edin." + "Şəbəkə hazır deyil. Yenidən cəhd edin." + "Mətni silin" + "Mətn və nömrə daxiletməsi arasında seçim edin" + "İştirakçı əlavə edin" + "İştirakçıları təsdiq edin" + "Yeni söhbətə başlayın" + "Bu elementi seçin" + "Videonu oxut" + "Adamlar və seçimlər" + "Sazlama" + "Adamlar və seçimlər" + "Ümumi" + "Bu söhbətdəki adamlar" + "Zəng edin" + "Mesaj göndərin" + "Mesaj göndərin<br/><kiçik>buradan: %s</kiçik>" + + Fotolar göndərin + Foto göndərin + + + Audioları göndərin + Audio göndərin + + + Videoları göndərin + Video göndərin + + + Kontakt kartlarını göndərin + Kontakt kartını göndərin + + + Qoşmaları göndərin + Qoşma göndərin + + + %d qoşma göndərilmə üçün hazırdır + 1 qoşma göndərilmə üçün hazırdır + + "Geri əlaqə göndərin" + "Google Play Store\'də bax" + "Versiya haqqında" + "Versiya %1$s" + "Açıq mənbə lisenziyaları" + "Bildirişlər" + "Qoşma limitini keçdi" + "Qoşmanı yükləyə bilmədi." + "Kontaktlara əlavə edilsin?" + "Kontakt əlavə edin" + "Mövzu" + "Mövzu: " + "%s%s" + "Kontakt kartı yüklənir" + "Kontakt kartını yükləmək alınmadı" + "Kontakt kartına baxın" + + %d kontakt + %d kontakt + + "Kontakt kartları" + "Doğum günü" + "Qeydlər" + "Mesajı yönləndirin" + "Cavablayın" + "+1" + "+%d" + "SMS deaktivdir" + "Göndərmək üçün Mesajlaşmanı defolt SMS tətbiqi kimi ayarlayın" + "Mesajlaşmanı defolt SMS tətbiqi kimi ayarlayın" + "Dəyişin" + "Mesaj qəbul etmək üçün Mesajlaşmanı defolt SMS tətbiqi kimi ayarlayın" + "SMS göndərmək üçün tərcih olunan SIM seçilməyib" + "Bu tətbiqə cihaz sahibi tərəfindən icazə verilmir." + "Ok" + "Söhbətdə həddən çox iştirakçı var" + + Yanlış kontakt + Yanlış kontakt + + "Kamera şəklini yükləmək olmur" + "Siz: " + "%s: " + "Qaralama" + "Söhbətə başlayan kimi onu burada siyahılanmış görəcəksiniz" + "Arxivlənmiş söhbətlər burada görünür" + "Söhbətlər yüklənir…" + "Şəkil" + "Audio klip" + "Video" + "Əlaqə kartı" + "MMS" + "Geri" + "Yenidən cəhd edin" + "Yeni mesaja başlamaq üçün kontakt adını və ya telefon nömrəsini daxil edin" + "Blok edin" + "%s bloklayın" + "%s blokdan çıxarın" + "%s bloklansın?" + "Bu nömrədən mesaj almağa davam edəcəksiniz, lakin mesajın gəlməsinə dair bildiriş olmayacaq. Söhbət arxivlənəcək." + "Bloklanmış kontaktlar" + "BLOKDAN ÇIXARIN" + "Bloklanmış kontaktlar" + "Sənəd kitabxanasından şəkil seçin" + "Mesaj göndərilir" + "Mesaj göndərildi" + "Mobil data deaktivdir. Ayarları yoxlayın." + "Təyyarə rejimində mesaj göndərmək olmur" + "Mesaj göndərilə bilmədi" + "Mesaj yükləndi" + "Mobil data deaktivdir. Ayarları yoxlayın." + "Təyyarrə rejimində mesajları endirə bilməz" + "Mesaj endirilə bilmədi" + "Sıfır" + "Bir" + "İki" + "Üç" + "Dörd" + "Beş" + "Altı" + "Yeddi" + "Səkkiz" + "Doqquz" + "Mesajı %1$s ilə göndərmək olmur, xəta %2$d" + "Mesajı naməlum operator ilə göndərmək olmur, xəta %1$d" + "Yön: %s" + "Mesaj göndərilmədi. Xidmət şəbəkədə aktivləşdirilməyib." + "Mesaj göndərilmədi: Etibarsız təyinat ünvanı" + "Mesaj göndərilmədi: etibarsız mesaj" + "Mesaj göndərilmədi: dəstəklənməyən məzmun" + "Mesaj göndərilmədi: dəstəklənməyən mesaj" + "Mesaj göndərilmədi: çox böyükdür" + "Yeni mesaj" + "Göstər" + "Təsvir" + "Uyğun tətbiqi tapmaq olmadı" + "Qəbuledicini silin" + "Yeni mesaj" + "Ləğv et" + "Edit access point" + "Ayarlanmayıb" + "Ad" + "APN" + "MMSC" + "MMS proksi" + "MMS por" + "MCC" + "MNC" + "APN növü" + "APN silin" + "Yeni APN" + "Yadda saxlayın" + "İmtina edin" + "Ad sahəsi boş ola bilməz." + "APN boş ola bilməz." + "MCC sahəsində 3 rəqəm olmalıdır." + "MNC sahəsi 2 və ya 3 rəqəm olmalıdır." + "Defolt APN ayarları bərpa olunur." + "Sıfırlayın" + "Varsayılan APN ayarlarının sıfırlanması tamamlandı." + "Başlıqsız" + "Çatma Nöqtəsi Adları" + "APNs" + "Yeni APN" + "Giriş Nöqtəsi Ad Ayarları bu istifadəçi üçün əlçatmazdır" + "Panoya kopyalansın?" + "Kopyalayın" + "Buraya: %s" + "Ümumi" + "Qabaqcıl" + "Ümumi ayarlar" + "Qabaqcıl ayarlar" + "\"%s\" SIM" + "Bütün alıcılara fərdi SMS göndər. Hər hansı bir cavabı özünüz olacaq" + "Bütün alıcılara bir MMS göndər" + "Naməlum nömrə" + "Yeni mesaj" + "Yeni mesaj." + "SIM seçicisi" + "%1$s seçildi, SIM seçicisi" + "Mövzunu dəyiş" + "SİM seçin və ya mövzu redaktə edin" + "Səs yazmaq üçün basın və saxlayın" + "Yeni söhbətə başla" + "Mesajlaşma" + "Mesajlaşma Siyahısı" + "Mesajlaşma" + "Yeni mesaj" + "Söhbət siyahısı" + "Söhbətlər yüklənir" + "Mesajlar yüklənir" + "Daha çox söhbət görüntülə" + "Daha çox mesaja bax" + "Söhbət silinib" + "Söhbət silindi. Başqa Mesajlaşma söhbəti göstərmək üçün toxunun" + "Bloklanıb" + "Blokdan çıxarılıb" + "Yaddaş yeri azdır. Bəzi məlumatlar itə bilər." + "Qoşmaları seçin" + "Seçimi təsdiq edin" + "%d seçilib" + "Lütfən, bir və ya daha çox qoşmanı silin və yenidən cəhd edin." + "Mesajınızı göndərməyə cəhd edə bilərsiniz, lakin bir və ya daha çox qoşmanı silmədiyiniz halda, mesaj çatmaya bilər." + "Hər mesaja yalnız bir video yükləyə bilərsiniz. Əlavə videoları silin və yenidən cəhd edin." + "Mesajlaşma qoşmanı yükləyə bilmədi." + "İstənilən halda göndərin" + "Söhbətə başlamaq olmadı" + "%1$s (%2$s)" + "%s seçilmiş" + diff --git a/res/values-bg/arrays.xml b/res/values-bg/arrays.xml new file mode 100644 index 0000000..ac036c4 --- /dev/null +++ b/res/values-bg/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "без тема" + "няма тема" + + + "Да" + "Не" + "ОK" + "Ха-ха" + "Благодаря" + "Приемам" + "Чудесно" + "Идвам" + "OK, ще се свържа с теб по-късно" + ":)" + ":(" + + diff --git a/res/values-bg/strings.xml b/res/values-bg/strings.xml new file mode 100644 index 0000000..5251fa8 --- /dev/null +++ b/res/values-bg/strings.xml @@ -0,0 +1,519 @@ + + + + + "Съобщения" + "Съобщения" + "Избиране на разговор" + "Настройки" + "Изпращане на съобщение" + "Добавяне на прикачен файл" + "Помощ" + "Добре дошли" + "Пропускане" + "Напред >" + "Напред" + "Изход" + "Настройки >" + "Настройки" + "Приложението Съобщения се нуждае от разрешение за SMS, телефон и контакти." + "Можете да промените разрешенията от „Настройки“ > „Приложения“ > „Съобщения“ > „Разрешения“." + "Можете да промените разрешенията от „Настройки“, „Приложения“, „Съобщения“, „Разрешения“." + "Често търсени" + "Всички контакти" + "Изпращане до %s" + "Правете снимки или видеоклипове" + "Избиране на изображения от това устройство" + "Запишете звук" + "Избиране на снимка" + "Мултимедийният файл е избран." + "Изборът на мултимедиен файл е премахнат." + "Избрахте %d" + "изображение от %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "изображение" + "Запис на звук" + "Споделяне" + "Току-що" + "Сега" + + %d мин + %d мин + + + %d часа + %d час + + + %d дни + %d ден + + + %d седмици + една седмица + + + %d месеца + един месец + + + %d години + една година + + "Съобщения от клас 0" + "Запазване" + "Мястото на устройството не е достатъчно. Приложението Съобщения автоматично ще изтрие по-старите кореспонденции, за да освободи място." + "Мястото в хранилището е на изчерпване" + "Възможно е Съобщения да не изпраща, нито да получава съобщения, докато на устройството не се освободи повече място." + "Мястото в хранилището за SMS намалява. Може да се наложи да изтриете съобщения." + "Потвърждаване на телефонния ви номер" + "Тази еднократна стъпка ще гарантира, че Съобщения ще доставя правилно груповите ви съобщения." + "Телефонен номер" + "Изтриване на всички съобщения с мултимедия" + "Изтриване на съобщенията, по-стари от %s" + "Автоматично изтриване на съобщенията, по-стари от %s" + "Пренебрегване" + "Да се изтрият ли всички съобщения с медийни файлове?" + "Да се изтрият ли съобщенията, по-стари от %s?" + "Да се изтрият ли съобщенията, по-стари от %s, и да се включи ли функцията за автоматично изтриване?" + "%s каза" + "Казахте" + "Съобщение от %s" + "Изпратихте съобщение" + "Изпраща се…" + "Не е изпратено. Докоснете за нов опит." + "Не е изпратено. Извършва се нов опит…" + "Повторно изпращане или изтриване" + "Моля, осъществете гласово обаждане до спешните служби. Понастоящем текстовото ви съобщение не можа да бъде доставено." + "Не бе успешно" + "Ново MMS съобщение за изтегляне" + "Ново MMS съобщение" + "Не можа да се изтегли" + "Докоснете за нов опит" + "Докоснете за изтегляне" + "Изтегляне или изтриване" + "Изтегля се…" + "Съобщението е с изтекла валидност или не е налице" + "размер: %1$s, валидност: %2$s" + "Не може да се изпрати. Получателят не е валиден." + "Услугата не е активирана за мрежата" + "Не можа да се изпрати поради проблем с мрежата" + "Съобщението е с изтекла валидност или не е налице" + "(Без тема)" + "Неизвестен подател" + "Доставено" + "Съобщението „%1$s“ от %2$s не можа да се изтегли." + "Операцията в базата от данни не можа да завърши поради недостиг на памет" + "Съобщението не е изпратено" + "Някои съобщения в приложението Съобщения не са изпратени" + + %d съобщения в %d разговора + %d съобщения в един разговор + + "Съобщението не е изтеглено" + "Някои съобщения в приложението Съобщения не са изтеглени" + + %d съобщения в %d разговора + %d съобщения в един разговор + + "Съобщението до %1$s не е изпратено" + "Моля, осъществете гласово обаждане до спешните служби. Понастоящем текстовото ви съобщение до %1$s не можа да бъде доставено." + + %d нови съобщения + Ново съобщение + + "Стартиране" + "Няма достъп до камерата" + "Няма достъп до камерата" + "Функцията за заснемане на видео не е налице" + "Медийният файл не може да се запази" + "Не може да се направи снимка" + "Назад" + "Архивирани" + "Изтриване" + "Архивиране" + "Деархивиране" + "Изключване на известията" + "Включване на известията" + "Добавяне на контакт" + "Изтегляне" + "Изпращане" + "Изтриване" + "Да се изтрие ли това съобщение?" + "Това действие не може да се отмени." + "Изтриване" + + Да се изтрият ли тези разговори? + Да се изтрие ли този разговор? + + "Изтриване" + "Отказ" + "До" + "Избиране на няколко изображения" + "Потвърждаване на избора" + "+ %d" + "Аудиото не може да се запише. Опитайте отново." + "Аудиото не може да се възпроизведе. Опитайте отново." + "Аудиото не можа да се запази. Опитайте отново." + "Докоснете и задръжте" + ", " + " ," + ": " + " :" + "Снимка" + "Аудиоклип" + "Видеоклип" + "Визитна картичка" + "Изтегляне" + "Отговор чрез SMS" + "Отговор чрез MMS" + "Отговор" + + %d участници + %d участник + + "Аз" + "Контактът е блокиран и архивиран" + "Контактът е отблокиран и деархивиран" + "Архивирахте %d" + "Деархивирахте %d" + "Известията са изключени" + "Известията са включени" + "Готово. Докоснете „Изпращане“ отново." + "Успешно зададохте Съобщения като стандартно приложение за SMS." + + Отхвърляне на прикачените файлове + Отхвърляне на прикачения файл + + "Прикачен аудиофайл" + "Пускане на прикачения аудиофайл" + "Пауза" + "Съобщение от %s: „%s“." + "Неуспешно съобщение от %s: „%s“. Час: %s." + "Съобщение от %s: „%s“. Час: %s." + "Неизпратено съобщение до %s: „%s“. Час: %s." + "Изпраща се съобщение до %s: „%s“. Час: %s." + "Неуспешно съобщение до %s: „%s“. Час: %s." + "Съобщение до %s: „%s“. Час: %s." + "Неуспешно съобщение от %s: „%s“. Час: %s. %s." + "Съобщение от %s: „%s“. Час: %s. %s." + "Неизпратено съобщение до %s: „%s“. Час: %s." + "Изпраща се съобщение до %s: „%s“. Час: %s." + "Неуспешно съобщение до %s: „%s“. Час: %s." + "Съобщение до %s: „%s“. Час: %s." + "Неуспешно съобщение. Докоснете, за да опитате отново." + "Разговор с/ъс %s" + "Изтриване на темата" + "Заснемане на видеоклип" + "Заснемане на кадър" + "Правене на снимка" + "Стартиране на записване на видеоклип" + "Превключване към камера на цял екран" + "Превключване между предната и задната камера" + "Спиране на записването и прикачване на видеоклипа" + "Спиране на видеозаписа" + "Снимки в Съобщения" + + %d снимки са запазени в албума „%s + %d снимка е запазена в албума „%s + + + %d видеоклипа са запазени в албума „%s + %d видеоклип е запазен в албума „%s + + + %d прикачени файла са запазени в албума „%s + %d прикачен файл е запазен в албума „%s + + + %d прикачени файла са запазени в „Изтегляния“ + %d прикачен файл е запазен в „Изтегляния“ + + + %d прикачени файла са запазени + %d прикачен файл е запазен + + + %d прикачени файла не можаха да бъдат запазени + %d прикачен файл не можа да се запази + + "Запазен прикачен файл от MMS" + "Настройки" + "Архивирани" + "Затваряне" + "MMS" + "Разширени" + "Отстраняване на грешки" + "Известия" + "Звук" + "Тих режим" + "Вибриране" + "Блокирано" + "Отчети за получаване на SMS" + "Заявяване на отчет за доставка за всеки изпратен от вас SMS" + "Автоматично извличане" + "Автоматично извличане на мултимедийните съобщения (MMS)" + "Автоматично извличане при роуминг" + "Автоматично извличане на MMS при роуминг" + "Групови съобщения" + "Използвайте MMS, за да изпратите едно съобщение, когато има няколко получатели" + "Стандартно приложение за SMS" + "Стандартно приложение за SMS" + + + "Вашият телефонен номер" + "Няма информация" + "Звуци за изходящи съобщения" + "Разтоварване на SMS" + "Разтоварване на необработените данни от получените SMS съобщения във файл във външното хранилище" + "Разтоварване на MMS" + "Разтоварване на необработените данни от получените MMS съобщения във файл във външното хранилище" + "Безжични сигнали" + "Опции на съобщението" + "Копиране на текста" + "Преглед на подробностите" + "Изтриване" + "Напред" + "Подробности за съобщението" + "Тип: " + "Текстово съобщение" + "Мултимедийно съобщение" + "От: " + "До: " + "Изпратено: " + "Получено: " + "Тема: " + "Размер: " + "Приоритет: " + "SIM: " + "Висок" + "Нормален" + "Нисък" + "SIM %s" + "Скрит адрес на изпращача" + "Съобщението не може да се изпрати, докато прикачените файлове се зареждат." + "Прикаченият файл не може да се зареди. Опитайте отново." + "Мрежата не е готова. Опитайте отново." + "Изтриване на текста" + "Превключване между букви и цифри" + "Добавяне на още участници" + "Потвърждаване на участниците" + "Начало на нова кореспонденция" + "Избиране на този елемент" + "Пускане на видеоклипа" + "Хора и опции" + "Отстраняване на грешки" + "Хора и опции" + "Общи настройки" + "Хора в този разговор" + "Обаждане по телефона" + "Изпращане на съобщение" + "Изпращане на съобщение<br/><small>от %s</small>" + + Изпращане на снимките + Изпращане на снимката + + + Изпращане на аудиозаписите + Изпращане на аудиозаписа + + + Изпращане на видеоклиповете + Изпращане на видеоклипа + + + Изпращане на визитните картички + Изпращане на визитната картичка + + + Изпращане на прикачените файлове + Изпращане на прикачения файл + + + %d прикачени файла са готови за изпращане + Един прикачен файл е готов за изпращане + + "Изпращане на отзиви" + "Преглед в Google Play Магазин" + "Информация за версията" + "Версия %1$s" + "Лицензи за отворен код" + "Известия" + "Ограничението за прикачени файлове е достигнато" + "Зареждането на прикачения файл не бе успешно." + "Да се добави ли към контактите?" + "Добавяне на контакт" + "Тема" + "Тема: " + "„%s“ – „%s“" + "Визитната картичка се зарежда" + "Визитната картичка не можа да се зареди" + "Преглед на визитната картичка" + + %d контакта + %d контакт + + "Визитни картички" + "Рожден ден" + "Бележки" + "Препращане на съобщението" + "Отговор" + "+1" + "+%d" + "SMS съобщенията са деактивирани" + "За да изпращате съобщения, задайте Съобщения като стандартно приложение за SMS" + "Задайте Съобщения като стандартно приложение за SMS" + "Промяна" + "За да получавате съобщения, задайте Съобщения като стандартно приложение за SMS" + "Няма избрана предпочитана SIM карта за изпращане на SMS съобщения" + "Това приложение не е разрешено от собственика на устройството." + "ОK" + "Твърде много участници в разговора" + + Контактите са невалидни + Контактът е невалиден + + "Изображението от камерата не можа да се зареди" + "Вие: " + "%s: " + "Чернова" + "Когато започнете нов разговор, ще го видите посочен тук" + "Тук се показват архивираните кореспонденции" + "Кореспонденциите се зареждат…" + "Снимка" + "Аудиоклип" + "Видеоклип" + "Визитна картичка" + "MMS" + "Отмяна" + "Повторен опит" + "Въведете име на контакт или телефонен номер, за да започнете ново съобщение" + "Блокиране" + "Блокиране на %s" + "Отблокиране на %s" + "Да се блокира ли %s?" + "Ще продължите да получавате съобщения от този номер, но вече няма да бъдете известявани. Кореспонденцията ще се архивира." + "Блокирани контакти" + "ОТБЛОКИРАНЕ" + "Блокирани контакти" + "Избиране на изображение от библиотеката с документи" + "Съобщението се изпраща" + "Съобщението е изпратено" + "Мобилните данни са изключени. Проверете настройките си." + "Не можете да изпращате съобщения в самолетен режим" + "Съобщението не можа да се изпрати" + "Съобщението е изтеглено" + "Мобилните данни са изключени. Проверете настройките си." + "Съобщенията не могат да се изтеглят в самолетен режим" + "Съобщението не можа да бъде изтеглено" + "нула" + "едно" + "две" + "три" + "четири" + "пет" + "шест" + "седем" + "осем" + "девет" + "Съобщението не може да се изпрати с/ъс %1$s – грешка %2$d" + "Съобщението не може да се изпрати с неизвестен оператор – грешка %1$d" + "Препр.: %s" + "Съобщението не е изпратено: Услугата не е активирана за мрежата" + "Съобщението не е изпратено: невалиден целеви адрес" + "Съобщението не е изпратено: невалидно съобщение" + "Съобщението не е изпратено: неподдържано съдържание" + "Съобщението не е изпратено: неподдържано съобщение" + "Съобщението не е изпратено: твърде голямо" + "Ново съобщение" + "Преглед" + "Изображение" + "Не можа да бъде намерено подходящо приложение" + "Премахване на получателя" + "Ново съобщение" + "Отказ" + "Редактиране на точката за достъп" + "Не е зададено" + "Име" + "Име на точката за достъп (APN)" + "MMSC" + "MMS прокси сървър" + "MMS порт" + "Mобилен код на държава" + "Код на мобилна мрежа (MNC)" + "Тип на името на точката за достъп (APN)" + "Изтриване на името на точката за достъп (APN)" + "Ново име на точката за достъп (APN)" + "Запазване" + "Отхвърляне" + "Полето за име трябва да се попълни." + "Името на точката за достъп (APN) трябва да се попълни." + "Полето за мобилен код на държавата трябва да е 3 цифри." + "Полето за код на мобилна мрежа (MNC) трябва да е с 2 или 3 цифри." + "Стандартните настройки за името на точката за достъп (APN) се възстановяват." + "Възстановяване на стандартните" + "Възстановяването на стандартните настройки за името на точката за достъп (APN) завърши." + "Без име" + "Имена на точката за достъп" + "Имена на точката за достъп (APN)" + "Ново име на точката за достъп (APN)" + "Настройките за име на точката за достъп не са налице за този потребител" + "Да се копира ли в буферната памет?" + "Копиране" + "до „%s“" + "Общи настройки" + "Разширени настройки" + "Общи настройки" + "Разширени настройки" + "SIM карта „%s“" + "Изпращане на отделни SMS съобщения до всички участници. Само вие ще получавате отговорите" + "Изпращане на един MMS до всички участници" + "Неизвестен номер" + "Ново съобщение" + "Ново съобщение." + "Инструмент за избор на SIM карта" + "Избрахте „%1$s“ – инструмент за избиране на SIM карти" + "Редактиране на темата" + "Избиране на SIM карта или редактиране на темата" + "Докоснете и задръжте, за да запишете аудио" + "Начало на нова кореспонденция" + "Съобщения" + "Списък със съобщения" + "Съобщения" + "Ново съобщение" + "Списък с кореспонденции" + "Кореспонденциите се зареждат" + "Съобщенията се зареждат" + "Преглед на още кореспонденции" + "Преглед на още съобщения" + "Кореспонденцията бе изтрита" + "Разговорът е изтрит. Докоснете за показване на друг от Съобщения" + "Блокирано" + "Отблокирано" + "Мястото в хранилището е малко. Може да изгубите някои данни." + "Избиране на прикачени файлове" + "Потвърждаване на избора" + "Избрахте %d" + "Моля, премахнете един или повече прикачени файлове и опитайте отново." + "Можете да опитате да изпратите съобщението си, но е възможно то да не бъде доставено, ако не премахнете един или повече прикачени файлове." + "Можете да изпращате само по един видеоклип с всяко съобщение. Моля, премахнете допълнителните клипове и опитайте отново." + "Зареждането на прикачения файл от Съобщения не бе успешно." + "Изпращане въпреки това" + "Разговорът не можа да започне" + "%1$s (%2$s)" + "Избрахте %s" + diff --git a/res/values-bn-rBD/arrays.xml b/res/values-bn-rBD/arrays.xml new file mode 100644 index 0000000..bdf164c --- /dev/null +++ b/res/values-bn-rBD/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "কোনো বিষয় নেই" + "বিষয়হীন" + + + "হ্যাঁ" + "না" + "ঠিক আছে" + "হিহি" + "ধন্যবাদ" + "আমি রাজি" + "সুন্দর" + "রাস্তায় আছি" + "ঠিক আছে, পরে আপনার সঙ্গে এই নিয়ে কথা বলছি" + ":)" + ":(" + + diff --git a/res/values-bn-rBD/strings.xml b/res/values-bn-rBD/strings.xml new file mode 100644 index 0000000..8c58185 --- /dev/null +++ b/res/values-bn-rBD/strings.xml @@ -0,0 +1,519 @@ + + + + + "বার্তাপ্রেরণ" + "বার্তাপ্রেরণ" + "কথোপকথন নির্বাচন করুন" + "সেটিংস" + "বার্তা পাঠান" + "একটি সংযুক্তি যোগ করুন" + "সহায়তা" + "স্বাগতম" + "এড়িয়ে যান" + "পরবর্তী >" + "পরবর্তী" + "প্রস্থান করুন" + "সেটিংস >" + "সেটিংস" + "SMS, ফোন এবং পরিচিতিগুলিতে বার্তাপ্রেরণ -এর অনুমতির প্রয়োজন।" + "আপনি সেটিংস > অ্যাপ্লিকেশানগুলি > বার্তাপ্রেরণ > অনুমতিগুলি এ অনুমতিগুলি পরিবর্তন করতে পারেন৷" + "আপনি সেটিংস, অ্যাপ্লিকেশানগুলি, বার্তাপ্রেরণ, অনুমতিগুলি এ অনুমতিগুলি পরিবর্তন করতে পারেন৷" + "প্রায়শই ব্যবহৃত" + "সকল পরিচিতি" + "%s এ পাঠান" + "ছবি বা ভিডিও ক্যাপচার করুন" + "এই ডিভাইস থেকে চিত্র নির্বাচন করুন" + "অডিও রেকর্ড করুন" + "ফটো চয়ন করুন" + "মিডিয়া নির্বাচিত হয়েছে।" + "মিডিয়া নির্বাচিত হয় নি।" + "%dটি নির্বাচন করা হয়েছে" + "চিত্র %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "চিত্র" + "অডিও রেকর্ড করুন" + "ভাগ করুন" + "এইমাত্র" + "এখনই" + + %d মিনিট + %d মিনিট + + + %d ঘণ্টা + %d ঘণ্টা + + + %d দিন + %d দিন + + + %d সপ্তাহ + %d সপ্তাহ + + + %d মাস + %d মাস + + + %d বছর + %d বছর + + "ক্লাস ০ বার্তা" + "সংরক্ষণ করুন" + "ডিভাইসে কম জায়গা আছে। বার্তাপ্রেরণ জায়গা খালি করতে স্বয়ংক্রিয়ভাবে পুরানো পাঠ্য বার্তাগুলি মুছে ফেলবে।" + "সঞ্চয়স্থান পূর্ণ হতে চলেছে" + "আপনার ফোনে অরো বেশি সঞ্চয়স্থান উপলব্ধ না থাকলে, বার্তাপ্রেরণ বার্তাগুলি পাঠাতে বা গ্রহণ করতে নাও পারে৷" + "SMS সংগ্রহস্থল বেশি নেই৷ আপনাকে বার্তা মুছতে হতে পারে৷" + "আপনার ফোন নম্বর নিশ্চিত করুন" + "এই এককালীন ধাপটি নিশ্চিত করে যে বার্তাপ্রেরণ গোষ্ঠী বার্তাগুলিকে সঠিকভাবে বিতরণ করবে৷" + "ফোন নম্বর" + "মিডিয়ার সাথে সমস্ত বার্তা মুছুন" + "%s এর থেকে পুরানো বার্তাগুলি মুছুন" + "%s-এর থেকে পুরানো বার্তা স্বংয়ক্রিয়ভাবে মুছুন" + "উপেক্ষা করুন" + "মিডিয়ার সমন্বিত সমস্ত বার্তা মুছুন" + "%s-এর থেকে পুরানো বার্তাগুলি মুছবেন?" + "%s-এর থেকে পুরানো বার্তা মুছে দিন এবং স্বয়ংক্রিয়ভাবে মোছা চালু করবেন?" + "%s বলেছেন" + "আপনি বলেছেন" + "%s এর থেকে বার্তা" + "আপনি একটি বার্তা পাঠিয়েছেন" + "পাঠানো হচ্ছে..." + "পাঠানো হয়নি। আবার চেষ্টা করতে স্পর্শ করুন।" + "পাঠানো হয়নি। আবার চেষ্টা হচ্ছে…" + "আবার পাঠান অথবা মুছে ফেলুন" + "অনুগ্রহ করে জরুরি পরিষেবাগুলিতে একটি ভয়েস কল করুন৷ আপনার পাঠ্য বার্তাগুলিকে এই মূহুর্তে বিতরণ করা যাবে না৷" + "ব্যর্থ হয়েছে" + "ডাউনলোড করার জন্য নতুন MMS বার্তা" + "নতুন MMS বার্তা" + "ডাউনলোড করা যায়নি" + "আবার চেষ্টা করতে স্পর্শ করুন" + "ডাউনলোড করার জন্য স্পর্শ করুন" + "ডাউনলোড করুন অথবা মুছুন" + "ডাউনলোড হচ্ছে..." + "বার্তার মেয়াদ শেষ হয়ে গেছে বা অনুপলব্ধ" + "আকার: %1$s, মেয়াদ শেষ: %2$s" + "পাঠানো যাবে না। প্রাপক বৈধ নয়।" + "নেটওয়ার্কে পরিষেবা সক্রিয় নেই" + "নেটওয়ার্ক সমস্যার কারণে পাঠানো যায়নি" + "বার্তার মেয়াদ শেষ হয়ে গেছে বা অনুপলব্ধ" + "(কোনো বিষয় নেই)" + "অজ্ঞাত প্রেরক" + "পৌঁছে দেওয়া হয়েছে" + "%2$s থেকে %1$s বার্তা ডাউনলোড করা যায়নি।" + "কম মেমরি থাকার কারণে ডেটাবেস ক্রিয়াকলাপ সম্পন্ন করা যায়নি" + "বার্তা পাঠানো হয়নি" + "বার্তাপ্রেরণ এ কিছু বার্তা পাঠানো হয়নি" + + %dটি কথোপকথনে %dটি বার্তা রয়েছে + %dটি কথোপকথনে %dটি বার্তা রয়েছে + + "বার্তা ডাউনলোড করা যাবে না" + "বার্তাপ্রেরণ এ কিছু বার্তা ডাউনলোড করা যায়নি" + + %dটি কথোপকথনে %dটি বার্তা রয়েছে + %dটি কথোপকথনে %dটি বার্তা রয়েছে + + "%1$s এ বার্তা পাঠানো হয়নি" + "অনুগ্রহ করে জরুরি পরিষেবাগুলিতে একটি ভয়েস কল করুন৷ %1$s এ পাঠানো আপনার পাঠ্য বার্তাগুলিকে এই মূহুর্তে বিতরণ করা যাবে না৷" + + %dটি নতুন বার্তা + %dটি নতুন বার্তা + + "আরম্ভ" + "ক্যামেরা উপলব্ধ নয়" + "ক্যামেরা উপলব্ধ নয়" + "ভিডিও ক্যাপচার উপলব্ধ নয়" + "মিডিয়া সংরক্ষণ করা যাবে না" + "ছবি তুলতে পারবেন না" + "ফিরুন" + "সংরক্ষণাগারভুক্ত" + "মুছুন" + "সংরক্ষণ করুন" + "সংরক্ষণাগারমুক্ত করুন" + "বিজ্ঞপ্তিগুলি বন্ধ করুন" + "বিজ্ঞপ্তি চালু করুন" + "পরিচিতি যোগ করুন" + "ডাউনলোড করুন" + "পাঠান" + "মুছুন" + "এই বার্তাটি মুছবেন?" + "এই কাজটিকে পূর্বাবস্থায় ফেরানো যাবে না৷" + "মুছুন" + + এই কথোপকথনগুলি মুছবেন? + এই কথোপকথনগুলি মুছবেন? + + "মুছুন" + "বাতিল করুন" + "প্রাপক" + "একাধিক চিত্র নির্বাচন করুন" + "নির্বাচন নিশ্চিত করুন" + "+%dটি" + "অডিও রেকর্ড করা যাবে না। আবার চেষ্টা করুন।" + "অডিও প্লে করা যাবে না। আবার চেষ্টা করুন।" + "অডিও সংরক্ষণ করা যায়নি। আবার চেষ্টা করুন" + "স্পর্শ করুন এবং ধরে রাখুন" + ", " + " " + ": " + " " + "ছবি" + "অডিও ক্লিপ" + "ভিডিও" + "পরিচিতি কার্ড" + "ডাউনলোড" + "SMS এর মাধ্যমে উত্তর দিন" + "MMS এর মাধ্যমে উত্তর দিন" + "উত্তর দিন" + + %d জন অংশগ্রহণকারী + %d জন অংশগ্রহণকারী + + "আমি" + "পরিচিতি অবরুদ্ধ ও সংরক্ষণাগারভুক্ত হয়েছে" + "পরিচিতি অবরোধ মুক্ত এবং সংরক্ষণাগারমুক্ত করা হয়েছে" + "%d টি সংরক্ষণাগারভুক্ত হয়েছে" + "%d সংরক্ষণাগারমুক্ত হয়েছে" + "বিজ্ঞপ্তিগুলি বন্ধ করা হয়েছে" + "বিজ্ঞপ্তিগুলি চালু করা হয়েছে" + "সবকিছু সেট। আবার পাঠাতে স্পর্শ করুন।" + "ডিফল্ট SMS অ্যাপ্লিকেশান হিসাবে বার্তাপ্রেরণ সফলভাবে সেট করা হয়েছে৷" + + সংযুক্তিগুলি বাতিল করুন + সংযুক্তিগুলি বাতিল করুন + + "অডিও সংযুক্তি" + "অডিও সংযুক্তি চালান" + "বিরাম দিন" + "%s এর পাঠানো বার্তা: %s৷" + "%s:-র %s বার্তা পাঠাতে ব্যর্থ হয়েছে। সময়: %s।" + "%s: এর থেকে %s বার্তা। সময়: %s।" + "%s: -কে %s বার্তা পাঠানো যায় নি। সময়: %s।" + "%s: -কে %s বার্তা পাঠাচ্ছে। সময়: %s।" + "%s: -কে %s বার্তা পাঠাতে ব্যর্থ হয়েছে। সময়: %s।" + "%s: -কে %s বার্তা পাঠান। সময়: %s।" + "%s: %s বার্তা পাঠাতে ব্যর্থ হয়েছেন। সময়: %s%s।" + "%s: এর থেকে %s বার্তা। সময়: %s%s।" + "%s: -কে %s বার্তা পাঠানো যায় নি। সময়: %s।" + "%s:-এ %s বার্তা পাঠানো হচ্ছে। সময়: %s।" + "%s: -এ %s বার্তা পাঠাতে ব্যর্থ হয়েছে। সময়: %s।" + "%s: -এ %s বার্তা পাঠান। সময়: %s।" + "বার্তা পাঠাতে ব্যর্থ হয়েছে। পুনরায় চেষ্টা করতে স্পর্শ করুন।" + "%s এর সাথে কথোপকথন" + "বিষয় মুছুন" + "ভিডিও ক্যাপচার করুন" + "একটি স্থির চিত্র ক্যাপচার করুন" + "ছবি তুলুন" + "ভিডিও রেকর্ডিং শুরু করুন" + "সম্পূর্ণ স্ক্রীন ক্যামেরাতে স্যুইচ করুন" + "সামনে ও পিছনে ক্যামেরার মধ্যে স্যুইচ করুন" + "রেকর্ডিং করা বন্ধ করুন এবং ভিডিও সংযুক্ত করুন" + "ভিডিও রেকর্ডিং বন্ধ করুন" + "বার্তাপ্রেরণ ফটোগুলি" + + %dটি ফটো \"%s\" অ্যালবামে সংরক্ষিত হয়েছে + %dটি ফটো \"%s\" অ্যালবামে সংরক্ষিত হয়েছে + + + %dটি ভিডিও \"%s\" অ্যালবামে সংরক্ষিত হয়েছে + %dটি ভিডিও \"%s\" অ্যালবামে সংরক্ষিত হয়েছে + + + %dটি সংযুক্তি \"%s\" অ্যালবামে সংরক্ষিত হয়েছে + %dটি সংযুক্তি \"%s\" অ্যালবামে সংরক্ষিত হয়েছে + + + %dটি সংযুক্তি \"ডাউনলোডগুলি\" এ সংরক্ষিত হয়েছে + %dটি সংযুক্তি \"ডাউনলোডগুলি\" এ সংরক্ষিত হয়েছে + + + %dটি সংযুক্তি সংরক্ষণ করা হয়েছে + %dটি সংযুক্তি সংরক্ষণ করা হয়েছে + + + %dটি সংযুক্তি সংরক্ষণ করা যায়নি + %dটি সংযুক্তি সংরক্ষণ করা যায়নি + + "MMS সংযুক্তি সংরক্ষণ করা হয়েছে" + "সেটিংস" + "সংরক্ষণাগারভুক্ত" + "বন্ধ করুন" + "MMS" + "উন্নত" + "ডিবাগ" + "বিজ্ঞপ্তিগুলি" + "আওয়াজ" + "নীরব" + "কম্পন" + "অবরুদ্ধ" + "SMS বিতরণের প্রতিবেদনগুলি" + "আপনি যে SMS পাঠাবেন তার প্রতিটির জন্য পৌঁছে দেওয়ার প্রতিবেদনের অনুরোধ করুন" + "স্বতঃ-উদ্ধার" + "স্বয়ংক্রিয়ভাবে MMS বার্তাগুলি পুনরুদ্ধার করুন" + "রোমিং থাকাকালীন স্বতঃ-উদ্ধার" + "রোমিং-এ থাকার সময় স্বয়ংক্রিয়ভাবে MMS পুনরুদ্ধার করুন" + "গোষ্ঠীর মধ্যে বার্তাপ্রেরণ" + "যখন একাধিক প্রাপক রয়েছে তখন একটি বার্তা পাঠাতে MMS ব্যবহার করুন" + "ডিফল্ট SMS অ্যাপ্লিকেশান" + "ডিফল্ট SMS অ্যাপ্লিকেশান" + + + "আপনার ফোন নম্বর" + "অজানা" + "আউটগোয়িং বার্তার শব্দগুলি" + "ডাম্প SMS" + "পাওয়া SMS এর অপীষ্কার ডেটাকে বহিরাগত সংগ্রহস্থলে জমা করুন" + "ডাম্প MMS" + "গৃহিত MMS এর কাঁচা ডেটাকে বহিরাগত সংগ্রহস্থলে জমা করুন" + "বেতার সতর্কতাগুলি" + "বার্তা বিকল্পগুলি" + "পাঠ্য অনুলিপি করুন" + "বিশদ বিবরণ দেখুন" + "মুছুন" + "ফরওয়ার্ড করুন" + "বার্তার বিশদ বিবরণ" + "প্রকার: " + "পাঠ্য বার্তা" + "মাল্টিমিডিয়া বার্তা" + "প্রেরক: " + "প্রাপক: " + "পাঠানো হয়েছে: " + "গ্রহণ করা হয়েছে: " + "বিষয়: " + "মাপ: " + "অগ্রাধিকার: " + "SIM: " + "উচ্চ" + "স্বাভাবিক" + "নিম্ন" + "SIM %s" + "লুকানো প্রেরক ঠিকানা" + "সংযুক্তি লোড করার সময় বার্তা পাঠানো যাচ্ছে না।" + "সংযুক্তি লোড করা যাবে না। আবার চেষ্টা করুন।" + "নেটওয়ার্ক প্রস্তুত নয়৷ আবার চেষ্টা করুন৷" + "পাঠ্য মুছে দিন" + "পাঠ্য এবং সংখ্যা লেখার মধ্যে স্যুইচ করুন" + "আরো অংশগ্রহণকারী যোগ করুন" + "অংশগ্রহণকারীদের নিশ্চিত করুন" + "নতুন কথোপকথন শুরু করুন" + "এই আইটেমটি নির্বাচন করুন" + "ভিডিও দেখুন" + "ব্যক্তি ও বিকল্পগুলি" + "ডিবাগ" + "ব্যক্তি ও বিকল্পগুলি" + "সাধারণ" + "এই কথোপকথনে থাকা ব্যক্তিগণ" + "একটি কল করুন" + "বার্তা পাঠান" + "%s</small> থেকে <br/><small>from বার্তা পাঠান" + + ফটোগুলি পাঠান + ফটোগুলি পাঠান + + + অডিওগুলি পাঠান + অডিওগুলি পাঠান + + + ভিডিওগুলি পাঠান + ভিডিওগুলি পাঠান + + + পরিচিতি কার্ডগুলি পাঠান + পরিচিতি কার্ডগুলি পাঠান + + + সংযুক্তিগুলি পাঠান + সংযুক্তিগুলি পাঠান + + + পাঠানোর জন্য %dটি সংযুক্তি প্রস্তুত + পাঠানোর জন্য %dটি সংযুক্তি প্রস্তুত + + "প্রতিক্রিয়া পাঠান" + "Google Play স্টোরে দেখুন" + "সংস্করণ তথ্য" + "সংস্করণ %1$s" + "মুক্ত উৎস লাইসেন্সগুলি" + "বিজ্ঞপ্তিগুলি" + "সংযুক্তির সীমা অতিক্রান্ত হয়েছে" + "সংযুক্তি লোড করতে ব্যর্থ হয়েছে।" + "পরিচিতিতে জুড়ুন" + "পরিচিতি জুড়ুন" + "বিষয়" + "বিষয়: " + "%s%s" + "পরিচিতি কার্ড লোড হচ্ছে" + "পরিচিতি কার্ড লোড করা যায়নি" + "পরিচিতি কার্ড দেখুন" + + %dটি পরিচিতি + %dটি পরিচিতি + + "যোগাযোগ কার্ড" + "জন্মদিন" + "টীকাগুলি" + "বার্তা ফরোয়ার্ড করুন" + "উত্তর" + "+১" + "+%d" + "SMS অক্ষম করা হয়েছে" + "পাঠাতে, ডিফল্ট SMS অ্যাপ্লিকেশান হিসাবে বার্তাপ্রেরণ সেট করুন" + "ডিফল্ট SMS অ্যাপ্লিকেশান হিসাবে বার্তাপ্রেরণ সেট করুন" + "পরিবর্তন" + "বার্তাগুলি গ্রহণ করতে, আপনার ডিফল্ট SMS অ্যাপ্লিকেশান হিসাবে বার্তাপ্রেরণ সেট করুন" + "SMS বার্তাগুলি পাঠানোর জন্য কোন পছন্দের SIM নির্বাচিত নেই৷" + "এই অ্যাপ্লিকেশানটি ডিভাইস মালিক দ্বারা অনুমোদিত নয়।" + "ঠিক আছে" + "একটি কথোপকথনে খুব বেশি সংখ্যার অংশগ্রহণকারী" + + অবৈধ পরিচিতিগুলি + অবৈধ পরিচিতিগুলি + + "ক্যামেরা চিত্র লোড করা যায়নি" + "আপনি: " + "%s: " + "খসড়া" + "আপনি নতুন কোনো কথোপকথন শুরু করলে, সেটিকে এই তালিকায় দেখতে পাবেন" + "সংরক্ষিত করা কথোপকথন এখানে প্রদর্শিত হয়" + "কথোপকথনগুলি লোড হচ্ছে…" + "ছবি" + "অডিও ক্লিপ" + "ভিডিও" + "পরিচিতি কার্ড" + "MMS" + "পূর্বাবস্থায় ফিরুন" + "পুনরায় চেষ্টা করুন" + "একটি নতুন বার্তা পাঠাতে একটি পরিচিতি নাম অথবা ফোন নম্বর লিখুন" + "অবরুদ্ধ করুন" + "%s-কে অবরুদ্ধ করুন" + "%s-কে অবরোধ মুক্ত করুন" + "%s-কে অবরুদ্ধ করবেন?" + "আপনি এই নম্বর থেকে অবিরত বার্তা গ্রহণ করতে করবেন কিন্তু আপনাকে আর সূচিত করা হবে না। এই কথোপকথন সংরক্ষণ করা হবে।" + "অবরুদ্ধ পরিচিতিগুলি" + "অবরোধ মুক্ত করুন" + "অবরুদ্ধ পরিচিতিগুলি" + "দস্তাবেজ লাইব্রেরি থেকে চিত্র নির্বাচন করুন" + "বার্তা পাঠানো হচ্ছে" + "বার্তা পাঠানো হয়েছে" + "সেলুলার ডেটা বন্ধ আছে। আপনার সেটিংস চেক করুন।" + "বিমান মোডে বার্তা পাঠানো যাবে না" + "এই বার্তাটি পাঠানো যায়নি" + "বার্তা ডাউনলোড করা হয়েছে" + "সেলুলার ডেটা বন্ধ আছে। আপনার সেটিংস চেক করুন।" + "বিমান মোডে বার্তাগুলি ডাউনলোড করা যাবে না" + "বার্তা ডাউনলোড করা যায়নি" + "শূন্য" + "এক" + "দুই" + "তিন" + "চার" + "পাঁচ" + "ছয়" + "সাত" + "আট" + "নয়" + "%1$s এর মাধ্যমে বার্তা পাঠানো যাবে না, ত্র্রুটি %2$d" + "অজানা পরিষেবা প্রদানকারীর মাধ্যমে বার্তা পাঠানো যাবে না, ত্রুটি %1$d" + "ফরওয়ার্ড: %s" + "বার্তা পাঠানো হয়নি: নেটওয়ার্কে পরিষেবা সক্রিয় নেই" + "বার্তা পাঠানো যায়নি: অবৈধ গন্তব্য ঠিকানা" + "বার্তা পাঠানো যায়নি: অবৈধ বার্তা" + "বার্তা পাঠানো যায়নি: অসমর্থিত বিষয়বস্তু" + "বার্তা পাঠানো যায়নি: অসমর্থিত বার্তা" + "বার্তা পাঠানো হয়নি: খুব বড়" + "নতুন বার্তা" + "দেখুন" + "চিত্র" + "কোনো উপযুক্ত অ্যাপ্লিকেশান পাওয়া যায়নি" + "প্রাপককে সরান" + "নতুন বার্তা" + "বাতিল করুন" + "অ্যাক্সেস পয়েন্ট সম্পাদনা করুন" + "সেট করা নেই" + "নাম" + "APN" + "MMSC" + "MMS প্রক্সি" + "MMS পোর্ট" + "MCC" + "MNC" + "APN প্রকার" + "APN মুছে দিন" + "নতুন APN" + "সংরক্ষণ করুন" + "খারিজ করুন" + "নামের ক্ষেত্রটি খালি থাকতে পারে না।" + "APN খালি থাকতে পারে না।" + "MCC ক্ষেত্রটিকে ৩ ডিজিটের হতে হবে।" + "MNC ক্ষেত্রটিকে ২ অথবা ৩ ডিজিটের হতে হবে।" + "ডিফল্ট APN সেটিংস পুনরুদ্ধার করা হচ্ছে।" + "পুনরায় ডিফল্ট হিসাবে সেট করুন" + "ডিফল্ট APN সেটিংস পুনরায় সেট করা সম্পন্ন হয়েছে।" + "শীর্ষকহীন" + "অ্যাক্সেস পয়েন্টের নামগুলি" + "APNগুলি" + "নতুন APN" + "এই ব্যবহারকারীর জন্য অ্যাক্সেস পয়েন্ট নাম সেটিংস উপলব্ধ নয়" + "ক্লিপবোর্ডে অনুলিপি করবেন?" + "অনুলিপি করুন" + "%s এ" + "সাধারণ" + "আরো বিকল্প" + "সাধারণ সেটিংস" + "উন্নত সেটিংস" + "\"%s\" SIM" + "সকল প্রাপকের কাছে আলাদা আলাদা করে SMS পাঠান৷ শুধুমাত্র আপনিই উত্তরগুলি পাবেন" + "সকল প্রাপকের কাছে একটি নির্দিষ্ট MMS পাঠান" + "অজানা নম্বর" + "নতুন বার্তা" + "নতুন বার্তা৷" + "SIM নির্বাচক" + "%1$s নির্বাচিত, SIM নির্বাচক" + "বিষয় সম্পাদনা করুন" + "SIM নির্বাচন করুন বা বিষয় সম্পাদনা করুন" + "অডিও রেকর্ড করতে স্পর্শ করুন এবং ধরে রাখুন" + "নতুন কথোপকথন শুরু করুন" + "বার্তাপ্রেরণ" + "বার্তাপ্রেরণ তালিকা" + "বার্তাপ্রেরণ" + "নতুন বার্তা" + "কথোপকথনের তালিকা" + "কথোপকথনগুলি লোড হচ্ছে" + "বার্তাগুলি লোড করা হচ্ছে" + "আরো কথোপকথন দেখুন" + "আরো বার্তা দেখুন" + "কথোপকথন মুছে দেওয়া হয়েছে" + "কথোপকথন মুছে ফেলা হয়েছে৷ একটি ভিন্ন বার্তাপ্রেরণ কথোপকথন দেখানোর জন্য স্পর্শ করুন" + "অবরুদ্ধ" + "অবরোধ মুক্ত করা হয়েছে" + "সঞ্চয়স্থান কম রয়েছে৷ কিছু ডেটা হারিয়ে যেতে পারে৷" + "সংযুক্তিগুলি নির্বাচন করুন" + "নির্বাচন নিশ্চিত করুন" + "%dটি নির্বাচন করা হয়েছে" + "একটি অথবা আরও বেশি সংযুক্তি সরিয়ে আবার চেষ্টা করুন।" + "আপনি আপনার বার্তা পাঠানোর চেষ্টা করতে পারেন, কিন্তু আপনি এক বা একাধিক সংযুক্তি অপসারণ না করা পর্যন্ত তা বিতরণ নাও করা হতে পারে।" + "আপনি বার্তা প্রতি শুধুমাত্র একটি ভিডিও পাঠাতে পারেন৷ অতিরিক্ত ভিডিওগুলি সরিয়ে দিয়ে আবার চেষ্টা করুন৷" + "বার্তাপ্রেরণ সংযুক্তি লোড করতে ব্যর্থ হয়েছে৷" + "তবুও পাঠান" + "কথোপকথন শুরু করা যায়নি" + "%1$s (%2$s)" + "%s নির্বাচিত হয়েছে" + diff --git a/res/values-ca/arrays.xml b/res/values-ca/arrays.xml new file mode 100644 index 0000000..69ce724 --- /dev/null +++ b/res/values-ca/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "sense assumpte" + "nosubject" + + + "Sí" + "No" + "D\'acord" + "Hehe" + "Gràcies" + "Accepto" + "Molt bé" + "Ja vinc." + "D\'acord. Et diré alguna cosa més tard." + ":)" + ":(" + + diff --git a/res/values-ca/strings.xml b/res/values-ca/strings.xml new file mode 100644 index 0000000..8923f0c --- /dev/null +++ b/res/values-ca/strings.xml @@ -0,0 +1,519 @@ + + + + + "Missatges" + "Missatges" + "Selecció de la conversa" + "Configuració" + "Envia el missatge" + "Afegeix un fitxer adjunt" + "Ajuda" + "Et donem la benvinguda" + "Omet" + "Següent >" + "Següent" + "Surt" + "Configuració >" + "Configuració" + "Missatges necessita permís per accedir als SMS, al telèfon i als contactes." + "Pots canviar els permisos a Configuració > Aplicacions > Missatges > Permisos." + "Pots canviar els permisos a Configuració, Aplicacions, Missatges, Permisos." + "Freqüents" + "Tots els contactes" + "Envia al %s" + "Captura fotos o vídeos" + "Tria imatges d\'aquest dispositiu." + "Enregistra l\'àudio" + "Triar una foto" + "S\'ha seleccionat el fitxer multimèdia." + "No hi ha cap fitxer multimèdia seleccionat." + "Seleccionats: %d" + "imatge: %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "imatge" + "Enregistra l\'àudio" + "Comparteix" + "Ara mateix" + "Ara" + + %d min + %d min + + + %d hores + %d hora + + + %d dies + %d dia + + + %d setmanes + una setmana + + + %d mesos + un mes + + + %d anys + un any + + "Missatge de classe 0" + "Desa" + "Queda poc espai al dispositiu. Per alliberar-ne, Missatgeria suprimirà automàticament els missatges antics." + "L\'espai d\'emmagatzematge s\'està esgotant" + "Pot ser que Missatges no enviï ni rebi missatges fins que no tinguis més espai disponible al dispositiu." + "Poc emmagatzematge per a SMS. És possible que et calgui suprimir missatges." + "Confirma el teu número de telèfon" + "Aquest pas únic garanteix que els missatges de grup s\'enviïn correctament amb Missatges." + "Número de telèfon" + "Suprimeix tots els missatges amb fitxers multimèdia" + "Suprimeix els missatges de més de %s" + "Suprimeix automàticament els missatges de més de %s" + "Ignora" + "Confirmes que vols suprimir tots els missatges amb fitxers multimèdia?" + "Confirmes que vols suprimir els missatges de més de %s?" + "Confirmes que vols suprimir els missatges de més de %s i activar la funció per suprimir-los automàticament?" + "%s ha dit:" + "Has dit:" + "Missatge de: %s" + "Has enviat un missatge." + "S\'està enviant…" + "No s\'ha enviat. Toca per tornar-ho a provar." + "No s\'ha enviat. S\'està tornant a provar…" + "Reenvia o suprimeix" + "Truca als serveis d\'emergència. En aquest moment, el missatge de text no s\'ha pogut enviar." + "S\'ha produït un error." + "Missatge MMS nou per baixar" + "Missatge MMS nou" + "No s\'ha pogut baixar." + "Toca per tornar-ho a provar" + "Toca per baixar" + "Baixa o suprimeix" + "S\'està baixant..." + "El missatge ha caducat o no està disponible." + "mida: %1$s, caducitat: %2$s" + "No es pot enviar. El destinatari no és vàlid." + "El servei no està activat a la xarxa." + "No s\'ha pogut enviar a causa d\'un problema de xarxa." + "El missatge ha caducat o no està disponible." + "(Sense assumpte)" + "Emissor desconegut" + "Entregat" + "No s\'ha pogut baixar el missatge %1$s de %2$s." + "No s\'ha pogut completar l\'operació de la base de dades perquè hi ha poca memòria." + "No s\'ha enviat el missatge." + "Alguns missatges no s\'han enviat a Missatges" + + %d missatges en %d converses + %d missatges en una conversa + + "No s\'ha baixat el missatge" + "Alguns missatges a Missatges no s\'han baixat" + + %d missatges en %d converses + %d missatges en una conversa + + "No s\'ha enviat el missatge al %1$s" + "Truca als serveis d\'emergència. En aquest moment, el missatge de text al %1$s no s\'ha pogut enviar." + + %d missatges nous + Missatge nou + + "Inicia" + "La càmera no està disponible." + "La càmera no està disponible." + "La captura de vídeo no està disponible." + "No es pot desar el fitxer multimèdia." + "No es pot fer una foto." + "Enrere" + "Arxivades" + "Suprimeix" + "Arxiu" + "Treu de l\'arxiu" + "Desactiva les notificacions" + "Activa les notificacions" + "Afegeix un contacte" + "Baixa" + "Envia" + "Suprimeix" + "Vols suprimir aquest missatge?" + "Aquesta acció no es pot desfer." + "Suprimeix" + + Vols suprimir aquestes converses? + Vols suprimir aquesta conversa? + + "Suprimeix" + "Cancel·la" + "Per a" + "Selecciona diverses imatges" + "Confirma la selecció" + "+%d" + "No es pot enregistrar l\'àudio. Torna-ho a provar." + "No es pot reproduir l\'àudio. Torna-ho a provar." + "No s\'ha pogut desar l\'àudio. Torna-ho a provar." + "Toca i mantén premut" + ", " + " " + ": " + " " + "Imatge" + "Clip d\'àudio" + "Vídeo" + "Targeta de contacte" + "Baixa" + "Respon amb un SMS" + "Respon amb un MMS" + "Respon" + + %d participants + %d participant + + "Jo" + "Contactes bloquejats i arxivats" + "Contacte desbloquejat i no arxivat" + "Arxivades: %d" + "No arxivades: %d" + "Notificacions desactivades" + "Notificacions activades" + "Ja està tot llest. Torna a tocar Envia." + "Missatges s\'ha definit correctament com a aplicació predeterminada de SMS." + + Descarta els fitxers adjunts + Descarta el fitxer adjunt + + "Àudio adjunt" + "Reprodueix l\'àudio adjunt" + "Atura" + "Missatge de %s: %s." + "Missatge no enviat de %s: %s. Hora: %s." + "Missatge de %s: %s. Hora: %s." + "Missatge no enviat a %s: %s. Hora: %s" + "S\'està enviant un missatge a %s: %s. Hora: %s." + "Missatge no enviat a %s: %s. Hora: %s." + "Missatge a %s: %s. Hora: %s." + "Missatge no enviat de %s: %s. Hora: %s. %s." + "Missatge de %s: %s. Hora: %s. %s." + "Missatge no enviat a %s: %s. Hora: %s" + "S\'està enviant un missatge a %s: %s. Hora: %s." + "Missatge no enviat a %s: %s. Hora: %s." + "Missatge a %s: %s. Hora: %s." + "Hi ha hagut un problema amb aquest missatge. Toca per tornar-ho a provar." + "Conversa amb %s" + "Suprimeix l\'assumpte" + "Captura un vídeo" + "Captura una imatge fixa" + "Fes una foto" + "Comença a enregistrar vídeo" + "Canvia a la càmera de pantalla completa" + "Canvia entre la càmera frontal i la posterior" + "Deixa d\'enregistrar i adjunta un vídeo" + "Atura l\'enregistrament de vídeo" + "Fotos de missatges" + + S\'han desat %d fotos a l\'àlbum %s + S\'ha desat %d foto a l\'àlbum %s + + + S\'han desat %d vídeos a l\'àlbum %s + S\'ha desat %d vídeo a l\'àlbum %s + + + S\'han desat %d fitxers adjunts a l\'àlbum %s + S\'ha desat %d fitxer adjunt a l\'àlbum %s + + + S\'han desat %d fitxers adjunts a Baixades + S\'ha desat %d fitxer adjunt a Baixades + + + S\'han desat %d fitxers adjunts + S\'ha desat %d fitxer adjunt + + + No s\'han pogut desar %d fitxers adjunts + No s\'ha pogut desar %d fitxer adjunt + + "S\'ha desat el fitxer adjunt MMS." + "Configuració" + "Arxivades" + "Tanca" + "MMS" + "Avançada" + "Depuració" + "Notificacions" + "So" + "Silenci" + "Vibració" + "S\'ha bloquejat" + "Informes d\'entrega de SMS" + "Sol·licita un informe d\'entrega per a cada SMS enviat" + "Recuperació automàtica" + "Recupera MMS automàticament" + "Recupera aut. els MMS en itinerància" + "Recupera MMS automàticament en itinerància" + "Missatge de grup" + "Utilitza MMS per enviar un únic missatge a diversos destinataris" + "Aplicació de SMS predeterminada" + "Aplicació de SMS predeterminada" + + + "Número de telèfon" + "Desconegut" + "Sons per a missatges de sortida" + "Envia el contingut dels SMS" + "Envia les dades sense processar dels SMS rebuts al fitxer d\'emmagatzematge extern." + "Envia el contingut dels MMS" + "Buida les dades sense processar dels MMS rebuts en un fitxer d’emmagatzematge extern" + "Alertes sense fil" + "Opcions del missatge" + "Copia el text" + "Mostra els detalls" + "Suprimeix" + "Reenvia" + "Detalls del missatge" + "Tipus: " + "Missatge de text" + "Missatge multimèdia" + "De: " + "Per a: " + "Enviat: " + "Rebut: " + "Assumpte: " + "Mida: " + "Prioritat: " + "SIM: " + "Alta" + "Normal" + "Baixa" + "SIM %s" + "Amaga l\'adreça del remitent" + "No es pot enviar el missatge durant la càrrega dels fitxers adjunts." + "No s\'ha pogut carregar el fitxer adjunt. Torna-ho a provar." + "La xarxa no està a punt. Torna-ho a provar." + "Suprimeix el text" + "Alterna entre escriure lletres i números" + "Afegeix més participants" + "Confirma els participants" + "Inicia una conversa nova" + "Selecciona aquest element." + "Reprodueix el vídeo." + "Persones i opcions" + "Depura" + "Persones i opcions" + "General" + "Persones en aquesta conversa" + "Fes una trucada" + "Envia el missatge" + "Envia un missatge<br/><small>des de %s</small>." + + Envia les fotos + Envia la foto + + + Envia els àudios + Envia l\'àudio + + + Envia els vídeos + Envia el vídeo + + + Envia les targetes de contacte + Envia la targeta de contacte + + + Envia els fitxers adjunts + Envia el fitxer adjunt + + + Ja es poden enviar %d fitxers adjunts + Ja es pot enviar un fitxer adjunt + + "Envia suggeriments" + "Mostra a Google Play Store" + "Informació de la versió" + "Versió %1$s" + "Llicències de codi obert" + "Notificacions" + "S\'ha assolit el límit de fitxers adjunts" + "No s\'ha pogut carregar el fitxer adjunt." + "Vols afegir-lo als contactes?" + "Afegeix el contacte" + "Assumpte" + "Assumpte: " + "%s%s" + "S\'està carregant la targeta de contacte" + "No s\'ha pogut carregar la targeta de contacte" + "Mostra la targeta de contactes" + + %d contactes + %d contacte + + "Targetes de contacte" + "Natalici" + "Notes" + "Reenvia el missatge" + "Respon" + "+1" + "+%d" + "SMS desactivats" + "Per enviar missatges, defineix Missatges com a aplicació predeterminada de SMS" + "Defineix Missatges com a aplicació predeterminada de SMS" + "Canvia" + "Per rebre missatges, defineix Missatges com a aplicació predeterminada de SMS" + "No s\'ha seleccionat cap SIM preferida per enviar missatges SMS." + "El propietari del dispositiu no admet l\'aplicació." + "D\'acord" + "Hi ha massa participants a la conversa." + + Els contactes no són vàlids + El contacte no és vàlid + + "No s\'ha pogut carregar la imatge de la càmera." + "Tu: " + "%s: " + "Esborrany" + "Quan comencis una conversa nova, es mostrarà aquí." + "Les converses arxivades es mostren aquí." + "S\'estan carregant les converses..." + "Imatge" + "Clip d\'àudio" + "Vídeo" + "Targeta de contacte" + "MMS" + "Desfés" + "Torna-ho a provar" + "Introdueix el nom d\'un contacte o el número de telèfon per escriure un missatge nou." + "Bloqueja" + "Bloqueja %s" + "Desbloqueja %s" + "Vols bloquejar %s?" + "Continuaràs rebent missatges d\'aquest número, però no se t\'enviarà cap notificació. Aquesta conversa s\'arxivarà." + "Contactes bloquejats" + "DESBLOQUEJA" + "Contactes bloquejats" + "Tria una imatge de la biblioteca de documents." + "S\'està enviant el missatge" + "Missatge enviat" + "Les dades del telèfon estan desactivades. Comprova\'n la configuració." + "No es poden enviar missatges amb el mode d\'avió activat." + "No s\'ha pogut enviar el missatge" + "Missatge baixat" + "Les dades del telèfon estan desactivades. Comprova\'n la configuració." + "No es poden baixar missatges en mode d\'avió" + "El missatge no s\'ha pogut baixar" + "Zero" + "Un" + "Dos" + "Tres" + "Quatre" + "Cinc" + "Sis" + "Set" + "Vuit" + "Nou" + "No es pot enviar el missatge amb %1$s, error %2$d" + "No es pot enviar el missatge amb un operador de telefonia mòbil desconegut, error %1$d" + "Rv: %s" + "No s\'ha enviat el missatge; el servei no s\'ha activat a la xarxa." + "Missatge no enviat: adreça de destinació no vàlida" + "Missatge no enviat: missatge no vàlid" + "Missatge no enviat: contingut no admès" + "Missatge no enviat: missatge no admès" + "El missatge no s\'ha enviat: és massa gran" + "Missatge nou" + "Mostra" + "Imatge" + "No s\'ha pogut trobar una aplicació adequada." + "Suprimeix el destinatari." + "Missatge nou" + "Cancel·la" + "Edita el punt d\'accés" + "No definit" + "Nom" + "APN" + "MMSC" + "Servidor intermediari MMS" + "Port per a MMS" + "MCC" + "MNC" + "Tipus d\'APN" + "Suprimeix l\'APN" + "APN nou" + "Desa" + "Descarta" + "El camp Nom no pot quedar buit." + "L\'APN no pot quedar buit." + "El camp MCC ha de tenir 3 dígits." + "El camp MNC ha de tenir 2 o 3 dígits." + "S\'està restaurant la configuració predeterminada d\'APN." + "Restableix al valor predeterminat" + "S\'ha restablert la configuració predeterminada d\'APN." + "Sense títol" + "Noms de punt d\'accés" + "APN" + "APN nou" + "La configuració del nom del punt d\'accés no està disponible per a aquest usuari." + "Ho vols copiar al porta-retalls?" + "Copia" + "per a %s" + "General" + "Avançada" + "Configuració general" + "Configuració avançada" + "SIM %s" + "Envia missatges SMS individuals a tots els destinataris. Només tu rebràs les respostes." + "Envia un MMS individual a tots els destinataris" + "Número desconegut" + "Missatge nou" + "Missatge nou" + "Selector de SIM" + "%1$s seleccionada, selector de SIM" + "Edita l\'assumpte" + "Selecciona la SIM o edita l\'assumpte" + "Mantén premut per enregistrar àudio" + "Inicia una conversa nova" + "Missatges" + "Llista de missatges" + "Missatges" + "Missatge nou" + "Llista de converses" + "S\'estan carregant les converses" + "S\'estan carregant els missatges" + "Mostra més converses" + "Mostra més missatges" + "S\'ha suprimit la conversa." + "S\'ha suprimit la conversa. Toca per mostrar una altra conversa de Missatges" + "Bloquejat" + "Desbloquejat" + "Hi ha poc espai d\'emmagatzematge. És possible que es perdin algunes dades." + "Selecciona els fitxers adjunts" + "Confirma la selecció" + "Seleccionats: %d" + "Suprimeix un o més fitxers adjunts i torna-ho a provar." + "Pots provar d\'enviar el missatge, però és possible que no s\'enviï si no suprimeixes un o més fitxers adjunts." + "Només pots enviar un vídeo per missatge. Suprimeix els vídeos addicionals i torna-ho a provar." + "Missatges no ha pogut carregar el fitxer adjunt." + "Envia igualment" + "No s\'ha pogut iniciar la conversa" + "%1$s (%2$s)" + "%s seleccionada" + diff --git a/res/values-cs/arrays.xml b/res/values-cs/arrays.xml new file mode 100644 index 0000000..95c2775 --- /dev/null +++ b/res/values-cs/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "bez předmětu" + "bezpředmětu" + + + "Ano" + "Ne" + "OK" + "Cha cha" + "Díky" + "Souhlasím" + "Hezké" + "Jsem na cestě." + "OK, ozvu se později." + ":)" + ":(" + + diff --git a/res/values-cs/strings.xml b/res/values-cs/strings.xml new file mode 100644 index 0000000..8b56e47 --- /dev/null +++ b/res/values-cs/strings.xml @@ -0,0 +1,571 @@ + + + + + "SMS a MMS" + "SMS a MMS" + "Vybrat konverzaci" + "Nastavení" + "Odeslat zprávu" + "Přidat přílohu" + "Nápověda" + "Vítejte" + "Přeskočit" + "Další >" + "Další" + "Ukončit" + "Nastavení >" + "Nastavení" + "Aplikace SMS a MMS vyžaduje oprávnění k přístupu k SMS, telefonu a kontaktům." + "Oprávnění můžete změnit v Nastavení > Aplikace > SMS a MMS > Oprávnění." + "Oprávnění můžete změnit v Nastavení, Aplikace, SMS a MMS, Oprávnění." + "Časté kontakty" + "Všechny kontakty" + "Odeslat na číslo %s" + "Pořídit fotky nebo video" + "Vyberte obrázky z tohoto zařízení." + "Nahrát zvuk" + "Vybrat fotku" + "Médium je vybráno." + "Výběr média je zrušen." + "Vybráno: %d" + "obrázek %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "obrázek" + "Nahrávání zvuku" + "Sdílet" + "Právě teď" + "Teď" + + %d min + %d min + %d min + %d min + + + %d hodiny + %d hodiny + %d hodin + %d hodina + + + %d dny + %d dne + %d dnů + %d den + + + %d týdny + %d týdne + %d týdnů + jeden týden + + + %d měsíce + %d měsíce + %d měsíců + jeden měsíc + + + %d roky + %d roku + %d let + jeden rok + + "Zpráva třídy 0" + "Uložit" + "V zařízení dochází místo. Aplikace SMS a MMS automaticky smaže starší zprávy, aby uvolnila místo." + "V úložišti je málo místa" + "Aplikace SMS a MMS nebude moci posílat ani přijímat zprávy, dokud v zařízení nebude k dispozici více místa." + "Dochází volné místo na zprávy SMS. Možná bude potřeba nějaké zprávy vymazat." + "Ověřit telefonní číslo" + "Tento jednorázový krok zajistí, že aplikace SMS a MMS správně doručí vaše skupinové zprávy." + "Telefonní číslo" + "Smazat všechny zprávy s médii" + "Smazat zprávy starší než %s" + "Automaticky smazat zprávy starší než %s" + "Ignorovat" + "Vymazat všechny zprávy s médii?" + "Smazat zprávy starší než %s?" + "Chcete smazat zprávy starší než %s a zapnout automatické mazání?" + "%s říká" + "Řekli jste" + "Zpráva od uživatele %s" + "Odeslali jste zprávu" + "Odesílání…" + "Zpráva nebyla odeslána. Klepnutím to zkusíte znovu." + "Zpráva nebyla odeslána. Probíhá další pokus…" + "Znovu odeslat nebo smazat" + "Uskutečněte prosím hlasové volání nouzových služeb. Vaši textovou zprávu v současné době nebylo možné doručit." + "Nezdařilo se" + "Nová zpráva MMS ke stažení" + "Nová zpráva MMS" + "Stažení se nezdařilo." + "Klepnutím to zkusíte znovu" + "Stáhněte klepnutím." + "Stáhnout nebo smazat" + "Stahování…" + "Platnost zprávy vypršela nebo zpráva není k dispozici." + "velikost: %1$s, vypršení platnosti: %2$s" + "Zprávu nelze odeslat. Příjemce není platný." + "Služba není v síti aktivována." + "Zprávu se nepodařilo odeslat z důvodu chyby v síti." + "Platnost zprávy vypršela nebo zpráva není k dispozici." + "(Bez předmětu)" + "Neznámý odesílatel" + "Doručeno" + "Stažení zprávy %1$s od odesílatele %2$s se nezdařilo." + "Databázovou operaci se nepodařilo dokončit z důvodu nedostatku paměti." + "Zprávu se nepodařilo odeslat." + "Několik zpráv v aplikaci SMS a MMS nebylo odesláno." + + Počet zpráv ve %d konverzacích: %d + Počet zpráv v %d konverzace: %d + Počet zpráv v %d konverzacích: %d + Počet zpráv v jedné konverzaci: %d + + "Zprávu se nepodařilo stáhnout" + "Několik zpráv se do aplikace SMS a MMS nepodařilo stáhnout." + + Počet zpráv ve %d konverzacích: %d + Počet zpráv v %d konverzace: %d + Počet zpráv v %d konverzacích: %d + Počet zpráv v jedné konverzaci: %d + + "Zpráva na číslo %1$s nebyla odeslána." + "Uskutečněte prosím hlasové volání nouzových služeb. Vaši textovou zprávu na číslo %1$s v současné době nebylo možné doručit." + + %d nové zprávy + %d nové zprávy + %d nových zpráv + Nová zpráva + + "Spustit" + "Fotoaparát není k dispozici" + "Fotoaparát není k dispozici." + "Záznam videa není k dispozici." + "Média nelze uložit." + "Nelze pořídit fotku" + "Zpět" + "Archivováno" + "Smazat" + "Archivovat" + "Vyjmout z archivu" + "Vypnout oznámení" + "Zapnout oznámení" + "Přidat kontakt" + "Stáhnout" + "Odeslat" + "Smazat" + "Smazat tuto zprávu?" + "Tuto akci nelze vrátit zpět." + "Smazat" + + Smazat tyto konverzace? + Smazat tyto konverzace? + Smazat tyto konverzace? + Smazat tuto konverzaci? + + "Smazat" + "Zrušit" + "Komu" + "Vybrat několik obrázků" + "Potvrďte výběr" + "+%d" + "Zvuk nelze zaznamenat. Zkuste to znovu." + "Zvuk nelze přehrát. Zkuste to znovu." + "Zvuk nelze uložit. Zkuste to znovu." + "Dotyk s podržením" + ", " + " " + ": " + " " + "Fotografie" + "Zvukový klip" + "Video" + "Karta kontaktu" + "Stáhnout" + "Odpovědět – SMS" + "Odpovědět v MMS" + "Odpovědět" + + %d účastníci + %d účastníka + %d účastníků + %d účastník + + "Já" + "Kontakt byl zablokován a archivován." + "Kontakt byl odblokován a jeho archivace byla zrušena" + "Archivováno: %d" + "Vyjmuto z archivu: %d" + "Oznámení jsou vypnuta" + "Oznámení jsou zapnuta" + "Vše je připraveno. Znovu klepněte na Odeslat." + "Aplikace SMS a MMS byla úspěšně nastavena jako výchozí aplikace pro SMS." + + Zahodit přílohy + Zahodit přílohy + Zahodit přílohy + Zahodit přílohu + + "Zvuková příloha" + "Přehrát zvukovou přílohu" + "Pozastavit" + "Zpráva od uživatele %s: %s" + "Neúspěšná zpráva od uživatele %s: %s. Čas: %s." + "Zpráva od uživatele %s: %s. Čas: %s." + "Neodeslaná zpráva pro uživatele %s: %s. Čas: %s." + "Odesílání zprávy uživateli %s: %s. Čas: %s." + "Neúspěšná zpráva pro uživatele %s: %s. Čas: %s." + "Zpráva pro uživatele %s: %s. Čas: %s." + "Neúspěšná zpráva od uživatele %s: %s. Čas: %s. %s." + "Zpráva od uživatele %s: %s. Čas: %s. %s." + "Neodeslaná zpráva pro uživatele %s: %s. Čas: %s." + "Odesílání zprávy uživateli %s: %s. Čas: %s." + "Neúspěšná zpráva pro uživatele %s: %s. Čas: %s." + "Zpráva pro uživatele %s: %s. Čas: %s." + "Neúspěšná zpráva. Klepnutím pokus opakujte." + "Konverzace s uživateli %s" + "Smazat předmět" + "Natočit video" + "Pořídit statický snímek" + "Vyfotit" + "Začít natáčet video" + "Přepnout na fotoaparát na celou obrazovku" + "Přepnout mezi předním a zadním fotoaparátem" + "Zastavit nahrávání a připojit video" + "Zastavit záznam videa" + "Fotky z aplikace SMS a MMS" + + %d fotky byly uloženy do alba %s. + %d fotky bylo uloženo do alba %s. + %d fotek bylo uloženo do alba %s. + %d fotka byla uložena do alba %s. + + + %d videa byla uložena do alba %s. + %d videa bylo uloženo do alba %s. + %d videí bylo uloženo do alba %s. + %d video bylo uloženo do alba %s. + + + %d přílohy byly uloženy do alba %s. + %d přílohy bylo uloženo do alba %s. + %d příloh bylo uloženo do alba %s. + %d příloha byla uložena do alba %s. + + + %d přílohy byly uloženy do složky stažených položek. + %d přílohy bylo uloženo do složky stažených položek. + %d příloh bylo uloženo do složky stažených položek. + %d příloha byla uložena do složky stažených položek. + + + Uloženy %d přílohy + Uloženo %d přílohy + Uloženo %d příloh + Uložena %d příloha + + + %d přílohy se nepodařilo uložit + %d přílohy se nepodařilo uložit + %d příloh se nepodařilo uložit + %d přílohu se nepodařilo uložit + + "Příloha MMS byla uložena" + "Nastavení" + "Archivováno" + "Zavřít" + "MMS" + "Rozšířená nastavení" + "Ladit" + "Oznámení" + "Zvuk" + "Tichý režim" + "Vibrace" + "Zablokováno" + "Zprávy o doručení SMS" + "U každé odeslané SMS požadovat potvrzení o doručení" + "Automatické načítání" + "Automaticky načítat zprávy MMS" + "Autom. načítání při roamingu" + "Při roamingu automaticky načítat zprávy MMS" + "Skupinové zprávy" + "Použít MMS k odeslání jedné zprávy, pokud má několik příjemců" + "Výchozí aplikace SMS" + "Výchozí aplikace SMS" + + + "Vaše telefonní číslo" + "Neznámé" + "Zvuky odchozích zpráv" + "Vypsání zpráv SMS" + "Vypsat nezpracovaná data přijatých zpráv SMS do souboru externího úložiště" + "Vypsání zpráv MMS" + "Vypsat nezpracovaná data přijatých zpráv MMS do souboru externího úložiště" + "Bezdrátová upozornění" + "Možnosti zprávy" + "Kopírovat text" + "Zobrazit podrobnosti" + "Smazat" + "Přeposlat" + "Podrobnosti zprávy" + "Typ: " + "Textová zpráva" + "Multimediální zpráva" + "Od: " + "Komu: " + "Odesláno: " + "Přijato: " + "Předmět: " + "Velikost: " + "Priorita: " + "SIM karta: " + "Vysoká" + "Normální" + "Nízká" + "SIM karta %s" + "Skrytá adresa odesílatele" + "Při načítání příloh nelze odeslat zprávu." + "Přílohu nelze načíst. Zkuste to znovu." + "Síť není připravena. Zkuste to znovu." + "Smazat text" + "Přepnutí mezi zadáváním textu a čísel" + "Přidat další účastníky" + "Potvrdit účastníky" + "Zahájit novou konverzaci" + "Vybrat tuto položku" + "Přehrát video" + "Lidé a možnosti" + "Ladit" + "Lidé a možnosti" + "Obecné" + "Lidé v této konverzaci" + "Uskutečnit hovor" + "Poslat zprávu" + "Odeslat zprávu<br/><small>ze SIM karty %s</small>" + + Odeslat fotky + Odeslat fotky + Odeslat fotky + Odeslat fotku + + + Odeslat zvukové nahrávky + Odeslat zvukové nahrávky + Odeslat zvukové nahrávky + Odeslat zvukovou nahrávku + + + Odeslat videa + Odeslat videa + Odeslat videa + Odeslat video + + + Odeslat vizitky + Odeslat vizitky + Odeslat vizitky + Odeslat vizitku + + + Odeslat přílohy + Odeslat přílohy + Odeslat přílohy + Odeslat přílohu + + + %d přílohy jsou připraveny k odeslání + %d přílohy je připraveno k odeslání + %d příloh je připraveno k odeslání + Jedna příloha je připravena k odeslání + + "Odeslat zpětnou vazbu" + "Zobrazit v Obchodě Google Play" + "Informace o verzi" + "Verze %1$s" + "Licence open source" + "Oznámení" + "Byl dosažen maximální počet příloh" + "Načtení přílohy se nezdařilo." + "Přidat do kontaktů?" + "Přidat kontakt" + "Předmět" + "Předmět: " + "%s%s" + "Načítání kontaktní karty" + "Kartu kontaktu nelze načíst." + "Zobrazit kontaktní kartu" + + %d kontakty + %d kontaktu + %d kontaktů + %d kontakt + + "Karty kontaktů" + "Narozeniny" + "Poznámky" + "Přeposlat zprávu" + "Odpovědět" + "+1" + "+%d" + "Zprávy SMS jsou zakázány" + "Chcete-li odeslat zprávu, nastavte aplikaci SMS a MMS jako výchozí aplikaci pro SMS." + "Nastavte aplikaci SMS a MMS jako výchozí aplikaci pro SMS." + "Změnit" + "Chcete-li dostávat zprávy, nastavte aplikaci SMS a MMS jako výchozí aplikaci pro SMS." + "Není vybrána preferovaná SIM karta pro odesílání zpráv SMS." + "Tuto aplikaci vlastník zařízení nepovolil." + "OK" + "Konverzace obsahuje příliš mnoho účastníků" + + Neplatné kontakty + Neplatné kontakty + Neplatné kontakty + Neplatný kontakt + + "Obraz z fotoaparátu nelze načíst." + "Vy: " + "%s: " + "Koncept" + "Zde budou uvedeny nové konverzace" + "Zde se zobrazují archivované konverzace." + "Načítání konverzací…" + "Fotografie" + "Zvukový klip" + "Video" + "Karta kontaktu" + "MMS" + "Vrátit zpět" + "Opakovat" + "Chcete-li začít psát novou zprávu, zadejte jméno kontaktu nebo telefonní číslo." + "Blokovat" + "Blokovat %s" + "Odblokovat kontakt %s" + "Blokovat %s?" + "Zprávy z tohoto čísla budete i nadále dostávat, ale nebude se pro ně již zobrazovat oznámení. Tato konverzace bude archivována." + "Blokované kontakty" + "ODBLOKOVAT" + "Blokované kontakty" + "Vyberte obrázek z knihovny dokumentů." + "Odesílání zprávy" + "Zpráva byla odeslána" + "Mobilní datové přenosy jsou vypnuty. Zkontrolujte svá nastavení." + "V režimu Letadlo zprávy nelze odeslat." + "Zprávu nelze odeslat" + "Zpráva byla stažena" + "Mobilní datové přenosy jsou vypnuty. Zkontrolujte svá nastavení." + "V režimu Letadlo zprávy nelze stahovat" + "Zprávu nelze stáhnout" + "Nula" + "Jedna" + "Dvě" + "Tři" + "Čtyři" + "Pět" + "Šest" + "Sedm" + "Osm" + "Devět" + "Zprávu prostřednictvím operátora %1$s nelze odeslat. Chyba %2$d." + "Zprávu prostřednictvím neznámého operátora nelze odeslat. Chyba %1$d." + "Fwd: %s" + "Zpráva nebyla odeslána: služba není v síti aktivována" + "Zpráva nebyla odeslána: neplatná cílová adresa" + "Zpráva nebyla odeslána: neplatná zpráva" + "Zpráva nebyla odeslána: nepodporovaný obsah" + "Zpráva nebyla odeslána: nepodporovaná zpráva" + "Zpráva nebyla odeslána, protože je příliš velká" + "Nová zpráva" + "Zobrazit" + "Obrázek" + "Vhodná aplikace nebyla nalezena." + "Odebrat příjemce" + "Nová zpráva" + "Zrušit" + "Upravit přístupový bod" + "Nenastaveno" + "Název" + "Název přístupového bodu (APN)" + "MMSC" + "Proxy server pro MMS" + "Port pro MMS" + "MCC" + "MNC" + "Typ APN" + "Smazat APN" + "Nový název přístupového bodu (APN)" + "Uložit" + "Zahodit" + "Pole Název nesmí být prázdné." + "Název přístupového bodu (APN) nesmí být prázdný." + "Pole MCC musí obsahovat alespoň 3 číslice." + "Pole MNC musí obsahovat 2 nebo 3 číslice." + "Obnovování výchozích nastavení (APN)" + "Obnovit výchozí" + "Obnovení výchozích nastavení APN bylo dokončeno." + "Bez názvu" + "Názvy přístupových bodů" + "Názvy přístupových bodů (APN)" + "Nový název přístupového bodu (APN)" + "Nastavení názvu přístupového budu pro tohoto uživatele není dostupné." + "Kopírovat do schránky?" + "Kopírovat" + "na SIM kartu %s" + "Obecné" + "Rozšířená nastavení" + "Obecná nastavení" + "Rozšířená nastavení" + "SIM karta %s" + "Odesílat všem příjemcům jednotlivé zprávy SMS. Odpovědi dostanete pouze vy." + "Odeslat všem příjemcům jednu zprávu MMS" + "Neznámé číslo" + "Nová zpráva" + "Nová zpráva" + "Výběr SIM karty" + "Výběr SIM karty (vybrána SIM karta %1$s)" + "Upravit předmět" + "Vybrat SIM kartu nebo upravit předmět" + "Zvuk zaznamenáte klepnutím a podržením" + "Zahájit novou konverzaci" + "SMS a MMS" + "Seznam SMS a MMS" + "SMS a MMS" + "Nová zpráva" + "Seznam konverzací" + "Načítání konverzací" + "Načítání zpráv" + "Zobrazit další konverzace" + "Zobrazit další zprávy" + "Konverzace byla smazána" + "Konverzace byla smazána. Klepnutím zobrazíte jinou konverzaci v aplikaci SMS a MMS." + "Zablokováno" + "Odblokováno" + "V úložišti je málo místa. Některá data mohou být ztracena." + "Vybrat přílohy" + "Potvrďte výběr" + "Vybráno: %d" + "Odstraňte prosím alespoň jednu přílohu a zkuste to znovu." + "Zprávu můžete zkusit odeslat, avšak může se stát, že nebude doručena, dokud neodstraníte alespoň jednu přílohu." + "Ve zprávě lze odeslat pouze jedno video. Odstraňte ostatní videa a zkuste to znovu." + "Načtení přílohy v aplikaci SMS a MMS se nezdařilo." + "Přesto odeslat" + "Konverzaci nelze zahájit" + "%1$s (%2$s)" + "Vybráno: %s" + diff --git a/res/values-da/arrays.xml b/res/values-da/arrays.xml new file mode 100644 index 0000000..d40336a --- /dev/null +++ b/res/values-da/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "intet emne" + "nosubject" + + + "Ja" + "Nej" + "OK" + "Haha" + "Tak" + "Jeg er enig" + "Sådan!" + "Er på vej" + "Jeg vender tilbage til dig lidt senere" + ":)" + ":(" + + diff --git a/res/values-da/strings.xml b/res/values-da/strings.xml new file mode 100644 index 0000000..340c28f --- /dev/null +++ b/res/values-da/strings.xml @@ -0,0 +1,519 @@ + + + + + "Beskeder" + "Beskeder" + "Vælg en samtale" + "Indstillinger" + "Send besked" + "Tilføj en vedhæftet fil" + "Hjælp" + "Velkommen" + "Spring over" + "Næste >" + "Næste" + "Afslut" + "Indstillinger >" + "Indstillinger" + "Beskeder skal have tilladelse for at kunne anvende Sms, Telefon og Kontaktpersoner." + "Du kan ændre tilladelser i Indstillinger > Apps > Beskeder > Tilladelser." + "Du kan ændre tilladelser under Indstillinger, Apps, Beskeder, Tilladelser." + "Mest brugte" + "Alle kontaktpersoner" + "Send til %s" + "Optag billeder eller video" + "Vælg billeder fra denne enhed" + "Optag lyd" + "Vælg billede" + "Medierne er valgt." + "Medierne er fravalgt." + "Der er valgt %d" + "billede %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "billede" + "Optag lyd" + "Del" + "Lige nu" + "Nu" + + %d min. + %d min. + + + %d timer + %d timer + + + %d dage + %d dage + + + %d uger + %d uger + + + %d måneder + %d måneder + + + %d år + %d år + + "Klasse 0-besked" + "Gem" + "Enheden er ved at løbe tør for plads. Ældre beskeder bliver automatisk slettet for at frigøre plads." + "Der er snart ikke mere lagerplads" + "Beskeder sender eller modtager muligvis ikke beskeder, før der er mere ledig plads på din enhed." + "Begrænset lagerplads til sms. Det kan være nødvendigt at slette beskeder." + "Bekræft dit telefonnummer" + "Dette engangstrin sikrer, at Beskeder leverer dine gruppebeskeder på korrekt vis." + "Telefonnummer" + "Slet alle beskeder med medier" + "Slet beskeder, der er mere end %s gamle" + "Slet automatisk beskeder, der er mere end %s gamle" + "Ignorer" + "Slet alle beskeder, der indeholder medier?" + "Vil du slette beskeder, der er mere end %s gamle?" + "Vil du slette beskeder, der er ældre end %s, og aktiverr automatisk sletning?" + "%s sagde" + "Du sagde" + "Besked fra %s" + "Du sendte en besked" + "Sender…" + "Ikke sendt. Tryk for at prøve igen." + "Ikke sendt. Prøver igen…" + "Send igen eller slet" + "Ring op til alarmcentralen. Din sms-besked kan ikke leveres på nuværende tidspunkt." + "Mislykkedes" + "Ny MMS-besked til download" + "Ny MMS-besked" + "Download mislykkedes" + "Tryk for at prøve igen" + "Tryk for at downloade" + "Download eller slet" + "Downloader…" + "Beskeden er udløbet eller ikke tilgængelig" + "størrelse: %1$s, udløb: %2$s" + "Beskeden kan ikke sendes. Ugyldig modtager." + "Tjenesten er ikke aktiveret på netværket" + "Et netværksproblem er skyld i, at afsendelsen mislykkedes" + "Beskeden er udløbet eller ikke tilgængelig" + "(Intet emne)" + "Ukendt afsender" + "Leveret" + "Beskeden %1$s blev ikke downloadet fra %2$s." + "Databasehandlingen blev ikke udført, fordi hukommelsen er for lille" + "Beskeden blev ikke sendt" + "Nogle beskeder blev ikke sendt i Beskeder" + + %d beskeder i %d samtaler + %d beskeder i %d samtaler + + "Beskeden blev ikke downloadet" + "Nogle beskeder blev ikke downloadet i Beskeder" + + %d beskeder i %d samtaler + %d beskeder i %d samtaler + + "Beskeden til %1$s blev ikke sendt" + "Ring op til alarmcentralen. Din sms-besked til %1$s kan ikke leveres på nuværende tidspunkt." + + %d nye beskeder + %d nye beskeder + + "Start" + "Kameraet er ikke tilgængeligt" + "Kameraet er ikke tilgængeligt" + "Videooptagelse er ikke tilgængelig" + "Medie kan ikke gemmes" + "Der kan ikke tages billeder" + "Tilbage" + "Arkiveret" + "Slet" + "Arkivér" + "Fjern fra arkiv" + "Deaktiver underretninger" + "Aktivér underretninger" + "Tilføj kontaktperson" + "Download" + "Send" + "Slet" + "Vil du slette denne besked?" + "Denne handling kan ikke fortrydes." + "Slet" + + Vil du slette disse samtaler? + Vil du slette disse samtaler? + + "Slet" + "Annuller" + "Til" + "Vælg flere billeder" + "Bekræft valg" + "+ %d" + "Der kan ikke optages lyd. Prøv igen." + "Lyden kan ikke afspilles. Prøv igen." + "Lyden kunne ikke gemmes. Prøv igen." + "Tryk og hold nede" + ", " + " " + ": " + " " + "Billede" + "Lydklip" + "Video" + "Visitkort" + "Download" + "Svar via sms" + "Svar via mms" + "Besvar" + + %d deltagere + %d deltagere + + "Mig" + "Kontakten er blokeret og arkiveret" + "Kontaktpersonen er ikke længere blokeret og er fjernet fra arkivet" + "%d blev arkiveret" + "%d er blevet fjernet fra arkivet." + "Underretninger er slået fra" + "Underretninger er slået til" + "Alt er klart. Tryk på Send igen." + "Beskeder blev angivet som standardappen til sms-beskeder." + + Kassér vedhæftede filer + Kassér vedhæftede filer + + "Vedhæftet lydfil" + "Afspil vedhæftet lydfil" + "Sæt på pause" + "Besked fra %s: %s." + "Mislykket besked fra %s: %s. Tidspunkt: %s." + "Besked fra %s: %s. Tidspunkt: %s." + "Ikke-afsendt besked til %s: %s. Tidspunkt: %s." + "Sender besked til %s: %s. Tidspunkt: %s." + "Mislykket besked til %s: %s. Tidspunkt: %s." + "Besked til %s: %s. Tidspunkt: %s." + "Mislykket besked fra %s: %s. Tidspunkt: %s. %s." + "Besked fra %s: %s. Tidspunkt: %s. %s." + "Ikke-afsendt besked til %s: %s. Tidspunkt: %s." + "Sender besked til %s: %s. Tidspunkt: %s." + "Mislykket besked til %s: %s. Tidspunkt: %s." + "Besked til %s: %s. Tidspunkt: %s." + "Mislykket besked. Tryk for at prøve igen." + "Samtale med %s" + "Slet emne" + "Optag video" + "Tag et stillbillede" + "Tag et billede" + "Start videooptagelse" + "Skift til kamera i fuld skærm" + "Skift mellem front- og bagudvendt kamera" + "Stop optagelsen, og vedhæft video" + "Stands optagelse af video" + "Billeder i Beskeder" + + %d billeder blev gemt i albummet \"%s\" + %d billeder blev gemt i albummet \"%s\" + + + %d videoer blev gemt i albummet \"%s\" + %d videoer blev gemt i albummet \"%s\" + + + %d vedhæftede filer blev gemt i albummet \"%s\" + %d vedhæftede filer blev gemt i albummet \"%s\" + + + %d vedhæftede filer blev gemt under \"Downloads\" + %d vedhæftede filer blev gemt under \"Downloads\" + + + %d vedhæftede filer blev gemt + %d vedhæftede filer blev gemt + + + %d vedhæftede filer kunne ikke gemmes + %d vedhæftede filer kunne ikke gemmes + + "Den vedhæftede MMS blev gemt" + "Indstillinger" + "Arkiveret" + "Luk" + "Mms" + "Avanceret" + "Fejlretning" + "Underretninger" + "Lyd" + "Lydløs" + "Vibrer" + "Blokeret" + "SMS-leveringsrapporter" + "Anmod om leveringsrapporter for alle de sms-beskeder, du sender" + "Automatisk hentning" + "Hent automatisk mms-beskeder" + "Automatisk hentning under roaming" + "Hent automatisk mms under roaming" + "Gruppebeskeder" + "Brug mms til at sende en enkelt besked, når der er flere modtagere" + "Standardapp til sms-beskeder" + "Standardapp til sms-beskeder" + + + "Dit telefonnummer" + "Ukendt" + "Lyde for udgående besked" + "Gem sms" + "Gem rådata fra modtagne sms-beskeder i en fil på et eksternt lager" + "Gem mms" + "Gem rådata fra modtagne mms-beskeder i en fil på et eksternt lager" + "Trådløse alarmer" + "Indstillinger for beskeder" + "Kopiér tekst" + "Se info" + "Slet" + "Videresend" + "Beskedinfo" + "Type: " + "Sms" + "Mms" + "Fra: " + "Til: " + "Sendt: " + "Modtaget: " + "Emne: " + "Størrelse: " + "Prioritet: " + "SIM-kort: " + "Høj" + "Normal" + "Lav" + "SIM-kort %s" + "Skjult afsenderadresse" + "Beskeden kan ikke sendes, mens der indlæses vedhæftede filer." + "Den vedhæftede fil kunne ikke indlæses. Prøv igen." + "Netværket er ikke klar. Prøv igen." + "Slet tekst" + "Skift mellem indtastning af tekst og tal" + "Tilføj flere deltagere" + "Bekræft deltagere" + "Start en ny samtale" + "Vælg dette element" + "Afspil video" + "Personer og valgmuligheder" + "Fejlretning" + "Personer og valgmuligheder" + "Generelt" + "Personer i denne samtale" + "Foretag et opkald" + "Send besked" + "Send besked<br/><small>fra %s</small>" + + Send billeder + Send billeder + + + Send lydoptagelser + Send lydoptagelser + + + Send videoer + Send videoer + + + Send visitkort + Send visitkort + + + Send vedhæftede filer + Send vedhæftede filer + + + %d vedhæftede filer er klar til at blive sendt + %d vedhæftede filer er klar til at blive sendt + + "Send feedback" + "Se i Google Play Butik" + "Oplysninger om versionen" + "Version %1$s" + "Open source-licenser" + "Underretninger" + "Grænsen for vedhæftede filer er nået" + "Den vedhæftede fil kunne ikke indlæses." + "Vil du føje dette nummer til Kontaktpersoner?" + "Tilføj kontakt" + "Emne" + "Emne: " + "%s%s" + "Kontaktkortet indlæses" + "Kontaktkortet kunne ikke indlæses" + "Se visitkortet" + + %d kontaktpersoner + %d kontaktpersoner + + "Kontaktkort" + "Fødselsdag" + "Noter" + "Videresend besked" + "Svar" + "+1" + "+%d" + "Sms er slået fra" + "Angiv Beskeder som standardappen til sms-beskeder for at sende beskeder" + "Angiv Beskeder som standardappen til sms-beskeder" + "Skift" + "Angiv Beskeder som standardappen til sms-beskeder for at modtage beskeder" + "Der er ikke valgt et foretrukket simkort til afsendelse af sms-beskeder" + "Denne app er ikke tilladt af enhedens ejer." + "OK" + "Der er for mange deltagere i en samtale" + + Ugyldige kontaktpersoner + Ugyldige kontaktpersoner + + "Kamerabilledet kunne ikke indlæses" + "Dig: " + "%s: " + "Kladde" + "Når du starter en ny samtale, vises den her" + "Arkiverede samtaler vises her" + "Indlæser samtaler ..." + "Billede" + "Lydklip" + "Video" + "Visitkort" + "Mms" + "Fortryd" + "Prøv igen" + "Indtast navnet på en kontaktperson eller et telefonnummer for at starte på en ny besked" + "Bloker" + "Bloker %s" + "Ophæv blokering af %s" + "Vil du blokere %s?" + "Du vil fortsat modtage beskeder fra dette nummer, men du bliver ikke længere underrettet. Denne samtale arkiveres." + "Blokerede kontakter" + "OPHÆV BLOKERING" + "Blokerede kontakter" + "Vælg billede fra dokumentbiblioteket" + "Sender besked" + "Beskeden er sendt" + "Mobildata er slået fra. Kontrollér dine indstillinger." + "Der kan ikke sendes beskeder i flytilstand" + "Beskeden blev ikke sendt" + "Beskeden blev downloadet" + "Mobildata er slået fra. Kontrollér dine indstillinger." + "Beskeder kan ikke downloades i flytilstand" + "Beskeden kunne ikke downloades" + "Nul" + "Et" + "To" + "Tre" + "Fire" + "Fem" + "Seks" + "Syv" + "Otte" + "Ni" + "Der kan ikke sendes en besked med %1$s, fejl %2$d" + "Der kan ikke sendes en besked med ukendt mobilselskab, fejl %1$d" + "Vs: %s" + "Beskeden blev ikke sendt: Tjenesten er ikke aktiveret på netværket" + "Beskeden blev ikke sendt: Destinationsadressen er ugyldig" + "Beskeden blev ikke sendt: Beskeden er ugyldig" + "Beskeden blev ikke sendt: Indholdet understøttes ikke" + "Beskeden blev ikke sendt: Beskeden understøttes ikke" + "Beskeden blev ikke sendt, da den er for stor" + "Ny besked" + "Vis" + "Billede" + "Vi kunne ikke finde en passende applikation" + "Fjern modtager" + "Ny besked" + "Annuller" + "Rediger adgangspunkt" + "Ikke angivet" + "Navn" + "Adgangspunkt" + "MMSC" + "MMS-proxy" + "MMS-port" + "MCC" + "MNC" + "Adgangspunktstype" + "Slet adgangspunkt" + "Nyt adgangspunkt" + "Gem" + "Kassér" + "Feltet Navn skal udfyldes." + "Du skal angive et adgangspunkt." + "Feltet MCC skal indeholde tre cifre." + "Feltet MNC skal indeholde to eller tre cifre." + "Gendanner standardindstillingerne for adgangspunktet." + "Nulstil til standard" + "Standardindstillingerne for adgangspunktet er nulstillet." + "Ikke-navngivet" + "Adgangspunkter" + "Adgangspunkter" + "Nyt adgangspunkt" + "Indstillingerne for adgangspunkt (APN) er ikke tilgængelige for denne bruger" + "Vil du kopiere til udklipsholder?" + "Kopiér" + "til %s" + "Generelt" + "Avanceret" + "Generelle indstillinger" + "Avancerede indstillinger" + "Indstillinger for SIM-kortet \"%s\"" + "Send individuelle sms-beskeder til alle modtagere. Der kan kun sendes svar til dig" + "Send en enkelt mms-besked til alle modtagere" + "Ukendt nummer" + "Ny besked" + "Ny besked." + "SIM-vælger" + "%1$s er valgt. SIM-vælger" + "Rediger emne" + "Vælg SIM-kort, eller rediger emne" + "Tryk og hold nede for at optage lyd" + "Start en ny samtale" + "Beskeder" + "Liste i Beskeder" + "Beskeder" + "Ny besked" + "Samtaleliste" + "Indlæser samtaler" + "Indlæser beskeder" + "Se flere samtaler" + "Se flere beskeder" + "Samtalen er slettet" + "Samtalen blev slettet. Tryk for at se en anden samtale i Beskeder." + "Blokeret" + "Blokering fjernet" + "Der er for lidt lagerplads. Nogle data gemmes muligvis ikke." + "Vælg vedhæftede filer" + "Bekræft valg" + "Der er valgt %d" + "Fjern en eller flere vedhæftede filer, og prøv igen." + "Du kan prøve at sende din besked, men den leveres muligvis ikke, medmindre du fjerner en eller flere vedhæftede filer." + "Du kan kun sende én video pr besked. Fjern flere videoer, og prøv igen." + "Beskeder kunne ikke indlæse den vedhæftede fil." + "Send alligevel" + "Samtalen kunne ikke startes" + "%1$s (%2$s)" + "%s er valgt" + diff --git a/res/values-de/arrays.xml b/res/values-de/arrays.xml new file mode 100644 index 0000000..c3e971f --- /dev/null +++ b/res/values-de/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "kein Betreff" + "Kein Betreff" + + + "Ja" + "Nein" + "OK" + "Hehe" + "Vielen Dank!" + "Ich stimme zu." + "Wunderbar" + "Bin auf dem Weg." + "OK, ich melde mich später." + ":)" + ":(" + + diff --git a/res/values-de/strings.xml b/res/values-de/strings.xml new file mode 100644 index 0000000..88b95c3 --- /dev/null +++ b/res/values-de/strings.xml @@ -0,0 +1,519 @@ + + + + + "SMS/MMS" + "SMS/MMS" + "Konversation auswählen" + "Einstellungen" + "Nachricht senden" + "Anhang hinzufügen" + "Hilfe" + "Willkommen" + "Überspringen" + "Weiter >" + "Weiter" + "Beenden" + "Einstellungen >" + "Einstellungen" + "Die SMS/MMS App benötigt Berechtigungen für SMS, Telefon und Kontakte." + "Sie können die Berechtigungen unter \"Einstellungen\" > \"Apps\" > \"SMS/MMS\" > \"Berechtigungen\" ändern." + "Sie können die Berechtigungen unter \"Einstellungen\" > \"Apps\" > \"SMS/MMS\" > \"Berechtigungen\" ändern." + "Häufig genutzte Kontakte" + "Alle Kontakte" + "An %s senden" + "Bilder oder Videos aufnehmen" + "Bilder von diesem Gerät auswählen" + "Audio aufzeichnen" + "Foto auswählen" + "Die Medien sind ausgewählt." + "Die Auswahl der Medien ist aufgehoben." + "%d ausgewählt" + "Bild: %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "Bild" + "Audio aufzeichnen" + "Teilen" + "Gerade eben" + "Jetzt" + + %d min + %d min + + + %d Stunden + %d Stunde + + + %d Tage + %d Tag + + + %d Wochen + eine Woche + + + %d Monate + ein Monat + + + %d Jahre + ein Jahr + + "Nachricht Klasse 0" + "Speichern" + "Das Gerät verfügt über wenig Speicherplatz. Die SMS/MMS App löscht ältere Nachrichten automatisch, um Speicherplatz freizugeben." + "Der Speicherplatz wird knapp" + "Die SMS/MMS App versendet oder empfängt möglicherweise keine Nachrichten, bis mehr Speicherplatz auf Ihrem Gerät zur Verfügung steht." + "Geringer SMS-Speicherplatz. Sie müssen eventuell Nachrichten löschen." + "Telefonnummer bestätigen" + "Mit dieser einmaligen Eingabe stellen Sie sicher, dass die SMS/MMS App Ihre Gruppennachrichten korrekt sendet." + "Telefonnummer" + "Alle Nachrichten mit Medien löschen" + "Nachrichten löschen, die älter als %s sind" + "Nachrichten, die älter als %s sind, automatisch löschen" + "Ignorieren" + "Alle Nachrichten mit Medien löschen?" + "Nachrichten löschen, die älter als %s sind?" + "Nachrichten löschen, die älter als %s sind, und automatisches Löschen aktivieren?" + "%s hat Folgendes gesagt:" + "Sie haben Folgendes gesagt:" + "Nachricht von %s" + "Sie haben eine Nachricht gesendet." + "Nachricht wird gesendet…" + "Nachricht wurde nicht gesendet. Zum erneuten Senden tippen." + "Nachricht wurde nicht gesendet. Neuer Versuch…" + "Erneut senden oder löschen" + "Bitte kontaktieren Sie die Notfalldienste per Sprachanruf. Ihre SMS konnte dieses Mal nicht zugestellt werden." + "Fehler" + "Neue MMS zum Herunterladen" + "Neue MMS" + "Fehler beim Download" + "Für neuen Versuch hier tippen" + "Zum Herunterladen tippen" + "Herunterladen oder löschen" + "Nachricht wird heruntergeladen…" + "Nachricht abgelaufen oder nicht verfügbar" + "Größe: %1$s, läuft ab: %2$s" + "Nachricht kann nicht gesendet werden. Der Empfänger ist ungültig." + "Dienst im Netzwerk nicht aktiviert" + "Nachricht kann aufgrund eines Netzwerkproblems nicht gesendet werden." + "Nachricht abgelaufen oder nicht verfügbar" + "(Kein Betreff)" + "Unbekannter Absender" + "Zugestellt" + "Nachricht \"%1$s\" von %2$s konnte nicht heruntergeladen werden." + "Datenbankoperation konnte aufgrund unzureichenden Speichers nicht abgeschlossen werden." + "Nachricht wurde nicht gesendet." + "Einige Nachrichten in der SMS/MMS App wurden nicht gesendet." + + %d Nachrichten in %d Unterhaltungen + %d Nachricht in einer Unterhaltung + + "Nachricht nicht heruntergeladen" + "Einige Nachrichten in der SMS/MMS App wurden nicht heruntergeladen." + + %d Nachrichten in %d Unterhaltungen + %d Nachricht in einer Unterhaltung + + "SMS an %1$s nicht gesendet" + "Bitte kontaktieren Sie die Notfalldienste per Sprachanruf. Ihre SMS an %1$s konnte dieses Mal nicht zugestellt werden." + + %d neue Nachrichten + Neue Nachricht + + "Starten" + "Kamera nicht verfügbar" + "Kamera nicht verfügbar" + "Videoaufnahme nicht verfügbar" + "Medien können nicht gespeichert werden." + "Keine Bildaufnahme möglich" + "Zurück" + "Archiviert" + "Löschen" + "Archivieren" + "Aus Archiv entfernen" + "Benachrichtigungen deaktivieren" + "Benachrichtigungen aktivieren" + "Kontakt hinzufügen" + "Herunterladen" + "Senden" + "Löschen" + "Diese Nachricht löschen?" + "Diese Aktion kann nicht rückgängig gemacht werden." + "Löschen" + + Diese Unterhaltungen löschen? + Diese Unterhaltung löschen? + + "Löschen" + "Abbrechen" + "An" + "Mehrere Bilder auswählen" + "Auswahl bestätigen" + "+%d" + "Die Audioaufzeichnung ist nicht möglich. Bitte versuchen Sie es erneut." + "Die Audiowiedergabe ist nicht möglich. Bitte versuchen Sie es erneut." + "Die Audionachricht konnte nicht gespeichert werden. Bitte versuchen Sie es erneut." + "Berühren und halten" + ", " + " " + ": " + " " + "Bild" + "Audioclip" + "Video" + "Kontaktkarte" + "Herunterladen" + "Per SMS antworten" + "Antwort per MMS" + "Antworten" + + %d Teilnehmer + %d Teilnehmer + + "Ich" + "Kontakt blockiert und archiviert" + "Blockierung des Kontakts aufgehoben & Kontakt aus dem Archiv entfernt" + "%d archiviert" + "%d aus dem Archiv entfernt" + "Benachrichtigungen deaktiviert" + "Benachrichtigungen aktiviert" + "Bereit. Zum erneuten Senden tippen." + "Die SMS/MMS App wurde als Standard-SMS-App festgelegt." + + Anhänge verwerfen + Anhang verwerfen + + "Audioanhang" + "Audioanhang wiedergeben" + "Pausieren" + "Nachricht von %s: %s." + "Fehler bei Nachricht von %s: %s. Uhrzeit: %s." + "Nachricht von %s: %s. Uhrzeit: %s." + "Nicht gesendete Nachricht an %s: %s. Uhrzeit: %s." + "Nachricht wird gesendet an %s: %s. Uhrzeit: %s." + "Fehler bei Nachricht an %s: %s. Uhrzeit: %s." + "Nachricht an %s: %s. Uhrzeit: %s." + "Fehler bei Nachricht von %s: %s. Uhrzeit: %s. %s." + "Nachricht von %s: %s. Uhrzeit: %s. %s." + "Nicht gesendete Nachricht an %s: %s. Uhrzeit: %s." + "Nachricht wird gesendet an %s: %s. Uhrzeit: %s." + "Fehler bei Nachricht an %s: %s. Uhrzeit: %s." + "Nachricht an %s: %s. Uhrzeit: %s." + "Fehler bei Nachricht. Zum Wiederholen tippen." + "Unterhaltung mit %s" + "Betreff löschen" + "Video aufnehmen" + "Standbild aufnehmen" + "Bild aufnehmen" + "Videoaufnahme starten" + "Zur Vollbildkamera wechseln" + "Zwischen Kamera auf Vorder- und Rückseite wechseln" + "Aufnahme beenden und Video anhängen" + "Videoaufzeichnung stoppen" + "Fotos in der SMS/MMS App" + + %d Fotos im Album \"%s\" gespeichert + %d Foto im Album \"%s\" gespeichert + + + %d Videos im Album \"%s\" gespeichert + %d Video im Album \"%s\" gespeichert + + + %d Anhänge im Album \"%s\" gespeichert + %d Anhang im Album \"%s\" gespeichert + + + %d Anhänge in \"Downloads\" gespeichert + %d Anhang in \"Downloads\" gespeichert + + + %d Anhänge wurden gespeichert. + %d Anhang wurde gespeichert. + + + %d Anhänge konnten nicht gespeichert werden. + %d Anhang konnte nicht gespeichert werden. + + "MMS-Anhang gespeichert" + "Einstellungen" + "Archiviert" + "Schließen" + "MMS" + "Erweitert" + "Fehlersuche" + "Benachrichtigungen" + "Ton" + "Lautlos" + "Vibrieren" + "Blockiert" + "SMS-Zustellberichte" + "Zustellbestätigung für jede gesendete SMS anfordern" + "Automatisch abrufen" + "MMS-Nachrichten automatisch abrufen" + "Automatischer Roaming-Abruf" + "MMS beim Roaming automatisch abrufen" + "SMS/MMS an Gruppe senden" + "Verwenden Sie MMS, um eine einzelne Nachricht an mehrere Empfänger zu versenden." + "Standard-SMS-App" + "Standard-SMS-App" + + + "Meine Telefonnummer" + "Unbekannt" + "Töne für ausgehende Nachrichten" + "SMS sichern" + "Erhaltene SMS-Rohdaten in externer Speicherdatei sichern" + "MMS sichern" + "Erhaltene MMS-Rohdaten in externer Speicherdatei sichern" + "Cell Broadcast-Warnungen" + "Nachrichtenoptionen" + "Text kopieren" + "Details ansehen" + "Löschen" + "Weiterleiten" + "Nachrichtendetails" + "Typ: " + "SMS" + "MMS" + "Von: " + "An: " + "Gesendet: " + "Empfangen: " + "Betreff: " + "Größe: " + "Priorität: " + "SIM: " + "Hoch" + "Normal" + "Niedrig" + "SIM %s" + "Ausgeblendete Senderadresse" + "Die Nachricht kann nicht gesendet werden, weil die Anhänge noch nicht geladen wurden." + "Anhang kann nicht geladen werden. Bitte versuchen Sie es erneut." + "Netzwerk ist nicht bereit. Bitte versuchen Sie es erneut." + "Text löschen" + "Zwischen Text- und Zifferneingabe wechseln" + "Weitere Teilnehmer hinzufügen" + "Teilnehmer bestätigen" + "Neue Unterhaltung starten" + "Dieses Element auswählen" + "Video abspielen" + "Personen & Optionen" + "Debuggen" + "Personen & Optionen" + "Allgemein" + "Personen in dieser Konversation" + "Anrufen" + "Nachricht senden" + "Nachricht senden<br/><small>von %s</small>" + + Fotos senden + Foto senden + + + Audioaufzeichnungen senden + Audioaufzeichnung senden + + + Videos senden + Video senden + + + Kontaktkarten senden + Kontaktkarte senden + + + Anhänge senden + Anhang senden + + + %d Anhänge zum Senden bereit + Ein Anhang zum Senden bereit + + "Feedback geben" + "Im Google Play Store ansehen" + "Versionsinformationen" + "Version: %1$s" + "Open-Source-Lizenzen" + "Benachrichtigungen" + "Anhanglimit erreicht" + "Anhang konnte nicht geladen werden" + "Zu Kontakten hinzufügen?" + "Kontakt hinzufügen" + "Betreff" + "Betreff: " + "%s%s" + "Kontaktkarte wird geladen." + "Kontaktkarte kann nicht geladen werden." + "Kontaktkarte ansehen" + + %d Kontakte + %d Kontakt + + "Kontaktkarten" + "Geburtstag" + "Notizen" + "Nachricht weiterleiten" + "Antworten" + "+1" + "+%d" + "SMS deaktiviert" + "Um Nachrichten zu senden, legen Sie die SMS/MMS App als Standard-SMS-App fest." + "Legen Sie die SMS/MMS App als Standard-SMS-App fest." + "Ändern" + "Um Nachrichten zu erhalten, legen Sie die SMS/MMS App als Standard-SMS-App fest." + "Keine bevorzugte SIM für das Senden von SMS ausgewählt" + "Diese App wird vom Geräteeigentümer nicht zugelassen." + "OK" + "Zu viele Teilnehmer in der Unterhaltung" + + Ungültige Kontakte + Ungültiger Kontakt + + "Kamerabild kann nicht geladen werden." + "Sie: " + "%s: " + "Entwurf" + "Wenn Sie eine neue Unterhaltung beginnen, wird diese hier angezeigt" + "Archivierte Unterhaltungen erscheinen hier." + "Unterhaltungen werden geladen…" + "Bild" + "Audioclip" + "Video" + "Kontaktkarte" + "MMS" + "Rückgängig" + "Erneut versuchen" + "Geben Sie einen Kontaktnamen oder eine Telefonnummer ein, um eine neue Nachricht zu beginnen." + "Blockieren" + "%s blockieren" + "Blockierung aufheben für %s" + "%s blockieren?" + "Sie erhalten weiterhin Nachrichten von dieser Nummer, werden jedoch nicht mehr benachrichtigt. Diese Unterhaltung wird archiviert." + "Blockierte Kontakte" + "Blockierung aufheben" + "Blockierte Kontakte" + "Bild aus der Dokumentenbibliothek auswählen" + "Nachricht wird gesendet" + "Nachricht gesendet" + "Mobilfunkdaten sind deaktiviert. Bitte überprüfen Sie Ihre Einstellungen." + "Im Flugmodus können keine Nachrichten gesendet werden." + "Die Nachricht konnte nicht gesendet werden." + "Nachricht heruntergeladen" + "Mobilfunkdaten sind deaktiviert. Bitte überprüfen Sie Ihre Einstellungen." + "Im Flugmodus können keine Nachrichten heruntergeladen werden." + "Die Nachricht konnte nicht heruntergeladen werden." + "Null" + "Eins" + "Zwei" + "Drei" + "Vier" + "Fünf" + "Sechs" + "Sieben" + "Acht" + "Neun" + "Nachricht kann nicht mit %1$s gesendet werden. Fehler %2$d." + "Nachricht kann nicht mit einem unbekannten Mobilfunkanbieter gesendet werden. Fehler %1$d." + "Fwd: %s" + "Nachricht wurde nicht gesendet: Der Dienst ist im Netzwerk nicht aktiviert." + "Nachricht nicht gesendet: ungültige Zieladresse" + "Nachricht nicht gesendet: ungültige Nachricht" + "Nachricht nicht gesendet: nicht unterstützter Inhalt" + "Nachricht nicht gesendet: nicht unterstützte Nachricht" + "Nachricht nicht gesendet: zu groß" + "Neue Nachricht" + "Ansehen" + "Bild" + "Keine geeignete App gefunden" + "Empfänger entfernen" + "Neue Nachricht" + "Abbrechen" + "Zugangspunkt bearbeiten" + "Nicht definiert" + "Name" + "APN" + "MMSC" + "MMS-Proxy" + "MMS-Port" + "MCC" + "MNC" + "APN-Typ" + "APN löschen" + "Neuer APN" + "Speichern" + "Verwerfen" + "Das Namensfeld darf nicht leer sein." + "APN darf nicht leer sein." + "Das MCC-Feld muss 3 Zeichen enthalten." + "Das MNC-Feld muss 2 oder 3 Zeichen enthalten." + "Standard-APN-Einstellungen werden wiederhergestellt" + "Auf Standard zurücksetzen" + "Zurücksetzen auf Standard-APN-Einstellungen abgeschlossen" + "Unbenannt" + "Zugriffspunktnamen" + "APNs" + "Neuer APN" + "Die Einstellungen für den Zugangspunkt sind für diesen Nutzer nicht verfügbar." + "In Zwischenablage kopieren?" + "Kopieren" + "an %s" + "Allgemein" + "Erweitert" + "Allgemeine Einstellungen" + "Erweiterte Einstellungen" + "SIM \"%s\"" + "Einzelne SMS-Nachrichten an alle Empfänger senden. Die Antworten erhalten nur Sie." + "Einzelne MMS an alle Empfänger senden" + "Unbekannte Nummer" + "Neue Nachricht" + "Neue Nachricht" + "SIM-Auswahl" + "%1$s ausgewählt, SIM-Auswahl" + "Betreff bearbeiten" + "SIM auswählen oder Betreff bearbeiten" + "Für Audioaufnahme berühren und halten" + "Neue Unterhaltung starten" + "SMS/MMS" + "Liste der Unterhaltungen" + "Nachricht" + "Neue Nachricht" + "Konversationsliste" + "Unterhaltungen werden geladen" + "Nachrichten werden geladen" + "Weitere Unterhaltungen anzeigen" + "Weitere Nachrichten ansehen" + "Konversation gelöscht" + "Unterhaltung gelöscht. Tippen Sie hier, um eine andere Unterhaltung zu sehen." + "Blockiert" + "Blockierung aufgehoben" + "Wenig freier Speicherplatz. Einige Daten gehen möglicherweise verloren." + "Anhänge auswählen" + "Auswahl bestätigen" + "%d ausgewählt" + "Bitte entfernen Sie mindestens einen Anhang und versuchen Sie es erneut." + "Sie können versuchen, die Nachricht zu senden, sie wird jedoch möglicherweise nicht zugestellt, wenn Sie nicht mindestens einen Anhang entfernen." + "Pro Nachricht kann nur ein Video gesendet werden. Bitte entfernen Sie überzählige Videos und versuchen Sie es erneut." + "Die SMS/MMS App konnte den Anhang nicht laden." + "Trotzdem senden" + "Die Unterhaltung konnte nicht gestartet werden." + "%1$s (%2$s)" + "%s ausgewählt" + diff --git a/res/values-el/arrays.xml b/res/values-el/arrays.xml new file mode 100644 index 0000000..280d8e9 --- /dev/null +++ b/res/values-el/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "χωρίς θέμα" + "χωρίς θέμα" + + + "Ναι" + "Όχι" + "ΟΚ" + "Χαχα" + "Σας ευχαριστούμε" + "Συμφωνώ" + "Ωραία" + "Έρχομαι" + "Εντάξει, θα επικοινωνήσω μαζί σου αργότερα" + ":)" + ":(" + + diff --git a/res/values-el/strings.xml b/res/values-el/strings.xml new file mode 100644 index 0000000..feade7b --- /dev/null +++ b/res/values-el/strings.xml @@ -0,0 +1,519 @@ + + + + + "Ανταλλαγή μηνυμάτων" + "Ανταλλαγή μηνυμάτων" + "Επιλογή συνομιλίας" + "Ρυθμίσεις" + "Αποστολή μηνύματος" + "Προσθήκη συνημμένου αρχείου" + "Βοήθεια" + "Καλώς ήρθατε" + "Παράλειψη" + "Επόμενο >" + "Επόμενο" + "Έξοδος" + "Ρυθμίσεις >" + "Ρυθμίσεις" + "Η Ανταλλαγή μηνυμάτων χρειάζεται άδεια για SMS, Τηλέφωνο και Επαφές." + "Μπορείτε να αλλάξετε τα δικαιώματα στις Ρυθμίσεις > Εφαρμογές > Ανταλλαγή μηνυμάτων > Δικαιώματα." + "Μπορείτε να αλλάξετε τα δικαιώματα στις Ρυθμίσεις, Εφαρμογές, Μηνύματα, Δικαιώματα." + "Συχνή επικοινωνία" + "Όλες οι επαφές" + "Αποστολή σε %s" + "Λήψη εικόνων ή βίντεο" + "Επιλέξτε εικόνες από αυτήν τη συσκευή" + "Ηχογράφηση" + "Επιλογή φωτογραφίας" + "Το μέσο είναι επιλεγμένο." + "Το μέσο δεν είναι επιλεγμένο." + "%d επιλεγμένα" + "εικόνα %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "εικόνα" + "Ηχογράφηση" + "Κοινή χρήση" + "Μόλις τώρα" + "Τώρα" + + %d λεπτά + %d λεπτό + + + %d ώρες + %d ώρα + + + %d ημέρες + %d ημέρα + + + %d εβδομάδες + μία εβδομάδα + + + %d μήνες + ένας μήνας + + + %d έτη + ένα έτος + + "Μήνυμα κλάσης 0" + "Αποθήκευση" + "Ο αποθηκευτικός χώρος της συσκευής είναι περιορισμένος. Η Ανταλλαγή μηνυμάτων θα διαγράψει αυτόματα τα παλαιότερα μηνύματα για να απελευθερώσει χώρο." + "Ο χώρος αποθήκευσης εξαντλείται" + "Η Ανταλλαγή μηνυμάτων μπορεί να μην κάνει αποστολή ή λήψη μηνυμάτων μέχρι να ελευθερώσετε περισσότερο χώρο στη συσκευή σας." + "Περιορισμένος αποθηκευτικός χώρος SMS. Μπορεί να χρειαστεί να διαγράψετε μηνύματα." + "Επιβεβαιώστε τον αριθμό τηλεφώνου σας" + "Αυτό το μοναδικό βήμα θα εξασφαλίσει ότι η Ανταλλαγή μηνυμάτων θα παραδώσει σωστά τα ομαδικά μηνύματά σας." + "Αριθμός τηλεφώνου" + "Διαγραφή όλων των μηνυμάτων με πολυμέσα" + "Διαγραφή μηνυμάτων πριν από τις %s" + "Αυτόματη διαγραφή των μηνυμάτων πριν από %s" + "Παράβλεψη" + "Να γίνει διαγραφή όλων των μηνυμάτων που περιέχουν πολυμέσα;" + "Να γίνει διαγραφή των μηνυμάτων πριν από τις %s;" + "Να γίνει αυτόματη διαγραφή των μηνυμάτων πριν από %s και ενεργοποίηση της αυτόματης διαγραφής;" + "Ο χρήστης %s είπε" + "Είπατε" + "Μήνυμα από το χρήστη %s" + "Στείλατε ένα μήνυμα" + "Αποστολή…" + "Δεν εστάλη. Αγγίξτε για να δοκιμάσετε ξανά." + "Δεν εστάλη. Επανάληψη…" + "Εκ νέου αποστολή ή διαγραφή" + "Κάντε μια φωνητική κλήση στις υπηρεσίες έκτακτης ανάγκης. Δεν ήταν δυνατή η παράδοση του μηνύματος κειμένου σας αυτήν τη φορά." + "Αποτυχία" + "Νέο μήνυμα MMS για λήψη" + "Νέο μήνυμα MMS" + "Δεν ήταν δυνατή η λήψη" + "Αγγίξτε για να δοκιμάσετε ξανά" + "Αγγίξτε για λήψη" + "Λήψη ή διαγραφή" + "Λήψη…" + "Το μήνυμα έληξε ή δεν είναι διαθέσιμο" + "μέγεθος: %1$s, λήξη: %2$s" + "Δεν είναι δυνατή η αποστολή. Ο παραλήπτης δεν είναι έγκυρος." + "Η υπηρεσία δεν έχει ενεργοποιηθεί στο δίκτυο" + "Δεν ήταν δυνατή η αποστολή λόγω προβλήματος στο δίκτυο" + "Το μήνυμα έληξε ή δεν είναι διαθέσιμο." + "(Χωρίς θέμα)" + "Άγνωστος αποστολέας" + "Παραδόθηκε" + "Δεν ήταν δυνατή η λήψη του μηνύματος %1$s από %2$s." + "Δεν ήταν δυνατή η ολοκλήρωση της λειτουργίας της βάσης δεδομένων λόγω ανεπαρκούς μνήμης" + "Το μήνυμα δεν εστάλη" + "Ορισμένα μηνύματα δεν εστάλησαν στην Ανταλλαγή μηνυμάτων" + + %d μηνύματα σε %d συνομιλίες + %d μηνύματα σε μία συνομιλία + + "Δεν έγινε λήψη του μηνύματος" + "Δεν έγινε λήψη ορισμένων μηνυμάτων στην Ανταλλαγή μηνυμάτων" + + %d μηνύματα σε %d συνομιλίες + %d μηνύματα σε μία συνομιλία + + "Το μήνυμα στον αριθμό %1$s δεν εστάλη" + "Κάντε μια φωνητική κλήση στις υπηρεσίες έκτακτης ανάγκης. Δεν ήταν δυνατή η παράδοση του μηνύματος κειμένου σας στον αριθμό %1$s αυτήν τη φορά." + + %d νέα μηνύματα + Νέο μήνυμα + + "Έναρξη" + "Η φωτογραφική μηχανή δεν είναι διαθέσιμη" + "Η φωτογραφική μηχανή δεν είναι διαθέσιμη" + "Η εγγραφή βίντεο δεν είναι διαθέσιμη" + "Δεν είναι δυνατή η αποθήκευση πολυμέσων" + "Δεν είναι δυνατή η λήψη φωτογραφίας" + "Πίσω" + "Αρχειοθετημένο" + "Διαγραφή" + "Αρχειοθέτηση" + "Κατάργηση αρχειοθέτησης" + "Απενεργοποίηση ειδοποιήσεων" + "Ενεργοποίηση ειδοποιήσεων" + "Προσθήκη επαφής" + "Λήψη" + "Αποστολή" + "Διαγραφή" + "Να διαγραφεί αυτό το μήνυμα;" + "Δεν είναι δυνατή η αναίρεση αυτής της ενέργειας." + "Διαγραφή" + + Διαγραφή αυτών των συνομιλιών; + Διαγραφή αυτής της συνομιλίας; + + "Διαγραφή" + "Ακύρωση" + "Προς" + "Επιλογή πολλαπλών εικόνων" + "Επιβεβαίωση επιλογής" + "+%d" + "Δεν είναι δυνατή η αποθήκευση ήχου. Δοκιμάστε ξανά." + "Δεν είναι δυνατή η αναπαραγωγή ήχου. Δοκιμάστε ξανά." + "Δεν ήταν δυνατή η αποθήκευση ήχου. Δοκιμάστε ξανά." + "Άγγιγμα & κράτημα" + ", " + " " + ": " + " " + "Εικόνα" + "Κλιπ ήχου" + "Βίντεο" + "Κάρτα επικοινωνίας" + "Λήψη" + "Απάντηση μέσω SMS" + "Απάντηση μέσω MMS" + "Απάντηση" + + %d συμμετέχοντες + %d συμμετέχων + + "Εγώ" + "Η επαφή αποκλείστηκε και αρχειοθετήθηκε" + "Έγινε κατάργηση αποκλεισμού και αρχειοθέτησης της επαφής" + "Έχουν αρχειοθετηθεί %d" + "%d αφαιρέθηκαν από το αρχείο" + "Οι ειδοποιήσεις απενεργοποιήθηκαν" + "Οι ειδοποιήσεις ενεργοποιήθηκαν" + "Έχουν ρυθμιστεί όλα. Αγγίξτε ξανά την επιλογή \"Αποστολή\"." + "Η Ανταλλαγή μηνυμάτων ορίστηκε επιτυχώς ως η προεπιλεγμένη εφαρμογή SMS." + + Απόρριψη συνημμένων + Απόρριψη συνημμένου + + "Συνημμένο ήχου" + "Αναπαραγωγή συνημμένου ήχου" + "Παύση" + "Μήνυμα από %s: %s." + "Αποτυχία μηνύματος από %s: %s. Ώρα: %s" + "Μήνυμα από %s: %s. Ώρα: %s" + "Μη απεσταλμένο μήνυμα προς %s: %s. Ώρα: %s" + "Αποστολή μηνύματος προς %s: %s. Ώρα: %s." + "Αποτυχία μηνύματος προς %s: %s. Ώρα: %s" + "Μήνυμα προς %s: %s. Ώρα: %s." + "Αποτυχία μηνύματος από %s: %s. Ώρα: %s. %s" + "Μήνυμα από %s: %s. Ώρα: %s. %s" + "Μη απεσταλμένο μήνυμα προς %s: %s. Ώρα: %s" + "Αποστολή μηνύματος σε %s: %s. Ώρα: %s" + "Αποτυχία μηνύματος προς %s: %s. Ώρα: %s" + "Μήνυμα προς %s: %s. Ώρα: %s" + "Αποτυχία μηνύματος. Αγγίξτε για επανάληψη." + "Συνομιλία με %s" + "Διαγραφή θέματος" + "Λήψη βίντεο" + "Τραβήξτε μια στατική εικόνα" + "Τραβήξτε μια φωτογραφία" + "Έναρξη εγγραφής βίντεο" + "Εναλλαγή σε κάμερα πλήρους οθόνης" + "Εναλλαγή μεταξύ μπροστινής και της πίσω φωτογραφικής μηχανής" + "Διακοπή εγγραφής και επισύναψη βίντεο" + "Διακοπή εγγραφής βίντεο" + "Φωτογραφίες Ανταλλαγής μηνυμάτων" + + %d φωτογραφίες αποθηκεύτηκαν στο λεύκωμα \"%s\" + %d φωτογραφία αποθηκεύτηκε στο λεύκωμα \"%s\" + + + %d βίντεο αποθηκεύτηκαν στο λεύκωμα \"%s\" + %d βίντεο αποθηκεύτηκε στο λεύκωμα \"%s\" + + + %d συνημμένα αποθηκεύτηκαν στο λεύκωμα \"%s\" + %d συνημμένο αποθηκεύτηκε στο λεύκωμα \"%s\" + + + %d συνημμένα αποθηκεύτηκαν στις \"Λήψεις\" + %d συνημμένο αποθηκεύτηκε στις \"Λήψεις\" + + + Αποθηκεύτηκε %d συνημμένων αρχείων + Αποθηκεύτηκε %d συνημμένο αρχείο + + + Δεν ήταν δυνατή η αποθήκευση %d συνημμένων αρχείων + Δεν ήταν δυνατή η αποθήκευση %d συνημμένου αρχείου + + "Αποθηκευμένο συνημμένο MMS" + "Ρυθμίσεις" + "Αρχειοθετημένο" + "Κλείσιμο" + "MMS" + "Σύνθετες ρυθμίσεις" + "Εντοπισμός σφαλμάτων" + "Ειδοποιήσεις" + "Ήχος" + "Σίγαση" + "Δόνηση" + "Αποκλεισμένο" + "Αναφορές παράδοσης SMS" + "Αίτηση αναφοράς παράδοσης για κάθε SMS που στέλνετε" + "Αυτόματη ανάκτηση" + "Αυτόματη ανάκτηση μηνυμάτων MMS" + "Αυ/τη ανάκτηση περιαγ." + "Αυτόματη ανάκτηση MMS κατά την περιαγωγή" + "Ομαδική ανταλλαγή μηνυμάτων" + "Χρησιμοποιήστε το MMS για να στείλετε ένα μήνυμα σε πολλούς παραλήπτες" + "Προεπιλεγμένη εφαρμογή SMS" + "Προεπιλεγμένη εφαρμογή SMS" + + + "Ο αριθμός τηλεφώνου σας" + "Άγνωστo" + "Ήχοε εξερχόμενου μηνύματος" + "Απόρριψη SMS" + "Απόρριψη μη επεξεργασμένων δεδομένων ληφθέντων SMS σε αρχείο εξωτερικού αποθηκευτικού χώρου" + "Απόρριψη MMS" + "Απόρριψη μη επεξεργασμένων δεδομένων ληφθέντων MMS σε αρχείο εξωτερικού αποθηκευτικού χώρου" + "Ασύρματες ειδοποιήσεις" + "Επιλογές μηνύματος" + "Αντιγραφή κειμένου" + "Προβολή λεπτομερειών" + "Διαγραφή" + "Προώθηση" + "Λεπτομέρειες μηνύματος" + "Τύπος: " + "Μήνυμα κειμένου" + "Μήνυμα πολυμέσων" + "Από: " + "Προς: " + "Εστάλη: " + "Ελήφθη: " + "Θέμα: " + "Μέγεθος: " + "Προτεραιότητα: " + "SIM: " + "Υψηλή" + "Κανονική" + "Χαμηλή" + "SIM %s" + "Διεύθυνση κρυφού αποστολέα" + "Δεν είναι δυνατή η αποστολή του μηνύματος ενώ φορτώνουν τα συνημμένα." + "Δεν είναι δυνατή η φόρτωση της επισύναψης. Δοκιμάστε ξανά." + "Το δίκτυο δεν είναι έτοιμο. Δοκιμάστε ξανά." + "Διαγραφή κειμένου" + "Εναλλαγή μεταξύ εισαγωγής κειμένου και αριθμών" + "Προσθήκη περισσότερων συμμετεχόντων" + "Επιβεβαίωση συμμετεχόντων" + "Έναρξη νέας συνομιλίας" + "Επιλέξτε αυτό το στοιχείο" + "Αναπαραγωγή βίντεο" + "Άτομα και επιλογές" + "Εντοπισμός σφαλμάτων" + "Άτομα και επιλογές" + "Γενικά" + "Άτομα σε αυτήν τη συνομιλία" + "Πραγματοποίηση κλήσης" + "Αποστολή μηνύματος" + "Αποστολή μηνύματος<br/><small>από %s</small>" + + Αποστολή φωτογραφιών + Αποστολή φωτογραφίας + + + Αποστολή αρχείων ήχου + Αποστολή αρχείου ήχου + + + Αποστολή αρχείων βίντεο + Αποστολή αρχείου βίντεο + + + Αποστολή καρτών επαφών + Αποστολή κάρτας επαφής + + + Αποστολή συνημμένων + Αποστολή συνημμένου + + + %d συνημμένα έτοιμα για αποστολή + Ένα συνημμένο έτοιμο για αποστολή + + "Αποστολή σχολίων" + "Προβολή στο Google Play Store" + "Πληροφορίες έκδοσης" + "Έκδοση %1$s" + "Άδειες λογισμικού ανοικτού κώδικα" + "Ειδοποιήσεις" + "Συμπλήρωση ορίου συνημμένων" + "Αποτυχία φόρτωσης συνημμένου." + "Προσθήκη στις επαφές;" + "Προσθήκη επαφής" + "Θέμα" + "Θέμα: " + "%s%s" + "Φόρτωση κάρτας επαφής" + "Δεν ήταν δυνατή η φόρτωση της κάρτας επαφής" + "Προβολή κάρτας επαφής" + + %d επαφές + %d επαφή + + "Κάρτες επαφών" + "Γενέθλια" + "Σημειώσεις" + "Προώθηση μηνύματος" + "Απάντηση" + "+1" + "+%d" + "Τα SMS απενεργοποιήθηκαν" + "Για αποστολή, ορίστε την Ανταλλαγή μηνυμάτων ως προεπιλεγμένη εφαρμογή SMS" + "Ορισμός Ανταλλαγής μηνυμάτων ως προεπιλεγμένης εφαρμογής SMS" + "Αλλαγή" + "Για να λαμβάνετε μηνύματα, ορίστε την Ανταλλαγή μηνυμάτων ως προεπιλεγμένη εφαρμογή SMS" + "Δεν έχει επιλεγεί προτιμώμενη SIM για την αποστολή μηνυμάτων SMS" + "Αυτή η εφαρμογή δεν επιτρέπεται από τον κάτοχο της συσκευής." + "ΟΚ" + "Πάρα πολλοί συμμετέχοντες σε μια συνομιλία" + + Μη έγκυρες επαφές + Μη έγκυρη επαφή + + "Δεν ήταν δυνατή η φόρτωση της εικόνας φωτογραφικής μηχανής" + "Εσείς: " + "%s: " + "Πρόχειρο" + "Αφού ξεκινήσετε μια νέα συνομιλία, θα εμφανίζεται εδώ" + "Οι αρχειοθετημένες συνομιλίες εμφανίζονται εδώ" + "Φόρτωση συνομιλιών…" + "Εικόνα" + "Κλιπ ήχου" + "Βίντεο" + "Κάρτα επαφής" + "MMS" + "Αναίρεση" + "Επανάληψη" + "Εισαγάγετε ένα όνομα επαφής ή έναν αριθμό τηλεφώνου για να ξεκινήσετε ένα νέο μήνυμα" + "Αποκλεισμός" + "Αποκλεισμός επαφής %s" + "Κατάργηση αποκλεισμού της επαφής %s" + "Αποκλεισμός %s;" + "Θα εξακολουθήσετε να λαμβάνετε μηνύματα από αυτόν τον αριθμό, αλλά δεν θα ειδοποιείστε πια. Αυτή η συνομιλία θα αρχειοθετηθεί." + "Αποκλεισμένες επαφές" + "ΚΑΤΑΡΓΗΣΗ ΑΠΟΚΛΕΙΣΜΟΥ" + "Αποκλεισμένες επαφές" + "Επιλέξτε μια εικόνα από τη βιβλιοθήκη εγγράφων" + "Αποστολή μηνύματος" + "Το μήνυμα εστάλη" + "Τα δεδομένα κινητής τηλεφωνίας έχουν απενεργοποιηθεί. Ελέγξτε τις ρυθμίσεις σας." + "Δεν είναι δυνατή η αποστολή μηνυμάτων σε Λειτουργία πτήσης" + "Αδυναμία αποστολής μηνύματος" + "Έγινε λήψη του μηνύματος" + "Τα δεδομένα κινητής τηλεφωνίας απενεργοποιήθηκαν. Ελέγξτε τις ρυθμίσεις σας." + "Δεν είναι δυνατή η λήψη μηνυμάτων σε λειτουργία πτήσης" + "Δεν ήταν δυνατή η λήψη του μηνύματος" + "Μηδέν" + "Ένα" + "Δύο" + "Τρία" + "Τέσσερα" + "Πέντε" + "Έξι" + "Επτά" + "Οκτώ" + "Εννέα" + "Δεν είναι δυνατή η αποστολή του μηνύματος με %1$s, σφάλμα %2$d" + "Δεν είναι δυνατή η αποστολή του μηνύματος με άγνωστη εταιρεία κινητής τηλεφωνίας, σφάλμα %1$d" + "Προώθ.: %s" + "Το μήνυμα δεν εστάλη: Η υπηρεσία δεν ενεργοποιήθηκε στο δίκτυο" + "Το μήνυμα δεν εστάλη: μη έγκυρη διεύθυνση προορισμού" + "Το μήνυμα δεν εστάλη: μη έγκυρο μήνυμα" + "Το μήνυμα δεν εστάλη: μη υποστηριζόμενο περιεχόμενο" + "Το μήνυμα δεν εστάλη: μη υποστηριζόμενο μήνυμα" + "Το μήνυμα δεν εστάλη: πάρα πολύ μεγάλο" + "Νέο μήνυμα" + "Προβολή" + "Εικόνα" + "Δεν ήταν δυνατή η εύρεση της κατάλληλης εφαρμογής" + "Κατάργηση παραλήπτη" + "Νέο μήνυμα" + "Ακύρωση" + "Επεξ/σία σημ. πρόσβ." + "Δεν έχει ρυθμιστεί" + "Όνομα" + "APN" + "MMSC" + "Διακομιστής μεσολάβησης MMS" + "Θύρα MMS" + "MCC" + "MNC" + "Τύπος APN" + "Διαγραφή APN" + "Νέο APN" + "Αποθήκευση" + "Απόρριψη" + "Το πεδίο Όνομα δεν μπορεί να είναι κενό." + "Το APN δεν μπορεί να είναι κενό." + "Το πεδίο MCC πρέπει να αποτελείται από 3 ψηφία." + "Το πεδίο MNC πρέπει να αποτελείται από 2 ή 3 ψηφία." + "Επαναφορά προεπιλεγμένων ρυθμίσεων APN." + "Επαναφορά προεπιλογών" + "Η επαναφορά των εργοστασιακών ρυθμίσεων APN ολοκληρώθηκε." + "Χωρίς τίτλο" + "Ονόματα σημείου πρόσβασης" + "APN" + "Νέο APN" + "Οι ρυθμίσεις ονόματος σημείου πρόσβασης δεν είναι διαθέσιμες γι\' αυτόν το χρήστη" + "Αντιγραφή στο πρόχειρο;" + "Αντιγραφή" + "σε %s" + "Γενικά" + "Σύνθετες ρυθμίσεις" + "Γενικές ρυθμίσεις" + "Σύνθετες ρυθμίσεις" + "\"%s\" SIM" + "Αποστολή μεμονωμένων μηνυμάτων SMS σε όλους τους παραλήπτες. Μόνο εσείς θα λαμβάνετε τις απαντήσεις" + "Αποστολή μεμονωμένου MMS σε όλους τους παραλήπτες" + "Άγνωστος αριθμός" + "Νέο μήνυμα" + "Νέο μήνυμα." + "Επιλογέας SIM" + "Επιλέχτηκε %1$s, επιλογέας SIM" + "Επεξεργασία θέματος" + "Επιλέξτε SIM ή επεξεργαστείτε το θέμα" + "Πατήστε παρατεταμένα για εγγραφή ήχου" + "Έναρξη νέας συνομιλίας" + "Ανταλλαγή μηνυμάτων" + "Λίστα Ανταλλαγής μηνυμάτων" + "Ανταλλαγή μηνυμάτων" + "Νέο μήνυμα" + "Λίστα συνομιλιών" + "Φόρτωση συνομιλιών" + "Φόρτωση μηνυμάτων" + "Προβολή περισσότερων συνομιλιών" + "Εμφάνιση περισσότερων μηνυμάτων" + "Η συνομιλία διαγράφηκε" + "Διαγραφή συνομιλίας. Αγγίξτε για να εμφανίσετε μια διαφορετική συνομιλία στην Ανταλλαγή μηνυμάτων" + "Αποκλείστηκε" + "Ο αποκλεισμός καταργήθηκε" + "Ο αποθηκευτικός χώρος είναι περιορισμένος. Ορισμένα στοιχεία ενδέχεται να χαθούν." + "Επιλέξτε συνημμένα" + "Επιβεβαίωση επιλογής" + "%d επιλ/μένα" + "Καταργήστε ένα ή περισσότερα συνημμένα και δοκιμάστε ξανά." + "Μπορείτε να δοκιμάσετε να στείλετε το μήνυμά σας, αλλά ενδέχεται να μην παραδοθεί αν δεν καταργήσετε ένα ή περισσότερα συνημμένα." + "Μπορείτε να στείλετε μόνο ένα βίντεο ανά μήνυμα. Καταργήστε τα επιπλέον βίντεο και δοκιμάστε ξανά." + "Αποτυχία φόρτωσης συνημμένου από την Ανταλλαγή μηνυμάτων." + "Αποστολή ούτως ή άλλως" + "Δεν ήταν δυνατή η έναρξη της συνομιλίας" + "%1$s (%2$s)" + "Επιλέχτηκε %s" + diff --git a/res/values-en-rAU/arrays.xml b/res/values-en-rAU/arrays.xml new file mode 100644 index 0000000..94d5aff --- /dev/null +++ b/res/values-en-rAU/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "no subject" + "nosubject" + + + "Yes" + "No" + "OK" + "Hehe" + "Thanks" + "I agree" + "Nice" + "On my way" + "OK, let me get back to you later" + ":)" + ":(" + + diff --git a/res/values-en-rAU/strings.xml b/res/values-en-rAU/strings.xml new file mode 100644 index 0000000..11048a5 --- /dev/null +++ b/res/values-en-rAU/strings.xml @@ -0,0 +1,519 @@ + + + + + "Messaging" + "Messaging" + "Select conversation" + "Settings" + "Send Message" + "Add an attachment" + "Help" + "Welcome" + "Skip" + "Next >" + "Next" + "Exit" + "Settings >" + "Settings" + "Messaging needs permission to SMS, Phone and Contacts." + "You can change permissions in Settings > Apps > Messaging > Permissions." + "You can change permissions in Settings, Apps, Messaging, Permissions." + "Frequents" + "All contacts" + "Send to %s" + "Capture pictures or video" + "Choose images from this device" + "Record audio" + "Choose photo" + "The media is selected." + "The media is unselected." + "%d selected" + "image %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "image" + "Record audio" + "Share" + "Just now" + "Now" + + %d mins + %d min + + + %d hours + %d hour + + + %d days + %d day + + + %d weeks + a week + + + %d months + a month + + + %d years + a year + + "Class 0 message" + "Save" + "Device is low on space. Messaging will automatically delete older messages to free up space." + "Storage space running out" + "Messaging might not send or receive messages until more space is available on your device." + "Low SMS storage. You may need to delete messages." + "Confirm your phone number" + "This one-off step will ensure Messaging will deliver your group messages properly." + "Phone number" + "Delete all messages with media" + "Delete messages older than %s" + "Auto-delete messages older than %s" + "Ignore" + "Delete all messages with media?" + "Delete messages older than %s?" + "Delete messages older than %s and turn on auto-delete?" + "%s said" + "You said" + "Message from %s" + "You sent a message" + "Sending…" + "Not sent. Touch to try again." + "Not sent. Trying again…" + "Resend or delete" + "Please make a voice call to emergency services. Your text message could not be delivered at this time." + "Failed" + "New MMS message to download" + "New MMS message" + "Couldn\'t download" + "Touch to try again" + "Touch to download" + "Download or delete" + "Downloading…" + "Message expired or not available" + "size: %1$s, expiration: %2$s" + "Can\'t send. Recipient not valid." + "Service not activated on network" + "Couldn\'t send due to network problem" + "Message expired or not available" + "(No subject)" + "Unknown sender" + "Delivered" + "Couldn\'t download message %1$s from %2$s." + "Couldn\'t complete database operation due to low memory" + "Message not sent" + "Some messages not sent in Messaging" + + %d messages in %d conversations + %d messages in one conversation + + "Message not downloaded" + "Some messages not downloaded in Messaging" + + %d messages in %d conversations + %d messages in one conversation + + "Message to %1$s not sent" + "Please make a voice call to emergency services. Your text message to %1$s could not be delivered at this time." + + %d new messages + New message + + "Start" + "Camera not available" + "Camera not available" + "Video capture not available" + "Can\'t save media" + "Can\'t take picture" + "Back" + "Archived" + "Delete" + "Archive" + "Unarchive" + "Turn off notifications" + "Turn on notifications" + "Add contact" + "Download" + "Send" + "Delete" + "Delete this message" + "This action cannot be undone." + "Delete" + + Delete these conversations? + Delete this conversation? + + "Delete" + "Cancel" + "To" + "Select multiple images" + "Confirm selection" + "+%d" + "Can\'t record audio. Try again." + "Can\'t play audio. Try again." + "Couldn\'t save audio. Try again." + "Touch & hold" + ", " + " " + ": " + " " + "Picture" + "Audio clip" + "Video" + "Contact card" + "Download" + "Reply via SMS" + "Reply via MMS" + "Reply" + + %d participants + %d participant + + "Me" + "Contact blocked & archived" + "Contact unblocked & unarchived" + "%d archived" + "%d unarchived" + "Notifications turned off" + "Notifications turned on" + "All set. Touch Send again." + "Messaging successfully set as the default SMS app." + + Discard attachments + Discard attachment + + "Audio attachment" + "Play audio attachment" + "Pause" + "Message from %s: %s." + "Failed message from %s: %s. Time: %s." + "Message from %s: %s. Time: %s." + "Unsent message to %s: %s. Time: %s." + "Sending message to %s: %s. Time: %s." + "Failed message to %s: %s. Time: %s." + "Message to %s: %s. Time: %s." + "Failed message from %s: %s. Time: %s. %s." + "Message from %s: %s. Time: %s. %s." + "Unsent message to %s: %s. Time: %s." + "Sending message to %s: %s. Time: %s." + "Failed message to %s: %s. Time: %s." + "Message to %s: %s. Time: %s." + "Failed message. Touch to retry." + "Conversation with %s" + "Delete subject" + "Capture video" + "Capture a still image" + "Take picture" + "Start recording video" + "Switch to full screen camera" + "Switch between front and back camera" + "Stop recording and attach video" + "Stop recording video" + "Messaging photos" + + %d photos saved to \"%s\" album + %d photo saved to \"%s\" album + + + %d videos saved to \"%s\" album + %d video saved to \"%s\" album + + + %d attachments saved to \"%s\" album + %d attachment saved to \"%s\" album + + + %d attachments saved to \"Downloads\" + %d attachment saved to \"Downloads\" + + + %d attachments saved + %d attachment saved + + + Couldn\'t save %d attachments + Couldn\'t save %d attachment + + "Saved MMS attachment" + "Settings" + "Archived" + "Close" + "MMS" + "Advanced" + "Debug" + "Notifications" + "Sound" + "Silent" + "Vibrate" + "Blocked" + "SMS delivery reports" + "Request a delivery report for each SMS that you send" + "Auto-retrieve" + "Automatically retrieve MMS messages" + "Roaming auto-retrieve" + "Automatically retrieve MMS when roaming" + "Group messaging" + "Use MMS to send a single message when there are multiple recipients" + "Default SMS app" + "Default SMS app" + + + "Your phone number" + "Unknown" + "Outgoing message sounds" + "Dump SMS" + "Dump received SMS raw data into external storage file" + "Dump MMS" + "Dump received MMS raw data into external storage file" + "Wireless alerts" + "Message options" + "Copy text" + "View details" + "Delete" + "Forward" + "Message details" + "Type: " + "Text message" + "Multimedia message" + "From: " + "To: " + "Sent: " + "Received: " + "Subject: " + "Size: " + "Priority: " + "SIM: " + "High" + "Normal" + "Low" + "SIM %s" + "Hidden sender address" + "Can\'t send message while loading attachments." + "Can\'t load attachment. Try again." + "Network is not ready. Try again." + "Delete text" + "Switch between entering text and numbers" + "Add more participants" + "Confirm participants" + "Start new conversation" + "Select this item" + "Play video" + "People & Options" + "Debug" + "People & Options" + "General" + "People in this conversation" + "Make a call" + "Send message" + "Send message<br/><small>from %s</small>" + + Send photos + Send photo + + + Send audios + Send audio + + + Send videos + Send video + + + Send contact cards + Send contact card + + + Send attachments + Send attachment + + + %d attachments ready to send + One attachment ready to send + + "Send feedback" + "View in Google Play Store" + "Version info" + "Version %1$s" + "Open-source licences" + "Notifications" + "Attachment limit reached" + "Failed to load attachment." + "Add to Contacts?" + "Add Contact" + "Subject" + "Subject: " + "%s%s" + "Loading contact card" + "Couldn\'t load contact card" + "View contact card" + + %d contacts + %d contact + + "Contact cards" + "Birthday" + "Notes" + "Forward message" + "Reply" + "+1" + "+%d" + "SMS disabled" + "To send, set Messaging as the default SMS app" + "Set Messaging as the default SMS app" + "Change" + "To receive messages, set Messaging as the default SMS app" + "No preferred SIM selected for sending SMS messages" + "This app isn\'t allowed by the device owner." + "OK" + "Too many participants in a conversation" + + Invalid contacts + Invalid contact + + "Couldn\'t load camera image" + "You: " + "%s: " + "Draft" + "Once you start a new conversation, you’ll see it listed here" + "Archived conversations appear here" + "Loading conversations…" + "Picture" + "Audio clip" + "In-stream video" + "Contact card" + "MMS" + "Undo" + "Retry" + "Enter a contact name or phone number to start a new message" + "Block" + "Block %s" + "Unblock %s" + "Block %s?" + "You\'ll continue to receive messages from this number but won\'t be notified anymore. This conversation will be archived." + "Blocked contacts" + "UNBLOCK" + "Blocked contacts" + "Choose image from document library" + "Sending message" + "Message sent" + "Mobile data is turned off. Check your settings." + "Can\'t send messages in Aeroplane mode" + "Message could not be sent" + "Message downloaded" + "Mobile data is turned off. Check your settings." + "Can\'t download messages in Aeroplane mode" + "Message could not be downloaded" + "Zero" + "One" + "Two" + "Three" + "Four" + "Five" + "Six" + "Seven" + "Eight" + "Nine" + "Can\'t send message with %1$s, error %2$d" + "Can\'t send message with unknown operator, error %1$d" + "Fwd: %s" + "Message not sent: service not activated on network" + "Message not sent: invalid destination address" + "Message not sent: invalid message" + "Message not sent: unsupported content" + "Message not sent: unsupported message" + "Message not sent: too large" + "New message" + "View" + "Image" + "Could not find an appropriate application" + "Remove recipient" + "New message" + "Cancel" + "Edit access point" + "Not set" + "Named" + "APN" + "MMSC" + "MMS proxy" + "MMS port" + "MCC" + "MNC" + "APN type" + "Delete APN" + "New APN" + "Save" + "Discard" + "The Name field can\'t be empty." + "The APN cannot be empty." + "MCC field must be 3 digits." + "MNC field must be 2 or 3 digits." + "Restoring default APN settings." + "Reset to default" + "Reset default APN settings completed" + "Untitled" + "Access point names" + "APNs" + "New APN" + "Access Point Name settings are not available for this user" + "Copy to clipboard?" + "Copy" + "to %s" + "General" + "Advanced" + "General settings" + "Advanced settings" + "\"%s\" SIM" + "Send individual SMS messages to all recipients. Only you will get any replies" + "Send a single MMS to all recipients" + "Unknown number" + "New message" + "New message" + "SIM selector" + "%1$s selected, SIM selector" + "Edit subject" + "Select SIM or edit subject" + "Touch and hold to record audio" + "Start new conversation" + "Messaging" + "Messaging List" + "Messaging" + "New message" + "Conversation list" + "Loading conversations" + "Loading messages" + "View more conversations" + "View more messages" + "Conversation deleted" + "Conversation deleted. Touch to show a different Messaging conversation" + "Blocked" + "Unblocked" + "Storage space is low. Some data may be lost." + "Select attachments" + "Confirm selection" + "%d selected" + "Please remove one or more attachments and try again." + "You can try sending your message, but it might not be delivered unless you remove one or more attachments." + "You can only send one video per message. Please remove additional videos and try again." + "Messaging failed to load attachment." + "Send anyway" + "Could not start conversation" + "%1$s (%2$s)" + "%s selected" + diff --git a/res/values-en-rGB/arrays.xml b/res/values-en-rGB/arrays.xml new file mode 100644 index 0000000..94d5aff --- /dev/null +++ b/res/values-en-rGB/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "no subject" + "nosubject" + + + "Yes" + "No" + "OK" + "Hehe" + "Thanks" + "I agree" + "Nice" + "On my way" + "OK, let me get back to you later" + ":)" + ":(" + + diff --git a/res/values-en-rGB/strings.xml b/res/values-en-rGB/strings.xml new file mode 100644 index 0000000..11048a5 --- /dev/null +++ b/res/values-en-rGB/strings.xml @@ -0,0 +1,519 @@ + + + + + "Messaging" + "Messaging" + "Select conversation" + "Settings" + "Send Message" + "Add an attachment" + "Help" + "Welcome" + "Skip" + "Next >" + "Next" + "Exit" + "Settings >" + "Settings" + "Messaging needs permission to SMS, Phone and Contacts." + "You can change permissions in Settings > Apps > Messaging > Permissions." + "You can change permissions in Settings, Apps, Messaging, Permissions." + "Frequents" + "All contacts" + "Send to %s" + "Capture pictures or video" + "Choose images from this device" + "Record audio" + "Choose photo" + "The media is selected." + "The media is unselected." + "%d selected" + "image %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "image" + "Record audio" + "Share" + "Just now" + "Now" + + %d mins + %d min + + + %d hours + %d hour + + + %d days + %d day + + + %d weeks + a week + + + %d months + a month + + + %d years + a year + + "Class 0 message" + "Save" + "Device is low on space. Messaging will automatically delete older messages to free up space." + "Storage space running out" + "Messaging might not send or receive messages until more space is available on your device." + "Low SMS storage. You may need to delete messages." + "Confirm your phone number" + "This one-off step will ensure Messaging will deliver your group messages properly." + "Phone number" + "Delete all messages with media" + "Delete messages older than %s" + "Auto-delete messages older than %s" + "Ignore" + "Delete all messages with media?" + "Delete messages older than %s?" + "Delete messages older than %s and turn on auto-delete?" + "%s said" + "You said" + "Message from %s" + "You sent a message" + "Sending…" + "Not sent. Touch to try again." + "Not sent. Trying again…" + "Resend or delete" + "Please make a voice call to emergency services. Your text message could not be delivered at this time." + "Failed" + "New MMS message to download" + "New MMS message" + "Couldn\'t download" + "Touch to try again" + "Touch to download" + "Download or delete" + "Downloading…" + "Message expired or not available" + "size: %1$s, expiration: %2$s" + "Can\'t send. Recipient not valid." + "Service not activated on network" + "Couldn\'t send due to network problem" + "Message expired or not available" + "(No subject)" + "Unknown sender" + "Delivered" + "Couldn\'t download message %1$s from %2$s." + "Couldn\'t complete database operation due to low memory" + "Message not sent" + "Some messages not sent in Messaging" + + %d messages in %d conversations + %d messages in one conversation + + "Message not downloaded" + "Some messages not downloaded in Messaging" + + %d messages in %d conversations + %d messages in one conversation + + "Message to %1$s not sent" + "Please make a voice call to emergency services. Your text message to %1$s could not be delivered at this time." + + %d new messages + New message + + "Start" + "Camera not available" + "Camera not available" + "Video capture not available" + "Can\'t save media" + "Can\'t take picture" + "Back" + "Archived" + "Delete" + "Archive" + "Unarchive" + "Turn off notifications" + "Turn on notifications" + "Add contact" + "Download" + "Send" + "Delete" + "Delete this message" + "This action cannot be undone." + "Delete" + + Delete these conversations? + Delete this conversation? + + "Delete" + "Cancel" + "To" + "Select multiple images" + "Confirm selection" + "+%d" + "Can\'t record audio. Try again." + "Can\'t play audio. Try again." + "Couldn\'t save audio. Try again." + "Touch & hold" + ", " + " " + ": " + " " + "Picture" + "Audio clip" + "Video" + "Contact card" + "Download" + "Reply via SMS" + "Reply via MMS" + "Reply" + + %d participants + %d participant + + "Me" + "Contact blocked & archived" + "Contact unblocked & unarchived" + "%d archived" + "%d unarchived" + "Notifications turned off" + "Notifications turned on" + "All set. Touch Send again." + "Messaging successfully set as the default SMS app." + + Discard attachments + Discard attachment + + "Audio attachment" + "Play audio attachment" + "Pause" + "Message from %s: %s." + "Failed message from %s: %s. Time: %s." + "Message from %s: %s. Time: %s." + "Unsent message to %s: %s. Time: %s." + "Sending message to %s: %s. Time: %s." + "Failed message to %s: %s. Time: %s." + "Message to %s: %s. Time: %s." + "Failed message from %s: %s. Time: %s. %s." + "Message from %s: %s. Time: %s. %s." + "Unsent message to %s: %s. Time: %s." + "Sending message to %s: %s. Time: %s." + "Failed message to %s: %s. Time: %s." + "Message to %s: %s. Time: %s." + "Failed message. Touch to retry." + "Conversation with %s" + "Delete subject" + "Capture video" + "Capture a still image" + "Take picture" + "Start recording video" + "Switch to full screen camera" + "Switch between front and back camera" + "Stop recording and attach video" + "Stop recording video" + "Messaging photos" + + %d photos saved to \"%s\" album + %d photo saved to \"%s\" album + + + %d videos saved to \"%s\" album + %d video saved to \"%s\" album + + + %d attachments saved to \"%s\" album + %d attachment saved to \"%s\" album + + + %d attachments saved to \"Downloads\" + %d attachment saved to \"Downloads\" + + + %d attachments saved + %d attachment saved + + + Couldn\'t save %d attachments + Couldn\'t save %d attachment + + "Saved MMS attachment" + "Settings" + "Archived" + "Close" + "MMS" + "Advanced" + "Debug" + "Notifications" + "Sound" + "Silent" + "Vibrate" + "Blocked" + "SMS delivery reports" + "Request a delivery report for each SMS that you send" + "Auto-retrieve" + "Automatically retrieve MMS messages" + "Roaming auto-retrieve" + "Automatically retrieve MMS when roaming" + "Group messaging" + "Use MMS to send a single message when there are multiple recipients" + "Default SMS app" + "Default SMS app" + + + "Your phone number" + "Unknown" + "Outgoing message sounds" + "Dump SMS" + "Dump received SMS raw data into external storage file" + "Dump MMS" + "Dump received MMS raw data into external storage file" + "Wireless alerts" + "Message options" + "Copy text" + "View details" + "Delete" + "Forward" + "Message details" + "Type: " + "Text message" + "Multimedia message" + "From: " + "To: " + "Sent: " + "Received: " + "Subject: " + "Size: " + "Priority: " + "SIM: " + "High" + "Normal" + "Low" + "SIM %s" + "Hidden sender address" + "Can\'t send message while loading attachments." + "Can\'t load attachment. Try again." + "Network is not ready. Try again." + "Delete text" + "Switch between entering text and numbers" + "Add more participants" + "Confirm participants" + "Start new conversation" + "Select this item" + "Play video" + "People & Options" + "Debug" + "People & Options" + "General" + "People in this conversation" + "Make a call" + "Send message" + "Send message<br/><small>from %s</small>" + + Send photos + Send photo + + + Send audios + Send audio + + + Send videos + Send video + + + Send contact cards + Send contact card + + + Send attachments + Send attachment + + + %d attachments ready to send + One attachment ready to send + + "Send feedback" + "View in Google Play Store" + "Version info" + "Version %1$s" + "Open-source licences" + "Notifications" + "Attachment limit reached" + "Failed to load attachment." + "Add to Contacts?" + "Add Contact" + "Subject" + "Subject: " + "%s%s" + "Loading contact card" + "Couldn\'t load contact card" + "View contact card" + + %d contacts + %d contact + + "Contact cards" + "Birthday" + "Notes" + "Forward message" + "Reply" + "+1" + "+%d" + "SMS disabled" + "To send, set Messaging as the default SMS app" + "Set Messaging as the default SMS app" + "Change" + "To receive messages, set Messaging as the default SMS app" + "No preferred SIM selected for sending SMS messages" + "This app isn\'t allowed by the device owner." + "OK" + "Too many participants in a conversation" + + Invalid contacts + Invalid contact + + "Couldn\'t load camera image" + "You: " + "%s: " + "Draft" + "Once you start a new conversation, you’ll see it listed here" + "Archived conversations appear here" + "Loading conversations…" + "Picture" + "Audio clip" + "In-stream video" + "Contact card" + "MMS" + "Undo" + "Retry" + "Enter a contact name or phone number to start a new message" + "Block" + "Block %s" + "Unblock %s" + "Block %s?" + "You\'ll continue to receive messages from this number but won\'t be notified anymore. This conversation will be archived." + "Blocked contacts" + "UNBLOCK" + "Blocked contacts" + "Choose image from document library" + "Sending message" + "Message sent" + "Mobile data is turned off. Check your settings." + "Can\'t send messages in Aeroplane mode" + "Message could not be sent" + "Message downloaded" + "Mobile data is turned off. Check your settings." + "Can\'t download messages in Aeroplane mode" + "Message could not be downloaded" + "Zero" + "One" + "Two" + "Three" + "Four" + "Five" + "Six" + "Seven" + "Eight" + "Nine" + "Can\'t send message with %1$s, error %2$d" + "Can\'t send message with unknown operator, error %1$d" + "Fwd: %s" + "Message not sent: service not activated on network" + "Message not sent: invalid destination address" + "Message not sent: invalid message" + "Message not sent: unsupported content" + "Message not sent: unsupported message" + "Message not sent: too large" + "New message" + "View" + "Image" + "Could not find an appropriate application" + "Remove recipient" + "New message" + "Cancel" + "Edit access point" + "Not set" + "Named" + "APN" + "MMSC" + "MMS proxy" + "MMS port" + "MCC" + "MNC" + "APN type" + "Delete APN" + "New APN" + "Save" + "Discard" + "The Name field can\'t be empty." + "The APN cannot be empty." + "MCC field must be 3 digits." + "MNC field must be 2 or 3 digits." + "Restoring default APN settings." + "Reset to default" + "Reset default APN settings completed" + "Untitled" + "Access point names" + "APNs" + "New APN" + "Access Point Name settings are not available for this user" + "Copy to clipboard?" + "Copy" + "to %s" + "General" + "Advanced" + "General settings" + "Advanced settings" + "\"%s\" SIM" + "Send individual SMS messages to all recipients. Only you will get any replies" + "Send a single MMS to all recipients" + "Unknown number" + "New message" + "New message" + "SIM selector" + "%1$s selected, SIM selector" + "Edit subject" + "Select SIM or edit subject" + "Touch and hold to record audio" + "Start new conversation" + "Messaging" + "Messaging List" + "Messaging" + "New message" + "Conversation list" + "Loading conversations" + "Loading messages" + "View more conversations" + "View more messages" + "Conversation deleted" + "Conversation deleted. Touch to show a different Messaging conversation" + "Blocked" + "Unblocked" + "Storage space is low. Some data may be lost." + "Select attachments" + "Confirm selection" + "%d selected" + "Please remove one or more attachments and try again." + "You can try sending your message, but it might not be delivered unless you remove one or more attachments." + "You can only send one video per message. Please remove additional videos and try again." + "Messaging failed to load attachment." + "Send anyway" + "Could not start conversation" + "%1$s (%2$s)" + "%s selected" + diff --git a/res/values-en-rIN/arrays.xml b/res/values-en-rIN/arrays.xml new file mode 100644 index 0000000..94d5aff --- /dev/null +++ b/res/values-en-rIN/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "no subject" + "nosubject" + + + "Yes" + "No" + "OK" + "Hehe" + "Thanks" + "I agree" + "Nice" + "On my way" + "OK, let me get back to you later" + ":)" + ":(" + + diff --git a/res/values-en-rIN/strings.xml b/res/values-en-rIN/strings.xml new file mode 100644 index 0000000..11048a5 --- /dev/null +++ b/res/values-en-rIN/strings.xml @@ -0,0 +1,519 @@ + + + + + "Messaging" + "Messaging" + "Select conversation" + "Settings" + "Send Message" + "Add an attachment" + "Help" + "Welcome" + "Skip" + "Next >" + "Next" + "Exit" + "Settings >" + "Settings" + "Messaging needs permission to SMS, Phone and Contacts." + "You can change permissions in Settings > Apps > Messaging > Permissions." + "You can change permissions in Settings, Apps, Messaging, Permissions." + "Frequents" + "All contacts" + "Send to %s" + "Capture pictures or video" + "Choose images from this device" + "Record audio" + "Choose photo" + "The media is selected." + "The media is unselected." + "%d selected" + "image %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "image" + "Record audio" + "Share" + "Just now" + "Now" + + %d mins + %d min + + + %d hours + %d hour + + + %d days + %d day + + + %d weeks + a week + + + %d months + a month + + + %d years + a year + + "Class 0 message" + "Save" + "Device is low on space. Messaging will automatically delete older messages to free up space." + "Storage space running out" + "Messaging might not send or receive messages until more space is available on your device." + "Low SMS storage. You may need to delete messages." + "Confirm your phone number" + "This one-off step will ensure Messaging will deliver your group messages properly." + "Phone number" + "Delete all messages with media" + "Delete messages older than %s" + "Auto-delete messages older than %s" + "Ignore" + "Delete all messages with media?" + "Delete messages older than %s?" + "Delete messages older than %s and turn on auto-delete?" + "%s said" + "You said" + "Message from %s" + "You sent a message" + "Sending…" + "Not sent. Touch to try again." + "Not sent. Trying again…" + "Resend or delete" + "Please make a voice call to emergency services. Your text message could not be delivered at this time." + "Failed" + "New MMS message to download" + "New MMS message" + "Couldn\'t download" + "Touch to try again" + "Touch to download" + "Download or delete" + "Downloading…" + "Message expired or not available" + "size: %1$s, expiration: %2$s" + "Can\'t send. Recipient not valid." + "Service not activated on network" + "Couldn\'t send due to network problem" + "Message expired or not available" + "(No subject)" + "Unknown sender" + "Delivered" + "Couldn\'t download message %1$s from %2$s." + "Couldn\'t complete database operation due to low memory" + "Message not sent" + "Some messages not sent in Messaging" + + %d messages in %d conversations + %d messages in one conversation + + "Message not downloaded" + "Some messages not downloaded in Messaging" + + %d messages in %d conversations + %d messages in one conversation + + "Message to %1$s not sent" + "Please make a voice call to emergency services. Your text message to %1$s could not be delivered at this time." + + %d new messages + New message + + "Start" + "Camera not available" + "Camera not available" + "Video capture not available" + "Can\'t save media" + "Can\'t take picture" + "Back" + "Archived" + "Delete" + "Archive" + "Unarchive" + "Turn off notifications" + "Turn on notifications" + "Add contact" + "Download" + "Send" + "Delete" + "Delete this message" + "This action cannot be undone." + "Delete" + + Delete these conversations? + Delete this conversation? + + "Delete" + "Cancel" + "To" + "Select multiple images" + "Confirm selection" + "+%d" + "Can\'t record audio. Try again." + "Can\'t play audio. Try again." + "Couldn\'t save audio. Try again." + "Touch & hold" + ", " + " " + ": " + " " + "Picture" + "Audio clip" + "Video" + "Contact card" + "Download" + "Reply via SMS" + "Reply via MMS" + "Reply" + + %d participants + %d participant + + "Me" + "Contact blocked & archived" + "Contact unblocked & unarchived" + "%d archived" + "%d unarchived" + "Notifications turned off" + "Notifications turned on" + "All set. Touch Send again." + "Messaging successfully set as the default SMS app." + + Discard attachments + Discard attachment + + "Audio attachment" + "Play audio attachment" + "Pause" + "Message from %s: %s." + "Failed message from %s: %s. Time: %s." + "Message from %s: %s. Time: %s." + "Unsent message to %s: %s. Time: %s." + "Sending message to %s: %s. Time: %s." + "Failed message to %s: %s. Time: %s." + "Message to %s: %s. Time: %s." + "Failed message from %s: %s. Time: %s. %s." + "Message from %s: %s. Time: %s. %s." + "Unsent message to %s: %s. Time: %s." + "Sending message to %s: %s. Time: %s." + "Failed message to %s: %s. Time: %s." + "Message to %s: %s. Time: %s." + "Failed message. Touch to retry." + "Conversation with %s" + "Delete subject" + "Capture video" + "Capture a still image" + "Take picture" + "Start recording video" + "Switch to full screen camera" + "Switch between front and back camera" + "Stop recording and attach video" + "Stop recording video" + "Messaging photos" + + %d photos saved to \"%s\" album + %d photo saved to \"%s\" album + + + %d videos saved to \"%s\" album + %d video saved to \"%s\" album + + + %d attachments saved to \"%s\" album + %d attachment saved to \"%s\" album + + + %d attachments saved to \"Downloads\" + %d attachment saved to \"Downloads\" + + + %d attachments saved + %d attachment saved + + + Couldn\'t save %d attachments + Couldn\'t save %d attachment + + "Saved MMS attachment" + "Settings" + "Archived" + "Close" + "MMS" + "Advanced" + "Debug" + "Notifications" + "Sound" + "Silent" + "Vibrate" + "Blocked" + "SMS delivery reports" + "Request a delivery report for each SMS that you send" + "Auto-retrieve" + "Automatically retrieve MMS messages" + "Roaming auto-retrieve" + "Automatically retrieve MMS when roaming" + "Group messaging" + "Use MMS to send a single message when there are multiple recipients" + "Default SMS app" + "Default SMS app" + + + "Your phone number" + "Unknown" + "Outgoing message sounds" + "Dump SMS" + "Dump received SMS raw data into external storage file" + "Dump MMS" + "Dump received MMS raw data into external storage file" + "Wireless alerts" + "Message options" + "Copy text" + "View details" + "Delete" + "Forward" + "Message details" + "Type: " + "Text message" + "Multimedia message" + "From: " + "To: " + "Sent: " + "Received: " + "Subject: " + "Size: " + "Priority: " + "SIM: " + "High" + "Normal" + "Low" + "SIM %s" + "Hidden sender address" + "Can\'t send message while loading attachments." + "Can\'t load attachment. Try again." + "Network is not ready. Try again." + "Delete text" + "Switch between entering text and numbers" + "Add more participants" + "Confirm participants" + "Start new conversation" + "Select this item" + "Play video" + "People & Options" + "Debug" + "People & Options" + "General" + "People in this conversation" + "Make a call" + "Send message" + "Send message<br/><small>from %s</small>" + + Send photos + Send photo + + + Send audios + Send audio + + + Send videos + Send video + + + Send contact cards + Send contact card + + + Send attachments + Send attachment + + + %d attachments ready to send + One attachment ready to send + + "Send feedback" + "View in Google Play Store" + "Version info" + "Version %1$s" + "Open-source licences" + "Notifications" + "Attachment limit reached" + "Failed to load attachment." + "Add to Contacts?" + "Add Contact" + "Subject" + "Subject: " + "%s%s" + "Loading contact card" + "Couldn\'t load contact card" + "View contact card" + + %d contacts + %d contact + + "Contact cards" + "Birthday" + "Notes" + "Forward message" + "Reply" + "+1" + "+%d" + "SMS disabled" + "To send, set Messaging as the default SMS app" + "Set Messaging as the default SMS app" + "Change" + "To receive messages, set Messaging as the default SMS app" + "No preferred SIM selected for sending SMS messages" + "This app isn\'t allowed by the device owner." + "OK" + "Too many participants in a conversation" + + Invalid contacts + Invalid contact + + "Couldn\'t load camera image" + "You: " + "%s: " + "Draft" + "Once you start a new conversation, you’ll see it listed here" + "Archived conversations appear here" + "Loading conversations…" + "Picture" + "Audio clip" + "In-stream video" + "Contact card" + "MMS" + "Undo" + "Retry" + "Enter a contact name or phone number to start a new message" + "Block" + "Block %s" + "Unblock %s" + "Block %s?" + "You\'ll continue to receive messages from this number but won\'t be notified anymore. This conversation will be archived." + "Blocked contacts" + "UNBLOCK" + "Blocked contacts" + "Choose image from document library" + "Sending message" + "Message sent" + "Mobile data is turned off. Check your settings." + "Can\'t send messages in Aeroplane mode" + "Message could not be sent" + "Message downloaded" + "Mobile data is turned off. Check your settings." + "Can\'t download messages in Aeroplane mode" + "Message could not be downloaded" + "Zero" + "One" + "Two" + "Three" + "Four" + "Five" + "Six" + "Seven" + "Eight" + "Nine" + "Can\'t send message with %1$s, error %2$d" + "Can\'t send message with unknown operator, error %1$d" + "Fwd: %s" + "Message not sent: service not activated on network" + "Message not sent: invalid destination address" + "Message not sent: invalid message" + "Message not sent: unsupported content" + "Message not sent: unsupported message" + "Message not sent: too large" + "New message" + "View" + "Image" + "Could not find an appropriate application" + "Remove recipient" + "New message" + "Cancel" + "Edit access point" + "Not set" + "Named" + "APN" + "MMSC" + "MMS proxy" + "MMS port" + "MCC" + "MNC" + "APN type" + "Delete APN" + "New APN" + "Save" + "Discard" + "The Name field can\'t be empty." + "The APN cannot be empty." + "MCC field must be 3 digits." + "MNC field must be 2 or 3 digits." + "Restoring default APN settings." + "Reset to default" + "Reset default APN settings completed" + "Untitled" + "Access point names" + "APNs" + "New APN" + "Access Point Name settings are not available for this user" + "Copy to clipboard?" + "Copy" + "to %s" + "General" + "Advanced" + "General settings" + "Advanced settings" + "\"%s\" SIM" + "Send individual SMS messages to all recipients. Only you will get any replies" + "Send a single MMS to all recipients" + "Unknown number" + "New message" + "New message" + "SIM selector" + "%1$s selected, SIM selector" + "Edit subject" + "Select SIM or edit subject" + "Touch and hold to record audio" + "Start new conversation" + "Messaging" + "Messaging List" + "Messaging" + "New message" + "Conversation list" + "Loading conversations" + "Loading messages" + "View more conversations" + "View more messages" + "Conversation deleted" + "Conversation deleted. Touch to show a different Messaging conversation" + "Blocked" + "Unblocked" + "Storage space is low. Some data may be lost." + "Select attachments" + "Confirm selection" + "%d selected" + "Please remove one or more attachments and try again." + "You can try sending your message, but it might not be delivered unless you remove one or more attachments." + "You can only send one video per message. Please remove additional videos and try again." + "Messaging failed to load attachment." + "Send anyway" + "Could not start conversation" + "%1$s (%2$s)" + "%s selected" + diff --git a/res/values-es-rUS/arrays.xml b/res/values-es-rUS/arrays.xml new file mode 100644 index 0000000..4a3fbb7 --- /dev/null +++ b/res/values-es-rUS/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "sin asunto" + "sinasunto" + + + "Sí" + "No" + "Aceptar" + "Je, je" + "Gracias" + "Acepto" + "Genial" + "Estoy en camino." + "De acuerdo, te llamo más tarde." + ":)" + ":(" + + diff --git a/res/values-es-rUS/strings.xml b/res/values-es-rUS/strings.xml new file mode 100644 index 0000000..1da2400 --- /dev/null +++ b/res/values-es-rUS/strings.xml @@ -0,0 +1,519 @@ + + + + + "Centro de mensajes" + "Centro de mensajes" + "Seleccionar conversación" + "Configuración" + "Enviar mensaje" + "Agregar un archivo adjunto" + "Ayuda" + "Te damos la bienvenida" + "Omitir" + "Siguiente >" + "Siguiente" + "Salir" + "Configuración >" + "Configuración" + "Centro de Mensajes necesita permiso para acceder a SMS, Teléfono y Contactos." + "Puedes cambiar los permisos en Configuración > Aplicaciones > Centro de Mensajes > Permisos." + "Puedes cambiar los permisos en Configuración, Aplicaciones, Centro de Mensajes, Permisos." + "Frecuentes" + "Todos los contactos" + "Enviar a %s" + "Capturar imágenes o video" + "Seleccionar imágenes de este dispositivo" + "Grabar audio" + "Elegir foto" + "El medio está seleccionado." + "Los medios no están seleccionados." + "Seleccionado: %d" + "imagen %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "imagen" + "Grabar audio" + "Compartir" + "Recién" + "Recién" + + %d minutos + %d minuto + + + %d horas + %d hora + + + %d días + %d día + + + %d semanas + una semana + + + %d meses + un mes + + + %d años + un año + + "Mensaje clase 0" + "Guardar" + "El dispositivo tiene poco espacio de almacenamiento. El Centro de Mensajes borrará automáticamente los mensajes antiguos para liberar espacio." + "Queda poco espacio de almacenamiento" + "Es posible que Centro de Mensajes no envíe ni reciba mensajes hasta que se libere más espacio en el dispositivo." + "Como queda poco espacio para SMS, tal vez debas eliminar mensajes." + "Confirmar tu número de teléfono" + "Este paso, que se realiza por única vez, asegurará que Centro de Mensajes envíe los mensajes grupales de forma correcta." + "Número de teléfono" + "Eliminar todos los mensajes con contenido multimedia" + "Eliminar mensajes de más de %s" + "Eliminar automáticamente mensajes de más de %s" + "Ignorar" + "¿Eliminar todos los mensajes con contenido multimedia?" + "¿Eliminar mensajes de más de %s?" + "¿Eliminar mensajes de más de %s y activar la eliminación automática?" + "%s dijo" + "Tú dijiste" + "Mensaje de %s" + "Enviaste un mensaje" + "Enviando…" + "El mensaje no se envió. Toca para volver a intentarlo." + "El mensaje no se envió. Reintentando…" + "Volver a enviar o eliminar" + "Realiza una llamada de voz a los servicios de emergencia. No se pudo enviar el mensaje de texto esta vez." + "Error" + "Nuevo mensaje MMS para descargar" + "Nuevo mensaje MMS" + "El mensaje no se pudo descargar." + "Toca para volver a intentarlo." + "Toca para descargar." + "Descargar o eliminar" + "Descargando…" + "El mensaje venció o no está disponible." + "tamaño: %1$s; vencimiento: %2$s" + "No se puede enviar porque el destinatario no es válido." + "No se activó el servicio en la red." + "No se pudo enviar debido a un problema de la red." + "El mensaje venció o no está disponible." + "(Sin asunto)" + "Remitente desconocido" + "Entregado" + "No se pudo descargar el mensaje %1$s de %2$s." + "No se pudo completar la operación de la base de datos por falta de memoria." + "Mensaje no enviado" + "No se pudieron enviar algunos mensajes en Centro de Mensajes" + + %d mensajes en %d conversaciones + %d mensajes en una conversación + + "Mensaje no descargado" + "No se pudieron descargar algunos mensajes en Centro de Mensajes" + + %d mensajes en %d conversaciones + %d mensaje en una conversación + + "Mensaje al %1$s no enviado" + "Realiza una llamada de voz a los servicios de emergencia. No se pudo enviar el mensaje de texto a %1$s esta vez." + + %d mensajes nuevos + Un mensaje nuevo + + "Iniciar" + "Cámara no disponible" + "Cámara no disponible" + "Captura de video no disponible" + "No se puede guardar el contenido multimedia." + "No se puede tomar la foto." + "Atrás" + "Archivadas" + "Eliminar" + "Archivo" + "Eliminar del archivo" + "Desactivar notificaciones" + "Activar notificaciones" + "Agregar contacto" + "Descargar" + "Enviar" + "Eliminar" + "¿Deseas eliminar este mensaje?" + "Esta acción no se puede deshacer." + "Eliminar" + + ¿Deseas borrar estas conversaciones? + ¿Deseas borrar esta conversación? + + "Eliminar" + "Cancelar" + "Para" + "Seleccionar varias imágenes" + "Confirmar selección" + "%d más" + "No se puede grabar el audio. Vuelve a intentarlo." + "No se puede reproducir el audio. Vuelve a intentarlo." + "No se pudo guardar el audio. Vuelve a intentarlo." + "Mantener presionado" + ", " + " " + ": " + " " + "Imagen" + "Clip de audio" + "Video" + "Tarjeta de contacto" + "Descargar" + "Responder con SMS" + "Responder (MMS)" + "Responder" + + %d participantes + %d participante + + "Yo" + "Contacto bloqueado y archivado" + "Contacto desbloqueado y sin archivar" + "%d archivada(s)" + "%d no archivada(s)" + "Notificaciones desactivadas" + "Notificaciones activadas" + "Todo listo. Vuelve a tocar Enviar." + "Se estableció de forma correcta Centro de Mensajes como tu aplicación de SMS predeterminada." + + Descartar archivos adjuntos + Descartar archivo adjunto + + "Archivo adjunto de audio" + "Reproducir archivo adjunto de audio" + "Pausa" + "Mensaje de %s: %s" + "Error del mensaje de %s: %s Hora: %s" + "Mensaje de %s: %s Hora: %s" + "Mensaje no enviado a %s: %s Hora: %s" + "Enviando mensaje a %s: %s Hora: %s" + "Error de mensaje a %s: %s Hora: %s" + "Mensaje a %s: %s Hora: %s" + "Error del mensaje de %s: %s Hora: %s %s" + "Mensaje de %s: %s Hora: %s %s" + "Mensaje no enviado a %s: %s Hora: %s" + "Enviando mensaje a %s: %s Hora: %s" + "Error del mensaje a %s: %s Hora: %s" + "Mensaje a %s: %s Hora: %s" + "Se produjo un error en el mensaje. Toca para volver a intentarlo." + "Conversación con %s" + "Eliminar asunto" + "Capturar video" + "Capturar una imagen fija" + "Tomar fotografía" + "Iniciar grabación de video" + "Cambiar a cámara en pantalla completa" + "Alternar entre cámara frontal y trasera" + "Detener la grabación y adjuntar video" + "Detener la grabación de video" + "Fotos de Centro de Mensajes" + + Se guardaron %d fotos en el álbum \"%s\" + Se guardó %d foto en el álbum \"%s\" + + + Se guardaron %d videos en el álbum \"%s\" + Se guardó %d video en el álbum \"%s\" + + + Se guardaron %d archivos adjuntos en el álbum \"%s\" + Se guardó %d archivo adjunto en el álbum \"%s\" + + + Se guardaron %d archivos adjuntos en \"Descargas\" + Se guardó %d archivo adjunto en \"Descargas\" + + + Se guardaron %d archivos adjuntos + Se guardó %d archivo adjunto + + + No se pudieron guardar %d archivos adjuntos + No se pudo guardar %d archivo adjunto + + "Archivo adjunto de MMS guardado" + "Configuración" + "Archivadas" + "Cerrar" + "MMS" + "Avanzada" + "Depurar" + "Notificaciones" + "Sonido" + "Silencio" + "Vibrar" + "Bloqueado" + "Informes de entrega de SMS" + "Solicitar un informe de entrega al enviar un SMS" + "Recuperación automática" + "Recuperar mensajes MMS automáticamente" + "Recuperación automática en roaming" + "Recuperar MMS automáticamente en roaming" + "Mensajes grupales" + "Utiliza MMS para enviar un mismo mensaje a varios destinatarios." + "Aplicación de SMS predeterminada" + "Aplicación de SMS predeterminada" + + + "Tu número de teléfono" + "Desconocido" + "Sonidos de mensaje saliente" + "Volcar SMS" + "Volcar datos sin procesar de SMS en el archivo de almacenamiento externo" + "Volcar MMS" + "Volcar datos sin procesar de MMS recibidos en un archivo de almacenamiento externo" + "Alertas inalámbricas" + "Opciones de mensaje" + "Copiar el texto" + "Ver detalles" + "Eliminar" + "Reenviar" + "Detalles del mensaje" + "Tipo: " + "Mensaje de texto" + "Mensaje multimedia" + "De: " + "Para: " + "Enviado: " + "Fecha de recepción: " + "Asunto: " + "Tamaño: " + "Prioridad: " + "SIM: " + "Alta" + "Normal" + "Baja" + "SIM %s" + "Dirección de remitente oculta" + "No se pueden enviar mensajes mientras se cargan adjuntos." + "No se puede cargar el archivo adjunto. Vuelve a intentarlo." + "La red no está lista. Vuelve a intentarlo." + "Suprimir texto" + "Cambiar entre ingresar texto y números" + "Agregar más participantes" + "Confirmar participantes" + "Comenzar una nueva conversación" + "Seleccionar este elemento" + "Reproducir video" + "Contactos y opciones" + "Depurar" + "Contactos y opciones" + "General" + "Contactos en esta conversación" + "Hacer una llamada" + "Enviar mensaje" + "Enviar mensaje<br/><small>de %s</small>" + + Enviar fotos + Enviar foto + + + Enviar audios + Enviar audio + + + Enviar videos + Enviar video + + + Enviar tarjetas de contacto + Enviar tarjeta de contacto + + + Enviar archivos adjuntos + Enviar archivo adjunto + + + %d archivos adjuntos listos para enviar + Un archivo adjunto listo para enviar + + "Enviar comentarios" + "Ver en Google Play Store" + "Información de la versión" + "Versión %1$s" + "Licencias de código abierto" + "Notificaciones" + "Se alcanzó el límite de archivos adjuntos." + "Se produjo un error al cargar el archivo adjunto." + "¿Agregar a contactos?" + "Agregar contacto" + "Asunto" + "Asunto: " + "%s%s" + "Cargando tarjeta de contacto" + "No se pudo cargar la tarjeta de contacto." + "Ver tarjeta de contacto" + + %d contactos + %d contacto + + "Tarjetas de contactos" + "Fecha de nacimiento" + "Notas" + "Reenviar mensaje" + "Responder" + "Hacer +1" + "+%d" + "SMS inhabilitados" + "Establecer Centro de mensajes como la aplicación de SMS predeterminada para enviar mensajes" + "Establecer Centro de Mensajes como la aplicación de SMS predeterminada" + "Cambiar" + "Establecer Centro de mensajes como la aplicación de SMS predeterminada para recibir mensajes" + "No se seleccionó ninguna SIM preferida para el envío de mensajes SMS." + "El propietario del dispositivo no permite esta aplicación." + "Aceptar" + "Demasiados participantes en una conversación" + + Contactos no válidos + Contacto no válido + + "No se pudo cargar la imagen de la cámara." + "Tú: " + "%s: " + "Borrador" + "Cuando inicies una conversación nueva, aparecerá aquí." + "Las conversaciones archivadas aparecen aquí." + "Cargando conversaciones…" + "Imagen" + "Clip de audio" + "Video" + "Tarjeta de contacto" + "MMS" + "Deshacer" + "Volver a intentar" + "Ingresa un nombre de contacto o número de teléfono para crear un nuevo mensaje." + "Bloquear" + "Bloquear a %s" + "Desbloquear a %s" + "¿Bloquear a %s?" + "Seguirás recibiendo mensajes de este número, pero no verás notificaciones. Esta conversación se archivará." + "Contactos bloqueados" + "DESBLOQUEAR" + "Contactos bloqueados" + "Seleccionar imagen de la galería de documentos" + "Enviando el mensaje" + "Se envió el mensaje." + "Los datos móviles están desactivados. Comprueba la configuración." + "No se pueden enviar mensajes en modo de avión." + "No se pudo enviar el mensaje." + "Se descargó el mensaje." + "Los datos móviles están desactivados. Comprueba la configuración." + "No se pueden descargar mensajes en modo de avión." + "No se pudo descargar el mensaje." + "Cero" + "Uno" + "Dos" + "Tres" + "Cuatro" + "Cinco" + "Seis" + "Siete" + "Ocho" + "Nueve" + "Error %2$d. No se puede enviar el mensaje con %1$s." + "Error %1$d. No se puede enviar el mensaje con un proveedor desconocido." + "Rv: %s" + "No se envió el mensaje porque el servicio no está activado en la red." + "Mensaje no enviado: dirección de destino no válida" + "Mensaje no enviado: mensaje no válido" + "Mensaje no enviado: contenido no admitido" + "Mensaje no enviado: mensaje no admitido" + "Mensaje no enviado: demasiado largo" + "Mensaje nuevo" + "Ver" + "Imagen" + "No se pudo encontrar una aplicación adecuada." + "Eliminar destinatario" + "Mensaje nuevo" + "Cancelar" + "Editar el punto de acceso" + "Sin establecer" + "Nombre" + "APN" + "MMSC" + "Proxy de MMS" + "Puerto MMS" + "MCC" + "MNC" + "Tipo de APN" + "Eliminar APN" + "APN nuevo" + "Guardar" + "Descartar" + "El campo Nombre no puede estar vacío." + "El APN no puede estar vacío." + "El campo de MCC debe contener tres dígitos." + "El campo MNC debe contener dos o tres dígitos." + "Restaurando la configuración APN predeterminada" + "Restablecer parámetros predeterminados" + "Se restableció la configuración predeterminada de APN." + "Sin título" + "Nombres de puntos de acceso" + "APN" + "APN nuevo" + "La configuración del nombre de punto de acceso no está disponible para este usuario." + "¿Copiar en el portapapeles?" + "Copiar" + "a %s" + "General" + "Avanzada" + "Configuración general" + "Configuración avanzada" + "SIM \"%s\"" + "Enviar mensajes de SMS individuales a todos los destinatarios (solo tú recibirás las respuestas)." + "Enviar un único MMS a todos los destinatarios" + "Número desconocido" + "Mensaje nuevo" + "Mensaje nuevo" + "Selector de SIM" + "Se seleccionó %1$s, selector de SIM." + "Editar asunto" + "Seleccionar SIM o editar asunto" + "Mantén presionado para grabar audio." + "Comenzar una nueva conversación" + "Centro de mensajes" + "Lista de Centro de Mensajes" + "Centro de mensajes" + "Mensaje nuevo" + "Lista de conversaciones" + "Cargando conversaciones" + "Cargando mensajes…" + "Ver más conversaciones" + "Ver más mensajes" + "Se eliminó la conversación." + "Se borró la conversación. Toca para mostrar otra conversación de Centro de Mensajes" + "Bloqueado" + "Desbloqueado" + "Hay poco espacio de almacenamiento. Podrían perderse algunos datos." + "Seleccionar archivos adjuntos" + "Confirmar selección" + "Seleccionado: %d" + "Elimina uno o más archivos adjuntos y vuelve a intentarlo." + "Puedes intentar enviar el mensaje, pero podría no enviarse a menos que elimines uno más archivos adjuntos." + "Solo puedes enviar un video por mensaje. Quita los videos adicionales y vuelve a intentarlo." + "Centro de Mensajes no pudo cargar el archivo adjunto." + "Enviar de todas formas" + "No se pudo iniciar la conversación." + "%1$s (%2$s)" + "Se seleccionó %s." + diff --git a/res/values-es/arrays.xml b/res/values-es/arrays.xml new file mode 100644 index 0000000..ca8e06b --- /dev/null +++ b/res/values-es/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "sin asunto" + "sinasunto" + + + "Sí" + "No" + "Ok" + "Je, je" + "Gracias" + "Acepto" + "Genial" + "Voy de camino" + "Vale, te llamo más tarde" + ":)" + ":(" + + diff --git a/res/values-es/strings.xml b/res/values-es/strings.xml new file mode 100644 index 0000000..78ffe68 --- /dev/null +++ b/res/values-es/strings.xml @@ -0,0 +1,519 @@ + + + + + "Mensajes" + "Mensajes" + "Seleccionar conversación" + "Ajustes" + "Enviar mensaje" + "Adjuntar un archivo" + "Ayuda" + "Te damos la bienvenida" + "Saltar" + "Siguiente >" + "Siguiente" + "Salir" + "Ajustes >" + "Ajustes" + "Mensajes necesita permiso para acceder a los SMS, al teléfono y a los contactos." + "Puedes cambiar los permisos en Ajustes > Aplicaciones > Mensajes > Permisos." + "Puedes cambiar los permisos en Ajustes, Aplicaciones, Mensajes, Permisos." + "Frecuentes" + "Todos los contactos" + "Enviar a %s" + "Capturar imágenes o vídeo" + "Seleccionar imágenes de este dispositivo" + "Grabar audio" + "Elegir foto" + "Contenido multimedia seleccionado." + "Contenido multimedia no seleccionado." + "Seleccionado: %d" + "imagen del %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "imagen" + "Grabar audio" + "Compartir" + "Ahora mismo" + "Ahora" + + %d min + %d min + + + %d horas + %d hora + + + %d días + %d día + + + %d semanas + una semana + + + %d meses + un mes + + + %d años + un año + + "Mensaje de clase 0" + "Guardar" + "Casi no queda espacio en el dispositivo. La aplicación Mensajes eliminará automáticamente los mensajes más antiguos para liberar espacio." + "Queda poco espacio" + "Es posible que Mensajes no envíe ni reciba mensajes hasta que haya más espacio disponible en el dispositivo." + "Queda poco espacio para recibir SMS. Es posible que tengas que eliminar mensajes." + "Confirma tu número de teléfono" + "Este paso único garantiza que Mensajes enviará tus mensajes de grupo correctamente." + "Número de teléfono" + "Eliminar todos los mensajes con archivos multimedia" + "Eliminar mensajes anteriores a %s" + "Eliminar automáticamente mensajes anteriores a %s" + "Ignorar" + "¿Eliminar todos los mensajes con archivos multimedia?" + "¿Eliminar mensajes anteriores a %s?" + "¿Eliminar mensajes anteriores a %s y activar eliminación automática?" + "%s ha dicho" + "Has dicho" + "Mensaje de %s" + "Has enviado un mensaje" + "Enviando..." + "No enviado. Toca para volver a intentarlo." + "No enviado. Volviendo a intentar…" + "Volver a enviar o eliminar" + "Llama a los servicios de emergencias. Tu mensaje de texto no se puede entregar en estos momentos." + "Error" + "Nuevo mensaje MMS que descargar" + "Nuevo mensaje MMS" + "Error al descargar" + "Toca para volver a intentarlo" + "Toca para descargar" + "Descargar o eliminar" + "Descargando..." + "El mensaje ha caducado o no está disponible" + "tamaño: %1$s, vencimiento: %2$s" + "No se puede enviar porque el destinatario no es válido." + "No se ha activado el servicio en la red" + "No se ha podido enviar por un problema de red" + "El mensaje ha caducado o no está disponible" + "(Sin asunto)" + "Destinatario desconocido" + "Entregado" + "No se ha podido descargar el mensaje %1$s de %2$s." + "No se ha podido completar la operación de la base de datos por falta de memoria" + "Mensaje no enviado" + "Hay algunos mensajes no enviados en Mensajes" + + %d mensajes en %d conversaciones + %d mensajes en una conversación + + "Mensaje no descargado" + "Algunos mensajes no se han descargado en Mensajes" + + %d mensajes en %d conversaciones + %d mensajes en una conversación + + "Mensaje para %1$s no enviado" + "Llama a los servicios de emergencias. Tu mensaje de texto enviado al número %1$s no se puede entregar en estos momentos." + + %d mensajes nuevos + Mensaje nuevo + + "Iniciar" + "Cámara no disponible" + "Cámara no disponible" + "Captura de vídeo no disponible" + "No se puede guardar el archivo multimedia" + "No se pueden hacer fotos" + "Atrás" + "Archivado" + "Eliminar" + "Archivar" + "No archivar" + "Desactivar notificaciones" + "Activar notificaciones" + "Añadir contacto" + "Descargar" + "Enviar" + "Eliminar" + "¿Eliminar este mensaje?" + "Esta acción no se puede deshacer." + "Eliminar" + + ¿Eliminar estas conversaciones? + ¿Eliminar esta conversación? + + "Eliminar" + "Cancelar" + "Para" + "Seleccionar varias imágenes" + "Confirmar selección" + "+%d" + "No se puede grabar el audio. Vuelve a intentarlo." + "No se puede reproducir el audio. Vuelve a intentarlo." + "No se ha podido guardar el audio. Vuelve a intentarlo." + "Mantener pulsado" + ", " + " " + ": " + " " + "Imagen" + "Fragmento de audio" + "Vídeo" + "Tarjeta de contacto" + "Descargar" + "Responder con SMS" + "Responder por MMS" + "Responder" + + %d participantes + %d participante + + "Yo" + "Contacto bloqueado y archivado" + "Contacto desbloqueado y no archivado" + "%d archivadas" + "%d no archivadas" + "Notificaciones desactivadas" + "Notificaciones activadas" + "Listo. Toca Enviar de nuevo." + "Mensajes establecida como aplicación de SMS predeterminada." + + Descartar archivos adjuntos + Descartar archivo adjunto + + "Archivo de audio adjunto" + "Reproducir archivo de audio adjunto" + "Pausar" + "Mensaje de %s: %s." + "Error del mensaje de %s: %s. Hora: %s." + "Mensaje de %s: %s. Hora: %s." + "Mensaje no enviado a %s: %s. Hora: %s." + "Enviando mensaje a %s: %s. Hora: %s." + "Error del mensaje para %s: %s. Hora: %s." + "Mensaje para %s: %s. Hora: %s." + "Error del mensaje de %s: %s. Hora: %s. %s." + "Mensaje de %s: %s. Hora: %s. %s." + "Mensaje no enviado a %s: %s. Hora: %s." + "Enviando mensaje a %s: %s. Hora: %s." + "Error del mensaje para %s: %s. Hora: %s." + "Mensaje para %s: %s. Hora: %s." + "Error del mensaje. Toca para volver a intentarlo." + "Conversación con %s" + "Eliminar asunto" + "Capturar vídeo" + "Capturar una imagen fija" + "Hacer foto" + "Iniciar grabación de vídeo" + "Cambiar a cámara en pantalla completa" + "Cambiar de la cámara frontal a la trasera" + "Dejar de grabar y adjuntar vídeo" + "Detener grabación de vídeo" + "Fotos de Mensajes" + + %d fotos guardadas en el álbum %s + %d foto guardada en el álbum %s + + + %d vídeos guardados en el álbum %s + %d vídeo guardado en el álbum %s + + + %d archivos adjuntos guardados en el álbum %s + %d archivo adjunto guardado en el álbum %s + + + %d archivos adjuntos guardados en Descargas + %d archivo adjunto guardado en Descargas + + + %d archivos adjuntos guardados + %d archivo adjunto guardado + + + No se han podido guardar %d archivos adjuntos + No se ha podido guardar %d archivo adjunto + + "Adjunto de MMS guardado" + "Ajustes" + "Archivado" + "Cerrar" + "MMS" + "Opciones avanzadas" + "Depurar" + "Notificaciones" + "Sonido" + "Silencio" + "Vibrar" + "Bloqueado" + "Informes de entrega de SMS" + "Solicitar un informe de entrega al enviar un SMS" + "Recuperar automáticamente" + "Recuperar automáticamente mensajes MMS" + "Autorrecuperar en itinerancia" + "Recuperar MMS automáticamente en itinerancia" + "Mensaje de grupo" + "Usar MMS para enviar un único mensaje a varios destinatarios" + "Aplicación de SMS predeterminada" + "Aplicación de SMS predeterminada" + + + "Tu número de teléfono" + "Desconocido" + "Sonidos de mensajes enviados" + "Volcar SMS" + "Volcar datos sin procesar de SMS recibidos en un archivo del almacenamiento externo" + "Volcar MMS" + "Volcar datos sin procesar de MMS recibidos en un archivo del almacenamiento externo" + "Alertas inalámbricas" + "Opciones de mensaje" + "Copiar texto" + "Ver detalles" + "Eliminar" + "Reenviar" + "Detalles de mensaje" + "Tipo: " + "Mensaje de texto" + "Mensaje multimedia" + "De: " + "A: " + "Enviado: " + "Recibido: " + "Asunto: " + "Tamaño: " + "Prioridad: " + "SIM: " + "Alta" + "Normal" + "Baja" + "SIM %s" + "Dirección de remitente oculta" + "No se pueden enviar mensajes mientras se cargan adjuntos." + "No se ha podido cargar el archivo adjunto. Vuelve a intentarlo." + "La red no está lista. Inténtalo de nuevo." + "Eliminar texto" + "Alternar entre introducir texto y números" + "Añadir más participantes" + "Confirmar participantes" + "Iniciar nueva conversación" + "Seleccionar este elemento" + "Reproducir vídeo" + "Contactos y opciones" + "Depurar" + "Contactos y opciones" + "Generales" + "Contactos en esta conversación" + "Hacer una llamada" + "Enviar mensaje" + "Enviar mensaje<br/><small>de %s</small>" + + Enviar fotos + Enviar foto + + + Enviar archivos de audio + Enviar archivo de audio + + + Enviar vídeos + Enviar vídeo + + + Enviar tarjetas de contacto + Enviar tarjeta de contacto + + + Enviar archivos adjuntos + Enviar archivo adjunto + + + %d archivos adjuntos listos para enviar + Un archivo adjunto listo para enviar + + "Danos tu opinión" + "Ver en Google Play Store" + "Información de la versión" + "Versión %1$s" + "Licencias código abierto" + "Notificaciones" + "Límite de archivos adjuntos alcanzado" + "Error al cargar archivo adjunto." + "¿Añadir a contactos?" + "Añadir contacto" + "Asunto" + "Asunto: " + "%s%s" + "Cargando tarjeta de contacto" + "No se ha podido cargar la tarjeta de contacto" + "Ver tarjeta de contacto" + + %d contactos + %d contacto + + "Tarjetas de contacto" + "Cumpleaños" + "Notas" + "Reenviar mensaje" + "Responder" + "+1" + "+%d" + "SMS inhabilitados" + "Para enviar mensajes, establece Mensajes como aplicación de SMS predeterminada" + "Establecer Mensajes como aplicación de SMS predeterminada" + "Cambiar" + "Para recibir mensajes, establece Mensajes como aplicación de SMS predeterminada" + "No se ha seleccionado la SIM preferida para enviar mensajes SMS" + "El propietario del dispositivo no permite esta aplicación." + "Aceptar" + "Demasiados participantes en una conversación" + + Contactos no válidos + Contacto no válido + + "No se ha podido cargar la imagen de la cámara" + "Tú: " + "%s: " + "Borrador" + "Cuando inicies una conversación nueva, aparecerá aquí" + "Aquí aparecen las conversaciones archivadas" + "Cargando conversaciones..." + "Imagen" + "Fragmento de audio" + "Vídeo" + "Tarjeta de contacto" + "MMS" + "Deshacer" + "Volver a intentar" + "Escribe el nombre de un contacto o un número de teléfono para iniciar un mensaje nuevo" + "Bloquear" + "Bloquear a %s" + "Desbloquear a %s" + "¿Bloquear a %s?" + "Seguirás recibiendo mensajes de este número pero no volverás a recibir notificaciones. Esta conversación se archivará." + "Contactos bloqueados" + "DESBLOQUEAR" + "Contactos bloqueados" + "Selecciona imagen de la biblioteca de documentos" + "Enviando mensaje" + "Mensaje enviado" + "Los datos móviles están desactivados. Comprueba los ajustes." + "No se puede enviar mensajes en modo avión" + "Error al enviar mensaje" + "Mensaje descargado" + "Los datos móviles están desactivados. Comprueba los ajustes." + "No se pueden descargar mensajes en modo avión" + "No se ha podido descargar el mensaje" + "Cero" + "Uno" + "Dos" + "Tres" + "Cuatro" + "Cinco" + "Seis" + "Siete" + "Ocho" + "Nueve" + "No se puede enviar el mensaje con %1$s (error %2$d)" + "No se puede enviar el mensaje con un operador desconocido (error %1$d)" + "Rv: %s" + "Mensaje no enviado porque el servicio no se ha activado en la red" + "Mensaje no enviado: dirección de destino no válida" + "Mensaje no enviado: mensaje no válido" + "Mensaje no enviado: contenido no admitido" + "Mensaje no enviado: mensaje no admitido" + "Mensaje no enviado: demasiado largo" + "Mensaje nuevo" + "Ver" + "Imagen" + "No se ha podido encontrar una aplicación adecuada" + "Eliminar destinatario" + "Mensaje nuevo" + "Cancelar" + "Editar punto de acceso" + "No definida" + "Nombre" + "APN" + "MMSC" + "Proxy para MMS" + "Puerto para MMS" + "MCC" + "MNC" + "Tipo de APN" + "Eliminar APN" + "Nuevo APN" + "Guardar" + "Descartar" + "El campo Nombre no puede estar vacío." + "El campo APN no puede estar vacío." + "El campo MCC debe contener 3 dígitos." + "El campo MNC debe contener 2 o 3 dígitos." + "Restaurando la configuración de APN predeterminada" + "Restablecer ajustes" + "Se ha restablecido la configuración de APN predeterminada." + "Sin título" + "Nombre de puntos de acceso" + "APN" + "Nuevo APN" + "Los ajustes del nombre de punto de acceso no están disponibles para este usuario" + "¿Copiar al portapapeles?" + "Copiar" + "para %s" + "General" + "Opciones avanzadas" + "Ajustes generales" + "Ajustes avanzados" + "SIM \"%s\"" + "Enviar mensajes SMS individuales. Solo tú recibirás las respuestas" + "Enviar un único MMS a todos los destinatarios" + "Número desconocido" + "Mensaje nuevo" + "Mensaje nuevo." + "Selector de SIM" + "%1$s seleccionada, selector de SIM" + "Editar asunto" + "Seleccionar SIM o editar asunto" + "Mantener pulsado para grabar audio" + "Iniciar nueva conversación" + "Mensajes" + "Lista de mensajes" + "Mensajes" + "Mensaje nuevo" + "Lista de conversaciones" + "Cargando conversaciones" + "Cargando mensajes" + "Ver más conversaciones" + "Ver más mensajes" + "Conversación eliminada" + "Conversación eliminada. Toca para ver otra conversación de Mensajes" + "Bloqueado" + "Desbloqueado" + "Queda poco espacio de almacenamiento. Es posible que se pierdan algunos datos." + "Seleccionar adjuntos" + "Confirmar selección" + "Seleccionado: %d" + "Elimina uno o varios archivos adjuntos y vuelve a intentarlo." + "Puedes probar a enviar el mensaje, pero quizás no se envíe hasta que elimines uno o varios archivos adjuntos." + "Solo puedes enviar un vídeo por mensaje. Elimina más vídeos y vuelve a intentarlo." + "El mensaje no ha podido cargar el archivo adjunto." + "Enviar de todos modos" + "No se ha podido iniciar la conversación" + "%1$s (%2$s)" + "%s seleccionada" + diff --git a/res/values-et-rEE/arrays.xml b/res/values-et-rEE/arrays.xml new file mode 100644 index 0000000..16e1744 --- /dev/null +++ b/res/values-et-rEE/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "teema puudub" + "teema puudub" + + + "Jah" + "Ei" + "OK" + "Hehe" + "Aitäh" + "Nõustun" + "Tore" + "Olen teel" + "OK, vastan sulle hiljem" + ":)" + ":(" + + diff --git a/res/values-et-rEE/strings.xml b/res/values-et-rEE/strings.xml new file mode 100644 index 0000000..79097fe --- /dev/null +++ b/res/values-et-rEE/strings.xml @@ -0,0 +1,519 @@ + + + + + "Sõnumside" + "Sõnumside" + "Vestluse valimine" + "Seaded" + "Saada sõnum" + "Manuse lisamine" + "Abi" + "Tere tulemast" + "Vahelejätmine" + "Järgmine >" + "Järgmine" + "Väljumine" + "Seaded >" + "Seaded" + "Sõnumside vajab luba SMS-idele, telefonile ja kontaktidele juurdepääsemiseks." + "Saate muuta lube jaotises Seaded > Rakendused > Sõnumside > Load." + "Lubade muutmiseks tehke valikud Seaded, Rakendused, Sõnumside, Load." + "Sagedased" + "Kõik kontaktid" + "Saada numbrile %s" + "Jäädvustage pilte või videoid" + "Kujutiste valimine sellest seadmest" + "Heli salvestamine" + "Foto valimine" + "Meedia on valitud." + "Meediat pole valitud." + "%d on valitud" + "pilt kuupäevaga %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "pilt" + "Heli salvestamine" + "Jagamine" + "Hetk tagasi" + "Praegu" + + %d min + %d min + + + %d tundi + %d tund + + + %d päeva + %d päev + + + %d nädalat + nädal + + + %d kuud + kuu + + + %d aastat + aasta + + "0-klassi sõnum" + "Salvesta" + "Seadmes on vähe ruumi. Sõnumside kustutab ruumi vabastamiseks automaatselt vanemad sõnumid." + "Talletusruum hakkab täis saama" + "Sõnumside ei pruugi sõnumeid saata ega vastu võtta seni, kuni seadmes on rohkem vaba ruumi." + "SMS-ide jaoks on vähe salvestusruumi. Peate võib-olla mõned sõnumid kustutama." + "Kinnitage oma telefoninumber" + "See ühekordne toiming tagab, et rakendus Sõnumside edastab teie grupisõnumid õigesti." + "Telefoninumber" + "Kustuta kõik meediumifaile sisaldavad sõnumid" + "Kustuta sõnumid, mis on vanemad kui %s" + "Kustuta automaatselt sõnumid, mis on vanemad kui %s" + "Eira" + "Kas kustutada kõik meediaga sõnumid?" + "Kas kustutada sõnumid, mis on vanemad kui %s?" + "Kas kustutada automaatselt sõnumid, mis on vanemad kui %s, ja lülitada sisse automaatne kustutamine?" + "%s ütles:" + "Ütlesite:" + "Sõnum saatjalt %s" + "Saatsite sõnumi" + "Saatmine ..." + "Ei saadetud. Puudutage uuesti proovimiseks." + "Ei saadetud. Proovitakse uuesti ..." + "Saada uuesti või kustuta" + "Helistage hädaabiteenusele. Teie tekstsõnumit ei õnnestunud praegu edastada." + "Ebaõnnestus" + "Uus MMS-sõnum allalaadimiseks" + "Uus MMS-sõnum" + "Ei saanud alla laadida" + "Puudutage uuesti proovimiseks" + "Puudutage allalaadimiseks" + "Laadi alla või kustuta" + "Allalaadimine ..." + "Sõnum on aegunud või pole saadaval" + "suurus: %1$s, aegumine: %2$s" + "Ei õnnestu saata. Saaja ei ole õige." + "Teenus pole võrgus aktiveeritud" + "Võrguprobleemi tõttu ei õnnestunud saata" + "Sõnum on aegunud või pole saadaval" + "(Teema puudub.)" + "Tundmatu saatja" + "Kohale toimetatud" + "Ei saa laadida alla sõnumit %1$s, mille saatis %2$s." + "Vähese mälu tõttu ei õnnestunud andmebaasi toimingut lõpule viia" + "Sõnumit ei saadetud" + "Mõnda sõnumit ei saadetud rakenduses Sõnumside" + + %d sõnumit %d vestluses + %d sõnumit ühes vestluses + + "Sõnumit ei laaditud alla" + "Mõnda sõnumit ei laaditud alla rakenduses Sõnumside" + + %d sõnumit %d vestluses + %d sõnumit ühes vestluses + + "Sõnumit numbrile %1$s ei saadetud" + "Helistage hädaabiteenusele. Teie tekstsõnumit numbrile %1$s ei õnnestunud praegu edastada." + + %d uut sõnumit + Uus sõnum + + "Käivita" + "Kaamera pole saadaval" + "Kaamera pole saadaval" + "Video jäädvustamine pole saadaval" + "Meediat ei saa salvestada" + "Pilti ei saa jäädvustada" + "Tagasi" + "Arhiivitud" + "Kustutamine" + "Arhiivimine" + "Arhiivimise tühistamine" + "Märguannete väljalülitamine" + "Märguannete sisselülitamine" + "Kontakti lisamine" + "Laadi alla" + "Saada" + "Kustuta" + "Kas kustutada see sõnum?" + "Seda toimingut ei saa tagasi võtta." + "Kustuta" + + Kas kustutada need vestlused? + Kas kustutada see vestlus? + + "Kustuta" + "Tühista" + "Sihtkohta" + "Mitme kujutise valimine" + "Valiku kinnitamine" + "ja veel %d" + "Heli ei õnnestu salvestada. Proovige uuesti." + "Heli ei saa esitada. Proovige uuesti." + "Heli ei õnnestunud salvestada. Proovige uuesti." + "Puudutage pikalt" + ", " + " " + ": " + " " + "Pilt" + "Heliklipp" + "Video" + "Kontaktikaart" + "Allalaadimine" + "Vasta SMS-iga" + "Vastam. MMS-iga" + "Vasta" + + %d osalejat + %d osaleja + + "Mina" + "Kontakt on blokeeritud ja arhiivitud" + "Kontakt deblokeeriti ja eemaldati arhiivist" + "%d on arhiivitud" + "%d üksuse arhiivimine on tühistatud" + "Märguanded on välja lülitatud" + "Märguanded on sisse lülitatud" + "Kõik on valmis. Puudutage uuesti käsku Saada." + "Sõnumside määramine SMS-i vaikerakenduseks õnnestus." + + Manustest loobumine + Manusest loobumine + + "Helimanus" + "Helimanuste esitamine" + "Peata" + "Sõnum saatjalt %s: %s." + "Sõnumi saamine kontaktilt %s ebaõnnestus: %s. Aeg: %s." + "Sõnum kontaktilt %s: %s. Aeg: %s." + "Saatmata sõnum kontaktile %s: %s. Aeg: %s." + "Sõnumi saatmine kontaktile %s: %s. Aeg: %s." + "Sõnumi saatmine kontaktile %s ebaõnnestus: %s. Aeg: %s." + "Sõnum kontaktile %s: %s. Aeg: %s." + "Sõnumi saamine kontaktilt %s ebaõnnestus: %s. Aeg: %s. %s." + "Sõnum kontaktilt %s: %s. Aeg: %s. %s." + "Saatmata sõnum kontaktile %s: %s. Aeg: %s." + "Sõnumi saatmine kontaktile %s: %s. Aeg: %s." + "Sõnumi saatmine kontaktile %s ebaõnnestus: %s. Aeg: %s." + "Sõnum kontaktile %s: %s. Aeg: %s." + "Sõnumi saatmine ebaõnnestus. Puudutage uuesti proovimiseks." + "Vestlus osalejatega %s" + "Teema kustutamine" + "Video jäädvustamine" + "Foto jäädvustamine" + "Pildistamine" + "Video salvestamise alustamine" + "Lülitamine täisekraaniga kaamerale" + "Esi- ja tagakaamera vahetamine" + "Salvestamise peatamine ja video lisamine" + "Lõpeta video salvestamine" + "Sõnumside fotod" + + %d fotot salvestati albumisse „%s + %d foto salvestati albumisse „%s + + + %d videot salvestati albumisse „%s + %d video salvestati albumisse „%s + + + %d manust salvestati albumisse „%s + %d manus salvestati albumisse „%s + + + %d manust salvestati kausta „Allalaadimised” + %d manus salvestati kausta „Allalaadimised” + + + %d manust salvestati + %d manus salvestati + + + %d manust ei saanud salvestada + %d manust ei saanud salvestada + + "Salvestati MMS-manus" + "Seaded" + "Arhiivitud" + "Sule" + "MMS" + "Täpsem" + "Silumine" + "Märguanded" + "Heli" + "Hääletu" + "Vibratsioon" + "Blokeeritud" + "SMS-i edastamisaruanded" + "Edastamisaruande taotlus iga saadetud SMS-i kohta" + "Automaatne toomine" + "MMS-sõnumite automaatne vastuvõtmine" + "Automaatne vastuvõtmine rändlusel" + "MMS-ide automaatne toomine rändluse ajal" + "Rühma sõnumside" + "MMS-i kasutamine, kui mitmele kontaktile tuleb saata üks sõnum" + "SMS-i vaikerakendus" + "SMS-i vaikerakendus" + + + "Teie telefoninumber" + "Teadmata" + "Väljuva sõnumi helid" + "SMS-i tõmmistamine" + "Saadud SMS-ide lähteandmete tõmmistamine välisesse talletusfaili" + "MMS-i tõmmistamine" + "Saadud MMS-ide lähteandmete tõmmistamine välisesse talletusfaili" + "Raadiosidehoiatused" + "Sõnumi valikud" + "Kopeeri tekst" + "Kuva üksikasjad" + "Kustuta" + "Edastamine" + "Sõnumi üksikasjad" + "Tüüp: " + "Tekstisõnum" + "Multimeediumsõnum" + "Saatja: " + "Saaja: " + "Saadetud: " + "Vastu võetud: " + "Teema: " + "Suurus: " + "Prioriteet: " + "SIM: " + "Suur" + "Tavaline" + "Madal" + "SIM %s" + "Varjatud saatja aadress" + "Sõnumit ei saa manuste laadimise ajal saata." + "Manust ei õnnestu laadida. Proovige uuesti." + "Võrk pole valmis. Proovige uuesti." + "Kustuta tekst" + "Teksti ja numbrite vaheldumisi sisestamine" + "Lisa rohkem osalejaid" + "Osalejate kinnitamine" + "Uue vestluse alustamine" + "Selle üksuse valimine" + "Video esitamine" + "Inimesed ja valikud" + "Silu" + "Inimesed ja valikud" + "Üldine" + "Selles vestluses osalevad inimesed" + "Helista" + "Saatke sõnum" + "Sõnumi saatmine<br/><small>SIM-kaardilt %s</small>" + + Saada fotod + Saada foto + + + Saada helifailid + Saada helifail + + + Saada videod + Saada video + + + Saada kontaktikaardid + Saada kontaktikaart + + + Saada manused + Saada manus + + + %d manust on saatmiseks valmis + Üks manus on saatmiseks valmis + + "Tagasiside saatmine" + "Kuvamine Google Play poes" + "Versiooni teave" + "Versioon %1$s" + "Avatud lähtekoodi litsentsid" + "Märguanded" + "Manuselimiit on täis" + "Manuse laadimine ebaõnnestus." + "Kas lisada kontaktidesse?" + "Kontaktisiku lisamine" + "Teema" + "Teema: " + "%s%s" + "Kontakti kaardi laadimine" + "Kontaktkaarti ei õnnestunud laadida" + "Kontakti kaardi kuvamine" + + %d kontakti + %d kontakt + + "Kontaktkaardid" + "Sünnipäev" + "Märkmed" + "Sõnumi edastamine" + "Vastamine" + "+1" + "+%d" + "SMS on keelatud" + "Saatmiseks määrake rakendus Sõnumside SMS-i vaikerakenduseks" + "Määrake rakendus Sõnumside SMS-i vaikerakenduseks" + "Muuda" + "Sõnumite vastuvõtmiseks määrake rakendus Sõnumside SMS-i vaikerakenduseks" + "SMS-sõnumite saatmiseks pole eelistatud SIM-kaarti valitud" + "Seadme omanik pole seda rakendust lubanud." + "OK" + "Vestluses on liiga palju osalejaid" + + Sobimatud kontaktid + Sobimatu kontakt + + "Kaamera kujutist ei õnnestunud laadida" + "Teie: " + "%s: " + "Mustand" + "Kui alustate uut vestlust, loetletakse see siin" + "Arhiivitud vestlused kuvatakse siin" + "Vestluste laadimine …" + "Pilt" + "Heliklipp" + "Video" + "Kontaktikaart" + "MMS" + "Tagasivõtmine" + "Proovi uuesti" + "Uue sõnumi alustamiseks sisestage kontakti nimi või telefoninumber" + "Blokeerimine" + "Blokeeri %s" + "Deblokeeri %s" + "Kas blokeerida %s?" + "Saate sellelt numbrilt ikka sõnumeid, kuid te ei saa enam märguandeid. Vestlus arhiivitakse." + "Blokeeritud kontaktid" + "DEBLOKEERIMINE" + "Blokeeritud kontaktid" + "Dokumendikogust kujutise valimine" + "Sõnumi saatmine" + "Sõnum on saadetud" + "Mobiilne andmeside on välja lülitatud. Kontrollige seadeid." + "Lennukirežiimis ei saa sõnumeid saata" + "Sõnumit ei saanud saata" + "Sõnum on alla laaditud" + "Mobiilne andmeside on välja lülitatud. Kontrollige seadeid." + "Lennukireżiimis ei saa sõnumeid alla laadida" + "Sõnumit ei saanud alla laadida" + "Null" + "Üks" + "Kaks" + "Kolm" + "Neli" + "Viis" + "Kuus" + "Seitse" + "Kaheksa" + "Üheksa" + "Operaatori %1$s kaudu ei õnnestu sõnumit saata, viga %2$d" + "Tundmatu operaatori kaudu ei õnnestu sõnumit saata, viga %1$d" + "Edastamine: %s" + "Sõnumit ei saadetud: teenus pole võrgus aktiveeritud" + "Sõnumit ei saadetud: kehtetu sihtaadress" + "Sõnumit ei saadetud: vigane sõnum" + "Sõnumit ei saadetud: toetuseta sisu" + "Sõnumit ei saadetud: toetuseta sõnum" + "Sõnumit ei saadetud: liiga suur" + "Uus sõnum" + "Kuvamine" + "Kujutis" + "Sobivat rakendust ei õnnestunud leida" + "Saaja eemaldamine" + "Uus sõnum" + "Tühista" + "Pääsupunkti muutmine" + "Määramata" + "Nimi" + "APN" + "MMSC" + "MMS-i puhverserver" + "MMS-i port" + "MCC" + "MNC" + "APN-i tüüp" + "Kustuta APN" + "Uus APN" + "Salvesta" + "Tühista" + "Nimeväli ei saa olla tühi." + "APN ei saa olla tühi." + "MCC-väli peab olema kolme numbriga." + "MNC-väli peab olema kahe või kolme numbriga." + "APN-i vaikeseadete taastamine." + "Lähtesta vaikeseadetele" + "APN-i vaikeseadete lähtestamine on lõpule viidud." + "Pealkirjata" + "Pääsupunktide nimed" + "APN-id" + "Uus APN" + "Pääsupunkti nime seaded pole selle kasutaja jaoks saadaval" + "Kas kopeerida lõikelauale?" + "Kopeeri" + "SIM-kaardile %s" + "Üldised" + "Täpsemad" + "Üldseaded" + "Täpsemad seaded" + "SIM-kaart „%s”" + "Individuaalsed SMS-id saadetakse kõikidele adressaatidele. Ainult teie saate vastuseid" + "Üks MMS saadetakse kõikidele adressaatidele" + "Tundmatu number" + "Uus sõnum" + "Uus sõnum." + "SIM-kaardi valija" + "%1$s on valitud, SIM-kaardi valija" + "Teema muutmine" + "SIM-kaardi valimine või objekti valimine" + "Puudutage pikalt heli salvestamiseks" + "Uue vestluse alustamine" + "Sõnumside" + "Sõnumside loend" + "Sõnumside" + "Uus sõnum" + "Vestlusloend" + "Vestluste laadimine …" + "Sõnumite laadimine ..." + "Kuva rohkem vestlusi" + "Kuva rohkem sõnumeid" + "Vestlus on kustutatud" + "Vestlus kustutati. Rakenduse Sõnumside eri vestluste kuvamiseks puudutage" + "Blokeeritud" + "Deblokeeritud" + "Talletusruumi on vähe. Osa andmetest võib kaduma minna." + "Manuste valimine" + "Valiku kinnitamine" + "%d on valitud" + "Eemaldage üks või mitu manust ja proovige uuesti." + "Võite proovida sõnumit saata, aga selle edastamine võib ebaõnnestuda, kui te ei eemalda üht või mitut manust." + "Saate sõnumis saata vaid ühe video. Eemaldage täiendavad videod ja proovige uuesti." + "Rakendusel Sõnumside ei õnnestunud manust laadida." + "Saada ikkagi" + "Vestlust ei õnnestunud alustada" + "%1$s (%2$s)" + "%s on valitud" + diff --git a/res/values-eu-rES/arrays.xml b/res/values-eu-rES/arrays.xml new file mode 100644 index 0000000..5ea97f0 --- /dev/null +++ b/res/values-eu-rES/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "gairik ez" + "gairik ez" + + + "Bai" + "Ez" + "Ados" + "Kar-kar" + "Eskerrik asko" + "Ados" + "Ederki" + "Banoa" + "Ados, geroago erantzungo dizut" + ":)" + ":(" + + diff --git a/res/values-eu-rES/strings.xml b/res/values-eu-rES/strings.xml new file mode 100644 index 0000000..24813fe --- /dev/null +++ b/res/values-eu-rES/strings.xml @@ -0,0 +1,519 @@ + + + + + "Mezuak" + "Mezuak" + "Hautatu elkarrizketa" + "Ezarpenak" + "Bidali mezua" + "Gehitu eranskina" + "Laguntza" + "Ongi etorri" + "Saltatu" + "Hurrengoa >" + "Hurrengoa" + "Irten" + "Ezarpenak >" + "Ezarpenak" + "Mezuak aplikazioak baimena behar du SMS mezuak bidaltzeko eta jasotzeko, deiak egiteko eta kontaktuak atzitzeko." + "Baimenak aldatzeko, zoaz Ezarpenak > Aplikazioak > Mezuak > Baimenak atalera." + "Baimenak aldatzeko, zoaz Ezarpenak, Aplikazioak, Mezuak, Baimenak atalera." + "Maiz erabilitakoak" + "Kontaktu guztiak" + "Bidali hona: %s" + "Atera irudiak edo grabatu bideoa" + "Aukeratu gailu honetako irudiak" + "Grabatu audioa" + "Aukeratu argazkia" + "Euskarria hautatu da." + "Euskarria desautatu da." + "%d hautatuta" + "Irudiaren data: %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "irudia" + "Grabatu audioa" + "Partekatu" + "Oraintxe" + "Orain" + + %d min + %d min + + + %d ordu + %d ordu + + + %d egun + %d egun + + + %d aste + Astebete + + + %d hilabete + Hilabete + + + %d urte + Urtebete + + "0 klaseko mezua" + "Gorde" + "Toki gutxi dago gailuan. Tokia egiteko, Mezuak aplikazioak automatikoki ezabatuko ditu mezu zaharragoak." + "Memoria betetzen ari da" + "Gailuan toki gehiago izan arte, baliteke Mezuak aplikazioak mezurik ez bidaltzea edo jasotzea." + "SMS mezuetarako memoria gutxi dago. Baliteke mezuak ezabatu behar izatea." + "Berretsi telefono-zenbakia" + "Behin bakarrik egin beharreko urrats honi esker, Mezuak aplikazioak taldeko mezuak behar bezala bidaliko dituela ziurtatuko duzu." + "Telefono-zenbakia" + "Ezabatu multimedia-elementuak dituzten mezu guztiak" + "Ezabatu %s baino zaharragoak diren mezuak" + "Ezabatu automatikoki %s baino zaharragoak diren mezuak" + "Egin ez ikusi" + "Multimedia-elementuak dauzkaten mezu guztiak ezabatu nahi dituzu?" + "%s baino zaharragoak diren mezuak ezabatu nahi dituzu?" + "%s baino zaharragoak diren mezuak ezabatu eta ezabaketa automatikoa aktibatu nahi duzu?" + "%s erabiltzaileak esan du" + "Zuk esan duzu" + "%s erabiltzailearen mezua:" + "Mezu bat bidali duzu" + "Bidaltzen…" + "Ez da bidali. Berriro saiatzeko, ukitu hau." + "Ez da bidali. Berriro saiatzen…" + "Birbidali edo ezabatu" + "Deitu larrialdi-zerbitzuetara. Ezin izan dugu entregatu testu-mezua." + "Huts egin du" + "MMS mezu berria deskargatzeko prest" + "MMS mezu berria" + "Ezin izan da deskargatu" + "Berriro saiatzeko, ukitu hau" + "Deskargatzeko, ukitu hau" + "Deskargatu edo ezabatu" + "Deskargatzen…" + "Mezua iraungi da edo ez dago erabilgarri" + "Tamaina: %1$s. Iraungitze-data: %2$s." + "Ezin da bidali. Hartzaileak ez du balio." + "Zerbitzua ez dago sare honetan aktibatuta" + "Ezin izan da bidali sareko arazo baten ondorioz" + "Mezua iraungi da edo ez dago erabilgarri" + "(Gairik ez)" + "Igorle ezezaguna" + "Bidalita" + "Ezin izan da %2$s erabiltzailearen %1$s mezua deskargatu." + "Ezin izan da datu-basearen eragiketa osatu memoria gutxi dagoelako" + "Ez da bidali mezua" + "Ez dira bidali Mezuak aplikazioko mezu batzuk" + + %d mezu daude %d elkarrizketatan + %d mezu daude elkarrizketa batean + + "Ez da deskargatu mezua" + "Ez dira deskargatu Mezuak aplikazioko mezu batzuk" + + %d mezu daude %d elkarrizketatan + %d mezu daude elkarrizketa batean + + "Mezua ez da bidali %1$s zenbakira" + "Deitu larrialdi-zerbitzuetara. Ezin izan dugu entregatu %1$s zenbakira bidalitako testu-mezua." + + %d mezu berri + Mezu berria + + "Hasi" + "Kamera ez dago erabilgarri" + "Kamera ez dago erabilgarri" + "Bideo-grabaketa ez dago erabilgarri" + "Ezin da gorde multimedia-elementua" + "Ezin da atera argazkia" + "Atzera" + "Artxibatutakoak" + "Ezabatu" + "Artxibatu" + "Kendu artxibotik" + "Desaktibatu jakinarazpenak" + "Aktibatu jakinarazpenak" + "Gehitu kontaktua" + "Deskargatu" + "Bidali" + "Ezabatu" + "Mezua ezabatu nahi duzu?" + "Ezin da ekintza hori desegin." + "Ezabatu" + + Elkarrizketa hauek ezabatu nahi dituzu? + Elkarrizketa ezabatu nahi duzu? + + "Ezabatu" + "Utzi" + "Hartzailea" + "Hautatu hainbat irudi" + "Berretsi hautapena" + "+%d" + "Ezin da grabatu audioa. Saiatu berriro." + "Ezin da erreproduzitu audioa. Saiatu berriro." + "Ezin izan da gorde audioa. Saiatu berriro." + "Eduki ukituta" + ", " + " " + ": " + " " + "Argazkia" + "Audio-klipa" + "Bideoa" + "Kontaktu-txartela" + "Deskargatu" + "Erantzun SMS bidez" + "Erantzun MMS bidez" + "Erantzun" + + %d parte-hartzaile + %d parte-hartzaile + + "Ni" + "Kontaktua blokeatu eta artxibatu egin da" + "Kontaktua desblokeatu da eta artxibotik kendu da" + "%d artxibatuta" + "%d artxibotik kenduta" + "Jakinarazpenak desaktibatuta daude" + "Jakinarazpenak aktibatuta daude" + "Dena ezarri da. Ukitu berriro Bidali." + "Mezuak aplikazioa behar bezala ezarri da SMS mezuetarako aplikazio lehenetsi gisa." + + Baztertu eranskinak + Baztertu eranskina + + "Audio-eranskina" + "Erreproduzitu audio-eranskina" + "Pausatu" + "%s erabiltzailearen mezua: %s." + "%s igorlearen mezuak huts egin du: %s. Ordua: %s" + "%s igorlearen mezua: %s. Ordua: %s" + "Ez zaio bidali mezua %s erabiltzaileari: %s. Ordua: %s" + "%s erabiltzaileari mezua bidaltzen: %s. Ordua: %s" + "Ezin izan zaio bidali mezua %s erabiltzaileari: %s. Ordua: %s" + "%s erabiltzailearentzako mezua: %s. Ordua: %s" + "%s igorlearen mezuak huts egin du: %s. Ordua: %s. %s" + "%s igorlearen mezua: %s. Ordua: %s. %s" + "Ez da bidali mezua %s taldera: %s. Ordua: %s" + "%s taldera mezua bidaltzen: %s. Ordua: %s" + "Ezin izan da bidali mezua %s taldera: %s. Ordua: %s" + "%s taldearentzako mezua: %s. Ordua: %s" + "Mezuak huts egin du. Ukitu berriro saiatzeko." + "Elkarrizketa erabiltzaile hauekin (%s)" + "Ezabatu gaia" + "Grabatu bideoa" + "Atera irudi finkoa" + "Atera argazkia" + "Hasi bideoa grabatzen" + "Aldatu pantaila osoko kamerara" + "Aldatu aurreko eta atzeko kameren artean" + "Utzi grabatzeari eta erantsi bideoa" + "Utzi bideoa grabatzeari" + "Mezuak aplikazioko argazkiak" + + %d argazki gorde dira \"%s\" albumean + %d argazki gorde da \"%s\" albumean + + + %d bideo gorde dira \"%s\" albumean + %d bideo gorde da \"%s\" albumean + + + %d eranskin gorde dira \"%s\" albumean + %d eranskin gorde da \"%s\" albumean + + + %d eranskin gorde dira \"Deskargak\" atalean + %d eranskin gorde da \"Deskargak\" atalean + + + Gorde dira %d eranskin + Gorde da %d eranskin + + + Ezin izan dira gorde %d eranskin + Ezin izan da gorde %d eranskin + + "MMSko eranskina gordeta dago" + "Ezarpenak" + "Artxibatuta" + "Itxi" + "MMS" + "Ezarpen aurreratuak" + "Araztu" + "Jakinarazpenak" + "Soinua" + "Isilik" + "Egin dar-dar" + "Blokeatuta" + "SMSak entregatzearen txostenak" + "Eskatu bidaltzen duzun SMS bakoitzaren entrega-txostena" + "Eskuratu automatikoki" + "Eskuratu MMS mezuak automatikoki" + "Eskuratu automatikoki ibiltaritzan" + "Eskuratu MMS mezuak automatikoki ibiltaritzan" + "Talde-mezularitza" + "Erabili MMSak mezu bakarra bidaltzeko hartzaile ugari daudenean" + "SMS mezuetarako aplikazio lehenetsia" + "SMS mezuetarako aplikazio lehenetsia" + + + "Zure telefono-zenbakia" + "Ezezaguna" + "Irteerako mezuen soinuak" + "Irauli SMSak" + "Irauli jasotako SMS mezuen prozesatu gabeko datuak kanpoko memoriako fitxategira" + "Irauli MMSak" + "Irauli jasotako MMS mezuen prozesatu gabeko datuak kanpoko memoriako fitxategira" + "Hari gabeko alertak" + "Mezuaren aukerak" + "Kopiatu testua" + "Ikusi xehetasunak" + "Ezabatu" + "Birbidali" + "Mezuaren xehetasunak" + "Mota: " + "Testu-mezua" + "Multimedia-mezua" + "Igorlea: " + "Hartzailea: " + "Bidaltze-data: " + "Jasotze-data: " + "Gaia: " + "Tamaina: " + "Lehentasuna: " + "SIM txartela: " + "Handia" + "Normala" + "Txikia" + "%s. SIM txartela" + "Igorlearen helbidea ezkutatuta badago" + "Ezin da mezua bidali eranskinak kargatzen ari diren bitartean." + "Ezin da kargatu eranskina. Saiatu berriro." + "Sarea ez dago prest. Saiatu berriro." + "Ezabatu testua" + "Aldatu testu eta zenbaki moduen artean" + "Gehitu parte-hartzaile gehiago" + "Berretsi parte-hartzaileak" + "Hasi elkarrizketa bat" + "Hautatu elementu hau" + "Erreproduzitu bideoa" + "Jendea eta aukerak" + "Araztu" + "Jendea eta aukerak" + "Orokorra" + "Elkarrizketa honetako jendea" + "Deitu" + "Bidali mezua" + "Bidali mezua <br/><small>%s txarteletik</small>" + + Bidali argazkiak + Bidali argazkia + + + Bidali audioak + Bidali audioa + + + Bidali bideoak + Bidali bideoa + + + Bidali kontaktu-txartelak + Bidali kontaktu-txartela + + + Bidali eranskinak + Bidali eranskina + + + %d eranskin bidaltzeko prest daude + Eranskin bat bidaltzeko prest dago + + "Bidali iritzia" + "Ikusi Google Play Store dendan" + "Bertsioari buruzko informazioa" + "%1$s bertsioa" + "Kode irekiko lizentziak" + "Jakinarazpenak" + "Eranskinen mugara iritsi zara" + "Ezin izan da kargatu eranskina." + "Kontaktuetan gehitu nahi duzu?" + "Gehitu kontaktua" + "Gaia" + "Gaia: " + "%s%s" + "Kontaktu-txartela kargatzen" + "Ezin izan da kargatu kontaktu-txartela" + "Ikusi kontaktu-txartela" + + %d kontaktu + %d kontaktu + + "Kontaktu-txartelak" + "Urtebetetzea" + "Oharrak" + "Birbidali mezua" + "Erantzun" + "+1" + "+%d" + "SMSak desgaituta" + "Mezuak bidaltzeko, ezarri Mezuak aplikazioa SMS mezuetarako aplikazio lehenetsi gisa" + "Ezarri Mezuak aplikazioa SMS mezuetarako aplikazio lehenetsi gisa" + "Aldatu" + "Mezuak jasotzeko, ezarri Mezuak aplikazioa SMS mezuetarako aplikazio lehenetsi gisa" + "Ez da hautatu SIM txartel hobetsirik SMS mezuak bidaltzeko" + "Gailuaren jabeak ez du aplikazio hau erabiltzea baimendu." + "Ados" + "Parte-hartzaile gehiegi daude elkarrizketan" + + Kontaktuek ez dute balio + Kontaktuak ez du balio + + "Ezin izan da kargatu kamerako irudia" + "Zu: " + "%s: " + "Zirriborroa" + "Elkarrizketak hasten dituzunean, hemen agertuko dira" + "Artxibatutako elkarrizketak hemen ageri dira" + "Elkarrizketak kargatzen…" + "Argazkia" + "Audio-klipa" + "Bideoa" + "Kontaktu-txartela" + "MMS" + "Desegin" + "Saiatu berriro" + "Mezu berria hasteko, idatzi kontaktu baten izena edo telefono-zenbakia." + "Blokeatu" + "Blokeatu %s" + "Desblokeatu %s" + "%s blokeatu nahi duzu?" + "Zenbaki horren mezuak jasotzen jarraituko duzu, baina ez duzu horren jakinarazpenik jasoko aurrerantzean. Elkarrizketa artxibatu egingo da." + "Blokeatutako kontaktuak" + "DESBLOKEATU" + "Blokeatutako kontaktuak" + "Aukeratu irudia dokumentuen liburutegitik" + "Mezua bidaltzen" + "Mezua bidali da" + "Mugikorreko datuak desaktibatuta daude. Egiaztatu ezarpenak." + "Ezin dira bidali mezuak hegaldi moduan" + "Ezin izan da bidali mezua" + "Mezua deskargatu da" + "Mugikorreko datuak desaktibatuta daude. Egiaztatu ezarpenak." + "Ezin da deskargatu mezua hegaldi moduan" + "Ezin izan da deskargatu mezua" + "Zero" + "Bat" + "Bi" + "Hiru" + "Lau" + "Bost" + "Sei" + "Zazpi" + "Zortzi" + "Bederatzi" + "Ezin da bidali mezua %1$s operadorearekin. Errorea: %2$d" + "Ezin da bidali mezua operadore ezezagunarekin. Errorea: %1$d" + "Fwd: %s" + "Ez da bidali mezua: zerbitzua ez dago aktibatuta sarean" + "Mezua ez da bidali. Helmugaren helbideak ez du balio." + "Mezua ez da bidali. Ez du balio." + "Mezua ez da bidali. Edukia ez da onartzen." + "Mezua ez da bidali. Ez da onartzen." + "Ez da bidali mezua: handiegia da" + "Mezu berria" + "Ikusi" + "Irudia" + "Ezin izan da aurkitu aplikazio egokia" + "Kendu hartzailea" + "Mezu berria" + "Utzi" + "Editatu sarbide-puntua" + "Ezarri gabe" + "Izena" + "APN" + "MMSC" + "MMS proxya" + "MMS ataka" + "MCC" + "MNC" + "APN mota" + "Ezabatu APNa" + "APN berria" + "Gorde" + "Baztertu" + "\"Izena\" eremua ezin da hutsik utzi." + "\"APN\" eremua ezin da hutsik utzi." + "\"MCC\" eremuak 3 digitu izan behar ditu." + "\"MNC\" eremuak 2 edo 3 digitu izan behar ditu." + "APN ezarpen lehenetsiak leheneratzen." + "Berrezarri ezarpen lehenetsiak" + "APN ezarpen lehenetsiak berrezarri dira." + "Izengabea" + "Sarbide-puntuen izenak" + "APNak" + "APN berria" + "Erabiltzaile honek ezin ditu APN ezarpenak atzitu" + "Arbelean kopiatu nahi duzu?" + "Kopiatu" + "%s txartelean" + "Orokorra" + "Ezarpen aurreratuak" + "Ezarpen orokorrak" + "Ezarpen aurreratuak" + "\"%s\" SIM txartela" + "Bidali SMS mezu bana hartzaile guztiei. Zuk bakarrik jasoko dituzu erantzunak" + "Bidali MMS mezu bakarra hartzaile guztiei" + "Zenbaki ezezaguna" + "Mezu berria" + "Mezu berria." + "SIM hautatzailea" + "%1$s hautatuta, SIM hautatzailea" + "Editatu gaia" + "Hautatu SIM txartela edo editatu gaia" + "Eduki sakatuta audioa grabatzeko" + "Hasi elkarrizketa bat" + "Mezuak" + "Mezuak aplikazioko zerrenda" + "Mezuak" + "Mezu berria" + "Elkarrizketa-zerrenda" + "Elkarrizketak kargatzen" + "Mezuak kargatzen" + "Ikusi elkarrizketa gehiago" + "Ikusi mezu gehiago" + "Elkarrizketa ezabatu egin da" + "Ezabatu egin da elkarrizketa. Ukitu hau Mezuak aplikazioko beste elkarrizketa bat erakusteko." + "Blokeatu da" + "Desblokeatu da" + "Toki gutxi geratzen da. Agian ez dira gordeko datu batzuk." + "Hautatu eranskinak" + "Berretsi hautapena" + "%d hautatuta" + "Kendu eranskin bat edo gehiago, eta saiatu berriro." + "Hala eta guztiz ere, mezua bidaltzen saia zaitezke, baina agian ez da entregatuko, eranskinetako batzuk kendu ezean." + "Bideo bakarra bidal dezakezu mezu bakoitzeko. Kendu gainerako bideoak eta saiatu berriro." + "Mezuak aplikazioak ezin izan du eranskina kargatu." + "Bidali halere" + "Ezin izan da hasi elkarrizketa" + "%1$s (%2$s)" + "%s hautatu da" + diff --git a/res/values-fa/arrays.xml b/res/values-fa/arrays.xml new file mode 100644 index 0000000..01b6f3e --- /dev/null +++ b/res/values-fa/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "بدون موضوع" + "بدون موضوع" + + + "بله" + "خیر" + "تأیید" + "هه‌هه" + "سپاسگزارم" + "موافقم" + "خوب" + "در راهم" + "بسیار خوب، بگذارید بعداً با شما تماس بگیرم" + ":)" + ":(" + + diff --git a/res/values-fa/strings.xml b/res/values-fa/strings.xml new file mode 100644 index 0000000..6a78532 --- /dev/null +++ b/res/values-fa/strings.xml @@ -0,0 +1,519 @@ + + + + + "پیام‌رسانی" + "پیام‌رسانی" + "انتخاب مکالمه" + "تنظیمات" + "ارسال پیام" + "افزودن پیوست" + "راهنما" + "خوش آمدید" + "رد شدن" + "‏بعدی >" + "بعدی" + "خروج" + "‏تنظیمات >" + "تنظیمات" + "«پیام‌رسانی» به مجوز دسترسی به پیامک، تلفن و مخاطبین نیاز دارد." + "‏می‌توانید مجوزها را در تنظیمات > برنامه‌ها > پیام‌رسانی > مجوزها تغییر دهید." + "می‌توانید مجوزها را در تنظیمات، برنامه‌ها، پیام‌رسانی، مجوزها تغییر دهید." + "مکرر" + "همه مخاطبین" + "ارسال به %s" + "گرفتن عکس یا ویدیو" + "انتخاب تصاویر از این دستگاه" + "ضبط صدا" + "انتخاب عکس" + "رسانه انتخاب شده است." + "رسانه انتخاب نشده است." + "%d مورد انتخاب شد" + "تاریخ تصویر %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "تصویر" + "ضبط صدا" + "اشتراک‌گذاری" + "هم‌اکنون" + "اکنون" + + %d دقیقه + %d دقیقه + + + %d ساعت + %d ساعت + + + %d روز + %d روز + + + %d هفته + %d هفته + + + %d ماه + %d ماه + + + %d سال + %d سال + + "پیام رده ۰" + "ذخیره‌" + "فضای ذخیره‌سازی دستگاه کم است. «پیام‌رسانی» برای آزاد کردن فضا، پیام‌های قدیمی‌تر را به‌طور خودکار پاک می‌کند." + "فضای ذخیره‌سازی رو به پایان است" + "شاید تا وقتی فضای بیشتری در دستگاهتان در دسترس نباشد، «پیام‌رسانی» پیام ارسال یا دریافت نکند." + "فضای ذخیره‌سازی پیامک کم است. ممکن است لازم باشد پیام‌ها را حذف کنید." + "شماره تلفنتان را تأیید کنید" + "با انجام این مرحله یک‌باره مطمئن می‌شوید «پیام‌رسانی» پیام‌های گروهی شما را به‌درستی تحویل می‌دهد." + "شماره تلفن" + "حذف همه پیام‌های دارای رسانه" + "حذف پیام‌های قدیمی‌تر از %s" + "حذف خودکار پیام‌های قدیمی‌تر از %s" + "نادیده گرفتن" + "همه پیام‌های دارای رسانه حذف شوند؟" + "پیام‌های قدیمی‌تر از %s حذف شوند؟" + "پیام‌های قدیمی‌تر از %s حذف شود و حذف خودکار روشن شود؟" + "%s گفت" + "گفتید" + "پیام از %s:" + "پیامی ارسال کردید" + "در حال ارسال…" + "ارسال نشد. برای امتحان مجدد لمس کنید." + "ارسال نشد. در حال امتحان مجدد…" + "ارسال مجدد یا حذف" + "لطفاً یک تماس صوتی با خدمات اضطراری برقرار کنید. در حال حاضر تحویل پیامک شما ممکن نیست." + "انجام نشد" + "‏پیام MMS جدید برای دانلود" + "‏پیام MMS جدید" + "دانلود نشد" + "برای امتحان مجدد لمس کنید" + "برای دانلود لمس کنید" + "دانلود یا حذف" + "در حال دانلود…" + "پیام منقضی شده است یا در دسترس نیست" + "اندازه: %1$s، تاریخ انقضا: %2$s" + "ارسال نمی‌شود. گیرنده معتبر نیست." + "سرویس در شبکه فعال نشده است" + "به علت مشکل شبکه ارسال نشد" + "پیام منقضی شده است یا در دسترس نیست" + "‫(بدون موضوع)‏" + "فرستنده ناشناس" + "تحویل داده شد" + "‫پیام %1$s از %2$s دانلود نشد.‏" + "به دلیل حافظه کم، عملیات پایگاه داده انجام نشد" + "پیام ارسال نشد" + "بعضی از پیام‌ها در «پیام‌رسانی» ارسال نشدند" + + %d پیام در %d مکالمه + %d پیام در %d مکالمه + + "پیام دانلود نشد" + "بعضی از پیام‌ها در «پیام‌رسانی» دانلود نشدند" + + %d پیام در %d مکالمه + %d پیام در %d مکالمه + + "پیام به %1$s ارسال نشد" + "لطفاً یک تماس صوتی با خدمات اضطراری برقرار کنید. در حال حاضر تحویل پیامک شما به %1$s ممکن نیست." + + %d پیام جدید + %d پیام جدید + + "شروع" + "دوربین در دسترس نیست" + "دوربین در دسترس نیست" + "ضبط ویدیو در دسترس نیست" + "رسانه ذخیره نمی‌شود" + "عکس نمی‌گیرد" + "برگشت" + "بایگانی شده" + "حذف" + "بایگانی" + "لغو بایگانی" + "خاموش کردن اعلان‌ها" + "روشن کردن اعلان‌ها" + "افزودن مخاطب" + "دانلود" + "ارسال" + "حذف" + "این پیام حذف شود؟" + "این عملکرد قابل واگرد نیست." + "حذف" + + این مکالمه حذف شود؟ + این مکالمه‌ها حذف شود؟ + + "حذف" + "لغو" + "به" + "انتخاب چند تصویر" + "تأیید انتخاب" + "+%d" + "صدا ضبط نمی‌شود. دوباره امتحان کنید." + "صدا پخش نمی‌شود. دوباره امتحان کنید." + "صدا ذخیره نشد. دوباره امتحان کنید." + "لمس کنید و نگه‌دارید" + "، " + " " + ": " + " " + "تصویر" + "کلیپ صوتی" + "ویدیو" + "کارت ویزیت" + "دانلود" + "پاسخ از طریق پیامک" + "‏پاسخ از طریق MMS" + "پاسخ" + + %d شرکت‌کننده + %d شرکت‌کننده + + "من" + "مخاطب مسدود و بایگانی شد" + "انسداد مخاطب برطرف شد و از بایگانی خارج شد" + "%d بایگانی شد" + "%d از بایگانی خارج شد" + "اعلان‌ها خاموش شد" + "اعلان‌ها روشن شد" + "همه چیز تنظیم شد. ارسال را دوباره لمس کنید." + "«پیام‌رسانی» با موفقیت به‌عنوان برنامه پیش‌فرض پیامک تنظیم شد." + + صرف‌‌نظر از پیوست + صرف‌‌نظر از پیوست‌ها + + "پیوست صوتی" + "پخش پیوست صوتی" + "توقف موقت" + "پیام از %s: ‏%s." + "پیام ناموفق از %s: ‏%s. زمان: %s." + "پیام از %s: ‏%s. زمان: %s." + "پیام ارسال نشده به %s: ‏%s. زمان: %s." + "ارسال پیام به %s: ‏%s. زمان: %s." + "پیام ناموفق به %s: %s. زمان: %s." + "پیام به %s: ‏%s. زمان: %s." + "پیام ناموفق از %s: ‏%s. زمان: %s. ‏%s." + "پیام از %s: ‏%s. زمان: %s. ‏%s." + "پیام ارسال نشده به %s: ‏%s. زمان: %s." + "ارسال پیام به %s: ‏%s. زمان: %s." + "پیام ناموفق به %s: %s. زمان: %s." + "پیام به %s: ‏%s. زمان: %s." + "پیام ناموفق. برای امتحان مجدد، لمس کنید." + "مکالمه با %s" + "حذف موضوع" + "فیلم‌برداری" + "ضبط تصویر ثابت" + "عکس گرفتن" + "شروع فیلم‌برداری" + "تغییر به دوربین تمام صفحه" + "جابه‌جایی بین دوربین جلو و عقب" + "توقف ضبط و پیوست ویدیو" + "توقف ضبط ویدیو" + "عکس‌های «پیام‌رسانی»" + + %d عکس در آلبوم «%s» ذخیره شد + %d عکس در آلبوم «%s» ذخیره شد + + + %d ویدیو در آلبوم «%s» ذخیره شد + %d ویدیو در آلبوم «%s» ذخیره شد + + + %d پیوست در آلبوم «%s» ذخیره شد + %d پیوست در آلبوم «%s» ذخیره شد + + + %d پیوست در «دانلودها» ذخیره شد + %d پیوست در «دانلودها» ذخیره شد + + + %d پیوست ذخیره شد + %d پیوست ذخیره شد + + + %d پیوست ذخیره نشد + %d پیوست ذخیره نشد + + "‏پیوست MMS ذخیره شد" + "تنظیمات" + "بایگانی شد" + "بستن" + "MMS" + "پیشرفته" + "اشکال‌زدایی" + "اعلان‌ها" + "صدا" + "بی‌صدا" + "لرزش" + "مسدود شده" + "گزارش‌های تحویل پیامک" + "درخواست گزارش تحویل برای هر پیامکی که ارسال می‌کنید" + "بازیابی خودکار" + "‏بازیابی خودکار پیام‌های MMS" + "بازیابی خودکار در طول رومینگ" + "‏بازیابی خودکار MMS هنگام رومینگ" + "پیام‌رسانی گروهی" + "‏در صورت وجود چند گیرنده، برای ارسال یک پیام از MMS استفاده شود" + "برنامه پیامک پیش‌فرض" + "برنامه پیامک پیش‌فرض" + + + "شماره تلفن شما" + "نامشخص" + "صداهای پیام خروجی" + "نسخه‌برداری پیامک" + "نسخه‌برداری اطلاعات خام پیامک دریافتی در فایل فضای ذخیره‌سازی خارجی" + "‏نسخه‌برداری MMS" + "‏نسخه‌برداری از اطلاعات خام MMS دریافتی در فایل فضای ذخیره‌سازی خارجی" + "هشدارهای بی‌سیم" + "گزینه‌های پیام" + "کپی کردن نوشتار" + "مشاهده جزئیات" + "حذف" + "بازارسال" + "جزئیات پیام" + "نوع: " + "پیامک" + "پیام چندرسانه‌ای" + "از: " + "به: " + "ارسال شده: " + "دریافت شده: " + "موضوع: " + "اندازه: " + "اولویت: " + "سیم‌کارت: " + "زیاد" + "عادی" + "کم" + "سیم‌کارت %s" + "مخفی کردن آدرس فرستنده" + "نمی‌توانید در حال بارگیری پیوست‌ها، پیام ارسال کنید." + "پیوست بارگیری نمی‌شود. دوباره امتحان کنید." + "شبکه آماده نیست. دوباره امتحان کنید." + "حذف نوشتار" + "جابه‌جایی بین وارد کردن نوشتار و عدد" + "افزودن شرکت‌کننده‌های بیشتر" + "تأیید شرکت‌کنندگان" + "شروع مکالمه جدید" + "انتخاب این مورد" + "پخش ویدیو" + "افراد و گزینه‌ها" + "اشکال‌زدایی" + "افراد و گزینه‌ها" + "کلی" + "افراد در این مکالمه" + "برقراری تماس تلفنی" + "ارسال پیام" + "‏ارسال پیام<br/><small>از %s</small>" + + ارسال عکس + ارسال عکس‌ها + + + ارسال فایل صوتی + ارسال فایل‌های صوتی + + + ارسال ویدیو + ارسال ویدیوها + + + ارسال کارت تماس + ارسال کارت‌های تماس + + + ارسال پیوست + ارسال پیوست‌ها + + + %d پیوست آماده ارسال است + %d پیوست آماده ارسال است + + "ارسال بازخورد" + "‏نمایش در فروشگاه Google Play" + "اطلاعات نسخه" + "‏نسخه %1$s" + "مجوزهای منبع باز" + "اعلان‌ها" + "به حد مجاز پیوست رسیدید" + "پیوست بارگیری نشد." + "به مخاطبین افزوده شود؟" + "افزودن مخاطب" + "موضوع" + "موضوع: " + "%s%s" + "در حال بارگیری کارت مخاطب" + "کارت مخاطب بارگیری نشد" + "مشاهده کارت مخاطب" + + %d مخاطب + %d مخاطب + + "کارت‌های مخاطب" + "تاریخ تولد" + "یادداشت‌ها" + "بازارسال پیام" + "پاسخ" + "1+" + "+%d" + "پیامک غیرفعال شد" + "برای ارسال، «پیام‌رسانی» را به‌عنوان برنامه پیش‌فرض پیامک تنظیم کنید" + "«پیام‌رسانی» را به‌عنوان برنامه پیش‌فرض پیامک تنظیم کنید" + "تغییر" + "برای دریافت پیام، «پیام‌رسانی» را به‌عنوان برنامه پیش‌فرض پیامک تنظیم کنید" + "هیچ سیم‌کارت برگزیده‌ای برای ارسال پیامک‌ها انتخاب نشده است" + "مالک دستگاه این برنامه را مجاز نکرده است." + "تأیید" + "شرکت‌کننده‌های خیلی زیادی در مکالمه وجود دارد" + + مخاطب نامعتبر + مخاطبین نامعتبر + + "تصویر دوربین بارگیری نشد" + "شما: " + "%s: " + "پيش‌نويس" + "وقتی مکالمه جدیدی را شروع کنید، آن را در این لیست مشاهده می‌کنید" + "مکالمات بایگانی‌شده در اینجا نشان داده می‌شود" + "در حال بارگیری مکالمات…" + "تصویر" + "کلیپ صوتی" + "ویدیو" + "کارت ویزیت" + "MMS" + "واگرد" + "امتحان مجدد" + "نام یا شماره تلفن مخاطب را برای شروع یک پیام جدید وارد کنید" + "مسدود کردن" + "مسدود کردن %s" + "رفع انسداد %s" + "%s مسدود شود؟" + "همچنان از این شماره پیام‌ها را دریافت می‌کنید، اما دیگر به شما اطلاع داده نمی‌شود. این مکالمه بایگانی می‌شود." + "مخاطبین مسدود شده" + "رفع انسداد" + "مخاطبین مسدود شده" + "انتخاب تصویر از کتابخانه سند" + "در حال ارسال پیام" + "پیام ارسال شد" + "داده شبکه تلفن همراه خاموش است. تنظیماتتان را بررسی کنید." + "در حالت هواپیما پیام‌ها ارسال نمی‌شوند" + "پیام ارسال نشد" + "پیام دانلود شد" + "داده سلولی خاموش است. تنظیماتتان را بررسی کنید." + "پیام‌ در حالت هواپیما دانلود نمی‌شود" + "پیام دانلود نشد" + "صفر" + "یک" + "دو" + "سه" + "چهار" + "پنج" + "شش" + "هفت" + "هشت" + "نه" + "ارسال پیام با %1$s امکان‌پذیر نیست، خطای %2$d" + "ارسال پیام با شرکت مخابراتی ناشناس امکان‌پذیر نیست، خطای %1$d" + "بازارسال: %s" + "پیام ارسال نشد: سرویس در شبکه فعال نیست" + "پیام ارسال نشد: آدرس مقصد نامعتبر است" + "پیام ارسال نشد: پیام نامعتبر است" + "پیام ارسال نشد: محتوا پشتیبانی نمی‌شود" + "پیام ارسال نشد: پیام پشتیبانی نمی‌شود" + "پیام ارسال نشد: بیش از حد بزرگ است" + "پیام جدید" + "مشاهده" + "تصویر" + "برنامه مناسبی یافت نشد" + "حذف گیرنده" + "پیام جدید" + "لغو" + "ویرایش نقطه دستیابی" + "تنظیم نشده" + "نام" + "نام نقطه دستیابی" + "MMSC" + "پروکسی فراپیام" + "درگاه فراپیام" + "MCC" + "MNC" + "نوع نام نقطه دستیابی" + "حذف نام نقطه دستیابی" + "نام نقطه دستیابی جدید" + "ذخیره‌" + "صرف‌نظر کردن" + "فیلد نام نباید خالی باشد." + "نام نقطه دستیابی نباید خالی باشد." + "‏فیلد MCC باید ۳ رقمی باشد." + "‏فیلد MNC باید ۲ یا ۳ رقمی باشد." + "درحال بازیابی تنظیمات پیش‌فرض نام نقطه دستیابی." + "بازنشانی به پیش‌فرض" + "تنظیمات پیش‌فرض نام نقطه دستیابی بازنشانی شد." + "بدون عنوان" + "نام‌های نقطه دسترسی" + "نام‌های نقاط دستیابی" + "نام نقطه دستیابی جدید" + "تنظیمات نام تقطه دسترسی برای این کاربر در دسترس نیست" + "کپی در بریده‌دان؟" + "کپی" + "به %s" + "کلی" + "پیشرفته" + "تنظیمات کلی" + "تنظیمات پیشرفته" + "سیم کارت «%s»" + "ارسال پیامک‌های جداگانه به همه گیرندگان. فقط شما جواب‌ها را دریافت می‌کنید" + "‏ارسال یک MMS تکی به همه گیرندگان" + "شماره ناشناس" + "پیام جدید" + "پیام جدید." + "انتخاب سیم" + "%1$s انتخاب شد، انتخاب‌گر سیم‌کارت" + "ویرایش موضوع" + "انتخاب سیم کارت یا ویرایش موضوع" + "برای ضبط کردن صدا، لمس کنید و نگه‌دارید" + "شروع مکالمه جدید" + "پیام‌رسانی" + "فهرست «پیام‌رسانی»" + "پیام‌رسانی" + "پیام جدید" + "فهرست مکالمات" + "در حال بارگیری مکالمات" + "در حال بارگیری پیام‌ها" + "مشاهده مکالمات بیشتر" + "مشاهده پیام‌های دیگر" + "مکالمه حذف شد" + "مکالمه حذف شد. لمس کنید تا مکالمه «پیام‌رسانی» دیگری نشان داده شود." + "مسدود شد" + "گشوده شد" + "فضای ذخیره‌سازی کم است. ممکن است برخی از اطلاعات از دست بروند." + "انتخاب پیوست‌ها" + "تأیید انتخاب" + "%d مورد انتخاب شد" + "لطفاً یک یا چند پیوست را بردارید و دوباره امتحان کنید." + "می‌توانید سعی کنید پیامتان را ارسال کنید اما شاید تا وقتی که یک یا چند پیوست را برندارید، تحویل داده نشود." + "فقط می‌توانید یک ویدیو در هر پیام ارسال کنید. لطفاً ویدیوهای اضافی را حذف کنید و دوباره امتحان نمایید." + "«پیام‌رسانی» پیوست را بارگیری نکرد." + "در هر حال فرستاده شود" + "مکالمه را نمی‌توان شروع کرد" + "%1$s‏ (%2$s)" + "%s انتخاب شد" + diff --git a/res/values-fi/arrays.xml b/res/values-fi/arrays.xml new file mode 100644 index 0000000..95d4f23 --- /dev/null +++ b/res/values-fi/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "ei aihetta" + "ei aihetta" + + + "Kyllä" + "Ei" + "OK" + "Hehe" + "Kiitos" + "Olen samaa mieltä" + "Kiva" + "Matkalla" + "OK, palaan asiaan" + ":)" + ":(" + + diff --git a/res/values-fi/strings.xml b/res/values-fi/strings.xml new file mode 100644 index 0000000..47ef00f --- /dev/null +++ b/res/values-fi/strings.xml @@ -0,0 +1,519 @@ + + + + + "Viestit" + "Viestit" + "Valitse keskustelu" + "Asetukset" + "Lähetä viesti" + "Lisää liite" + "Ohje" + "Tervetuloa" + "Ohita" + "Seuraava >" + "Seuraava" + "Sulje" + "Asetukset >" + "Asetukset" + "Viestit tarvitsee käyttöoikeuden tekstiviesteihin, puhelimeen ja yhteystietoihin." + "Voit muuttaa käyttöoikeuksia kohdassa Asetukset > Sovellukset > Viestit > Käyttöoikeudet." + "Voit muuttaa käyttöoikeuksia kohdassa Asetukset, Sovellukset, Viestit, Käyttöoikeudet." + "Usein käytetyt" + "Yhteystiedot" + "Lähetä numeroon %s" + "Tallenna kuva tai video" + "Valitse kuvia tästä laitteesta" + "Tallenna ääntä" + "Valitse valokuva" + "Media on valittu." + "Median valinta on poistettu." + "%d valittu" + "kuva %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "kuva" + "Tallenna ääntä" + "Jaa" + "Juuri nyt" + "Nyt" + + %d min + %d minuutti + + + %d tuntia + %d tunti + + + %d päivää + %d päivä + + + %d viikkoa + viikko + + + %d kuukautta + kuukausi + + + %d vuotta + vuosi + + "Luokan 0 viesti" + "Tallenna" + "Laitteen tila on vähissä. Viestit poistaa automaattisesti vanhempia viestejä vapauttaakseen tilaa." + "Tallennustila vähissä" + "Viestit ei ehkä lähetä tai vastaanota viestejä ennen kuin laitteessa on enemmän vapaata tilaa." + "Tekstiviestien tallennustila on vähissä. Harkitse viestien poistamista." + "Vahvista puhelinnumerosi" + "Tekemällä tämän kertaluontoisen vaiheen varmistat, että Viestit toimittaa ryhmäviestisi oikein." + "Puhelinnumero" + "Poista kaikki mediaa sisältävät viestit" + "Poista viestit, joiden ikä on yli %s" + "Poista automaattisesti viestit, jotka ovat vanhempia kuin %s" + "Ohita" + "Poistetaanko kaikki mediaa sisältävät viestit?" + "Poistetaanko viestit, jotka ovat vanhempia kuin %s?" + "Poistetaanko viestit, jotka ovat vanhempia kuin %s, ja otetaanko automaattinen poisto käyttöön?" + "%s sanoi" + "Sinä sanoit" + "Viesti käyttäjältä %s" + "Lähetit viestin" + "Lähetetään…" + "Ei lähetetty. Yritä uudelleen koskettamalla." + "Ei lähetetty. Yritetään uudelleen…" + "Lähetä uudelleen tai poista" + "Ota yhteyttä hätäpalveluihin soittamalla äänipuhelu. Tekstiviestiäsi ei voida toimittaa tällä hetkellä." + "Epäonnistui" + "Uusi multimediaviesti ladattavissa" + "Uusi multimediaviesti" + "Lataus epäonnistui" + "Yritä uudelleen koskettamalla" + "Lataa koskettamalla" + "Lataa tai poista" + "Ladataan…" + "Viesti on vanhentunut tai ei ole käytettävissä" + "koko: %1$s, vanhenee: %2$s" + "Ei voi lähettää. Vastaanottaja ei kelpaa." + "Palvelua ei ole aktivoitu verkossa" + "Ei voitu lähettää verkkovian takia" + "Viesti on vanhentunut tai ei ole käytettävissä" + "(Ei aihetta)" + "Tuntematon lähettäjä" + "Toimitettu" + "Lähettäjän %2$s viestin %1$s lataaminen epäonnistui." + "Tietokantatoimintoa ei voi suorittaa: muisti on vähissä" + "Viestiä ei lähetetty" + "Joitakin viestejä ei lähetetty Viesteissä." + + %d viestiä %d keskustelussa + %d viesti yhdessä keskustelussa + + "Viestiä ei ladattu." + "Joitakin viestejä ei ladattu Viesteissä." + + %d viestiä %d keskustelussa + %d viesti yhdessä keskustelussa + + "Viestiä numeroon %1$s ei lähetetty." + "Ota yhteyttä hätäpalveluihin soittamalla äänipuhelu. Tekstiviestiäsi numeroon %1$s ei voida toimittaa tällä hetkellä." + + %d uutta viestiä + Uusi viesti + + "Käynnistä" + "Kamera ei käytettävissä" + "Kamera ei käytettävissä" + "Videon kaappaus ei käytettävissä" + "Mediaa ei voi tallentaa" + "Kuvan ottaminen ei onnistu." + "Edellinen" + "Arkistoitu" + "Poista" + "Arkistoi" + "Poista arkistosta" + "Poista ilmoitukset käytöstä" + "Ota ilmoitukset käyttöön" + "Lisää kontakti" + "Lataa" + "Lähetä" + "Poista" + "Poistetaanko tämä viesti?" + "Tätä toimintoa ei voi kumota." + "Poista" + + Poistetaanko nämä keskustelut? + Poistetaanko tämä keskustelu? + + "Poista" + "Peruuta" + "Vastaanottaja" + "Valitse useita kuvia" + "Vahvista valinta" + "+ %d" + "Ääntä ei voi tallentaa. Yritä uudelleen." + "Ääntä ei voi toistaa. Yritä uudelleen." + "Ääntä ei voitu tallentaa. Yritä uudelleen." + "Kosketa pitkään" + ", " + " " + ": " + " " + "Kuva" + "Äänileike" + "Video" + "Yhteystietokortti" + "Lataa" + "Vastaa tekstiviestitse" + "Vastaa MMS-viestillä" + "Vastaa" + + %d osallistujaa + %d osallistuja + + "Minä" + "Yhteystieto estetty ja arkistoitu" + "Yhteystiedon esto on poistettu, ja se on poistettu arkistosta." + "%d arkistoitua" + "%d poistettiin arkistosta" + "Ilmoitukset on poistettu käytöstä." + "Ilmoitukset on otettu käyttöön." + "Kaikki valmiina. Kosketa Lähetä uudelleen." + "Viestit asetettiin oletustekstiviestisovellukseksi." + + Hylkää liitteet + Hylkää liite + + "Ääniliite" + "Toista ääniliite" + "Tauko" + "Viesti lähettäjältä %s: %s." + "Epäonnistunut viesti lähettäjältä %s: %s. Aika: %s." + "Viesti lähettäjältä %s: %s. Aika: %s." + "Lähettämätön viesti vastaanottajalle %s: %s. Aika: %s." + "Lähetetään viestiä vastaanottajalle %s: %s. Aika: %s." + "Epäonnistunut viesti vastaanottajalle %s: %s. Aika: %s." + "Viesti vastaanottajalle %s: %s. Aika: %s." + "Epäonnistunut viesti lähettäjältä %s: %s. Aika: %s. %s." + "Viesti lähettäjältä %s: %s. Aika: %s. %s." + "Lähettämätön viesti vastaanottajalle %s: %s. Aika: %s." + "Lähetetään viestiä vastaanottajalle %s: %s. Aika: %s." + "Epäonnistunut viesti vastaanottajalle %s: %s. Aika: %s." + "Viesti vastaanottajalle %s: %s. Aika: %s." + "Viesti epäonnistui. Yritä uudelleen koskettamalla." + "Keskustelu henkilöiden %s kanssa" + "Poista aihe" + "Tallenna video" + "Ota kuva" + "Ota kuva" + "Aloita videon tallentaminen" + "Siirry koko näytön kameraan" + "Vaihda etu- ja takakameran välillä" + "Lopeta tallennus ja liitä video" + "Lopeta videon tallentaminen" + "Viestien valokuvat" + + %d valokuvaa tallennettu albumiin %s + %d valokuva tallennettu albumiin %s + + + %d videota tallennettu albumiin %s + %d video tallennettu albumiin %s + + + %d liitettä tallennettu albumiin %s + %d liite tallennettu albumiin %s + + + %d liitettä tallennettu Lataukset-kansioon + %d liite tallennettu Lataukset-kansioon + + + %d liitettä tallennettiin + %d liite tallennettiin. + + + %d liitteen tallentaminen epäonnistui. + %d liitteen tallentaminen epäonnistui. + + "Multimedialiite tallennettiin" + "Asetukset" + "Arkistoitu" + "Sulje" + "Multimediaviesti" + "Lisäasetukset" + "Vianetsintä" + "Ilmoitukset" + "Ääni" + "Äänetön" + "Värinä" + "Estetty" + "Tekstiviestin lähetystiedot" + "Pyydä lähetystiedot kaikista lähettämistäsi tekstiviesteistä" + "Automaattilataus" + "Lataa MMS-viestit automaattisesti" + "Automaattilataus roaming-tilassa" + "Hae MMS-viestit automaattisesti roaming-tilassa." + "Ryhmäviestit" + "Jaa yksi multimediaviesti useille vastaanottajille" + "Oletusarvoinen tekstiviestisovellus" + "Oletusarvoinen tekstiviestisovellus" + + + "Puhelinnumerosi" + "Tuntematon" + "Lähtevien viestien äänet" + "Tekstiviestivedos" + "Vedosta vastaanotetut tekstiviestien raakatiedot ulkoiseen tallennustiedostoon" + "MMS-vedos" + "Vedosta vastaanotetut MMS-raakatiedot ulkoiseen tallennustiedostoon" + "Langattoman verkon ilmoitukset" + "Viestiasetukset" + "Kopioi teksti" + "Näytä tiedot" + "Poista" + "Lähetä edelleen" + "Viestin tiedot" + "Tyyppi: " + "Tekstiviesti" + "Multimediaviesti" + "Lähettäjä: " + "Vastaanottaja: " + "Lähetetty: " + "Vastaanotettu: " + "Aihe: " + "Koko: " + "Tärkeys: " + "SIM-kortti: " + "Korkea" + "Normaali" + "Matala" + "SIM-kortti %s" + "Piilotettu lähettäjän osoite" + "Viestiä ei voi lähettää liitetiedostojen latauksen aikana." + "Liitettä ei voi ladata. Yritä uudelleen." + "Verkko ei ole valmis. Yritä uudelleen." + "Poista teksti" + "Vaihda tekstin ja numeroiden syötön välillä" + "Lisää osallistujia" + "Vahvista osallistujat" + "Aloita uusi keskustelu" + "Valitse tämä" + "Katso video" + "Henkilöt ja asetukset" + "Vianetsintä" + "Henkilöt ja asetukset" + "Yleiset" + "Keskustelun osallistujat" + "Soita puhelu" + "Lähetä viesti" + "Lähetä viesti<br/><small>kortilta %s</small>" + + Lähetä kuvia + Lähetä kuva + + + Lähetä äänitiedostoja + Lähetä äänitiedosto + + + Lähetä videoita + Lähetä video + + + Lähetä yhteystietokortteja + Lähetä yhteystietokortti + + + Lähetä liitteitä + Lähetä liite + + + %d liitettä valmiina lähetettäväksi + Yksi liite valmiina lähetettäväksi + + "Lähetä palautetta" + "Näytä Google Play Kaupassa" + "Versiotiedot" + "Versio %1$s" + "Avoimen lähdekoodin käyttöluvat" + "Ilmoitukset" + "Liitetiedostojen enimmäisraja on saavutettu." + "Liitetiedoston lataaminen epäonnistui." + "Lisätäänkö yhteystietoihin?" + "Lisää yhteystieto" + "Aihe" + "Aihe: " + "%s%s" + "Ladataan yhteystietokorttia" + "Yhteystietokorttia ei voitu ladata:" + "Näytä yhteystietokortti" + + %d yhteystietoa + %d yhteystieto + + "Yhteystietokortit" + "Syntymäpäivä" + "Muistiinpanot" + "Lähetä viesti edelleen" + "Vastaa" + "+1" + "+%d" + "Tekstiviestit on poistettu käytöstä" + "Voit lähettää valittuasi Viestit oletustekstiviestisovellukseksi." + "Aseta Viestit oletustekstiviestisovellukseksi." + "Vaihda" + "Voit vastaanottaa viestejä valittuasi Viestit oletustekstiviestisovellukseksi." + "Ensisijaista SIM-korttia ei ole valittu tekstiviestien lähettämiselle" + "Laitteen omistaja ei ole myöntänyt lupaa tälle sovellukselle." + "OK" + "Keskustelussa on liian monta osallistujaa" + + Virheelliset yhteystiedot + Virheellinen yhteystieto + + "Kamerakuvaa ei voitu ladata" + "Sinä: " + "%s: " + "Luonnos" + "Uudet keskustelut on lueteltu tässä" + "Arkistoidut keskustelut näkyvät tässä" + "Ladataan keskusteluja…" + "Kuva" + "Äänileike" + "Video" + "Yhteystietokortti" + "Multimediaviesti" + "Kumoa" + "Yritä uudelleen" + "Aloita uusi viesti antamalla yhteystiedon nimi tai puhelinnumero" + "Estä" + "Estä %s" + "Kumoa esto: %s" + "Estetäänkö %s?" + "Saat edelleen viestejä tästä numerosta, mutta et enää ilmoituksia. Tämä keskustelu arkistoidaan." + "Estetyt yhteystiedot" + "KUMOA ESTO" + "Estetyt yhteystiedot" + "Valitse kuva dokumenttikirjastosta" + "Lähetetään viestiä" + "Viesti lähetetty" + "Matkapuhelindata on kytketty pois. Tarkista asetukset." + "Viestejä ei voi lähettää lentokonetilassa" + "Viestiä ei voitu lähettää" + "Viesti ladattu" + "Matkapuhelindata on kytketty pois. Tarkista asetukset." + "Viestejä ei voi ladata lentokonetilassa." + "Viestin lataaminen epäonnistui." + "Nolla" + "Yksi" + "Kaksi" + "Kolme" + "Neljä" + "Viisi" + "Kuusi" + "Seitsemän" + "Kahdeksan" + "Yhdeksän" + "Viestin lähettäminen operaattorin %1$s kautta ei onnistunut. Virhe %2$d" + "Viestin lähettäminen tuntemattoman operaattorin kautta ei onnistunut. Virhe %1$d" + "Vl: %s" + "Viestiä ei lähetetty: palvelu ei ole käytössä verkossa" + "Viestiä ei lähetetty: virheellinen kohdeosoite" + "Viestiä ei lähetetty: virheellinen viesti" + "Viestiä ei lähetetty: sisältöä ei tueta" + "Viestiä ei lähetetty: viestiä ei tueta" + "Viestiä ei lähetetty: liian suuri" + "Uusi viesti" + "Näytä" + "Kuva" + "Sopivaa sovellusta ei löytynyt" + "Poista vastaanottaja" + "Uusi viesti" + "Peruuta" + "Muokkaa tukiasemaa" + "Ei asetettu" + "Nimi" + "APN" + "MMSC" + "Multimediaviestien välityspalvelin" + "Multimediaviestien portti" + "MCC" + "MNC" + "APN-tyyppi" + "Poista APN" + "Uusi APN" + "Tallenna" + "Hylkää" + "Nimi-kenttä ei voi olla tyhjä." + "APN ei voi olla tyhjä." + "MCC-kentän arvon oltava kolminumeroinen." + "MNC-kentän arvon on oltava kaksi- tai kolminumeroinen." + "Palautetaan APN-oletusasetuksia" + "Palauta oletukseksi" + "APN-oletusasetukset on palautettu." + "Nimetön" + "Tukiasemien nimet" + "APN:t" + "Uusi APN" + "Tämä käyttäjä ei voi käyttää APN-asetuksia" + "Kopioidaanko leikepöydälle?" + "Kopioi" + "vastaanottaja: %s" + "Yleiset" + "Lisäasetukset" + "Yleiset asetukset" + "Lisäasetukset" + "%s SIM" + "Lähetä oma tekstiviesti kullekin vastaanottajalle. Vain sinä saat vastaukset." + "Lähetä yksi MMS-viesti kaikille vastaanottajille" + "Tuntematon numero" + "Uusi viesti" + "Uusi viesti." + "SIM-valitsin" + "%1$s valittu, SIM-valitsin" + "Muokkaa aihetta" + "Valitse SIM tai muokkaa aihetta" + "Nauhoita ääntä koskettamalla pitkään" + "Aloita uusi keskustelu." + "Viestit" + "Viestit-luettelo" + "Viestit" + "Uusi viesti" + "Keskusteluluettelo" + "Ladataan keskusteluja." + "Ladataan viestejä." + "Näytä lisää keskusteluita." + "Näytä lisää viestejä." + "Keskustelu on poistettu." + "Keskustelu poistettu. Näytä toinen Viestit-keskustelu." + "Estetty" + "Esto kumottu" + "Tallennustila on vähissä. Tietoja voi kadota." + "Valitse liitteet" + "Vahvista valinta" + "%d valittu" + "Poista yksi tai useampi liitetiedosto ja yritä uudelleen." + "Voit yrittää lähettää viestin, mutta sitä ei ehkä toimiteta ennen kuin olet poistanut yhden tai useamman liitetiedoston." + "Voit lähettää vain yhden videon viestiä kohden. Poista ylimääräiset videot ja yritä uudelleen." + "Viestit ei pystynyt lataamaan liitetiedostoa." + "Lähetä silti" + "Keskustelun aloittaminen epäonnistui" + "%1$s (%2$s)" + "%s valittu" + diff --git a/res/values-fr-rCA/arrays.xml b/res/values-fr-rCA/arrays.xml new file mode 100644 index 0000000..05d76fb --- /dev/null +++ b/res/values-fr-rCA/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "sans objet" + "aucun objet" + + + "Oui" + "Non" + "OK" + "Hé hé" + "Merci" + "J\'accepte" + "Bien" + "En chemin" + "OK, je te réponds plus tard" + ":)" + ":(" + + diff --git a/res/values-fr-rCA/strings.xml b/res/values-fr-rCA/strings.xml new file mode 100644 index 0000000..239f4af --- /dev/null +++ b/res/values-fr-rCA/strings.xml @@ -0,0 +1,519 @@ + + + + + "Messagerie" + "Messagerie" + "Sélectionner la conversation" + "Paramètres" + "Envoyer un message" + "Ajouter une pièce jointe" + "Aide" + "Bienvenue" + "Ignorer" + "Suivant >" + "Suivant" + "Quitter" + "Paramètres >" + "Paramètres" + "Vous devez accorder à l\'application Messagerie des autorisations d\'accès aux messages, au téléphone et aux contacts." + "Vous pouvez modifier les autorisations dans Paramètres > Applications > Messagerie > Autorisations." + "Vous pouvez modifier les autorisations dans Paramètres > Applications > Messagerie > Autorisations." + "Fréquents" + "Tous les contacts" + "Envoyer à %s" + "Prendre des photos ou filmer une vidéo" + "Choisir des images de cet appareil" + "Enregistrer des fichiers audio" + "Choisir une photo" + "Support sélectionné." + "Support désélectionné." + "%d sélectionné(s)" + "Image : %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "image" + "Enregistrer des fichiers audio" + "Partager" + "À l\'instant" + "Maintenant" + + %d min + %d min + + + %d heure + %d heures + + + %d jour + %d jours + + + %d semaine + %d semaines + + + %d mois + %d mois + + + %d an + %d ans + + "Message de catégorie 0" + "Enregistrer" + "L\'appareil manque d\'espace. Pour en libérer, les messages de l\'application Messagerie les plus anciens seront automatiquement supprimés." + "L\'espace de stockage est presque plein" + "Il est possible que vous ne puissiez pas envoyer ni recevoir de messages dans l\'application Messagerie jusqu\'à ce que votre appareil ait plus d\'espace libre." + "Stockage limité pour les textos. Vous devrez peut-être supprimer des messages." + "Confirmez votre numéro de téléphone" + "Cette étape unique permet de garantir que vos messages de groupe sont correctement envoyés dans Messagerie." + "Numéro de téléphone" + "Supprimer tous les messages avec du contenu multimédia" + "Supprimer les messages datant de plus de %s" + "Suppression automatique des messages de plus de %s" + "Ignorer" + "Supprimer tous les messages avec contenu multimédia?" + "Supprimer les messages datant de plus de %s?" + "Supprimer les messages de plus de %s et activer la suppression automatique?" + "%s a dit" + "Vous avez dit" + "Message de %s" + "Vous avez envoyé un message" + "Envoi en cours..." + "Message non envoyé. Touchez ici pour réessayer." + "Message non envoyé. Nouvelle tentative en cours..." + "Renvoyer ou supprimer" + "Veuillez appeler les services d\'urgence. Impossible d\'envoyer votre texto pour l\'instant." + "Échec" + "Nouveau message multimédia à télécharger" + "Nouveau message multimédia" + "Échec du téléchargement" + "Touchez ici pour réessayer" + "Touchez ici pour lancer le téléchargement." + "Télécharger ou supprimer" + "Téléchargement en cours..." + "Le message a expiré ou n\'est plus disponible." + "taille : %1$s, date d\'expiration : %2$s" + "Impossible d\'envoyer le message. Destinataire non valide." + "Ce service n\'est pas activé sur le réseau" + "Impossible d\'envoyer le message en raison d\'un problème de réseau" + "Le message est expiration ou n\'est plus disponible." + "(Aucun objet)" + "Expéditeur inconnu" + "Distribué" + "Impossible de télécharger le message « %1$s » de l\'utilisateur suivant : %2$s." + "Impossible d\'effectuer cette opération dans la base de données. Mémoire insuffisante." + "Le message n\'a pas été envoyé" + "Certains messages n\'ont pas été envoyés dans Messagerie" + + %d message dans %d conversations + %d messages dans %d conversations + + "Message non téléchargé" + "Certains messages n\'ont pas été téléchargés dans Messagerie" + + %d message dans %d conversations + %d messages dans %d conversations + + "Échec de l\'envoi du message à %1$s" + "Veuillez appeler les services d\'urgence. Impossible d\'envoyer votre texto au numéro %1$s pour l\'instant." + + %d nouveau message + %d nouveaux messages + + "Démarrer" + "L\'appareil photo n\'est pas disponible" + "L\'appareil photo n\'est pas disponible" + "L\'enregistrement vidéo n\'est pas disponible" + "Impossible d\'enregistrer le média" + "Impossible de prendre la photo." + "Précédent" + "Archivée" + "Supprimer" + "Archiver" + "Annuler l\'archivage" + "Désactiver les notifications" + "Activer les notifications" + "Ajouter un contact" + "Télécharger" + "Envoyer" + "Supprimer" + "Supprimer ce message?" + "Cette action ne peut être annulée." + "Supprimer" + + Supprimer cette conversation? + Supprimer ces conversations? + + "Supprimer" + "Annuler" + "À" + "Sélectionner plusieurs images" + "Confirmer la sélection" + "+%d" + "Impossible d\'enregistrer l\'audio. Veuillez réessayer." + "Impossible de lire l\'audio. Veuillez réessayer." + "Impossible d\'enregistrer l\'audio. Veuillez réessayer." + "Maintenez le doigt ici" + ", " + " " + ": " + " " + "Image" + "Extrait audio" + "Vidéo" + "Fiche de contact" + "Télécharger" + "Répondre par texto" + "Rép. m. multim." + "Répondre" + + %d participant + %d participants + + "Moi" + "Contact bloqué et archivé" + "Contact débloqué et archivage annulé" + "%d archivée(s)" + "Archivage annulé de %d" + "Les notifications sont désactivées" + "Les notifications sont activées" + "Tout est prêt. Touchez « Envoyer » de nouveau." + "L\'application Messagerie a été définie comme application de messagerie par défaut." + + Supprimer la pièce jointe + Supprimer les pièces jointes + + "Contenu audio joint" + "Jouer le contenu audio joint" + "Pause" + "Message de %s : %s." + "Échec de la réception du message de %s : %s. Heure : %s." + "Message de %s : %s. Heure : %s." + "Message non envoyé à %s : %s. Heure : %s." + "Envoi du message à %s : %s. Heure : %s." + "Échec de l\'envoi du message à %s : %s. Heure : %s." + "Message à %s : %s. Heure : %s." + "Échec de la réception du message de %s : %s. Heure : %s. %s." + "Message de %s : %s. Heure : %s. %s." + "Message non envoyé à : %s: %s. Heure: %s." + "Envoi du message à %s : %s. Heure : %s." + "Échec de l\'envoi du message à %s : %s. Heure : %s." + "Message à %s : %s. Heure : %s." + "Échec du message. Touchez pour réessayer." + "Conversation avec %s" + "Supprimer l\'objet" + "Filmer une vidéo" + "Capturer une image fixe" + "Prendre une photo" + "Lancer l\'enregistrement vidéo" + "Activer le mode plein écran de l\'appareil photo" + "Basculer entre l\'appareil photo avant et arrière" + "Arrêter l\'enregistrement et joindre la vidéo" + "Arrêter l\'enregistrement vidéo" + "Photos de Messagerie" + + %d photo enregistrée dans l\'album « %s » + %d photos enregistrées dans l\'album « %s » + + + %d vidéo enregistrée dans l\'album « %s » + %d vidéos enregistrées dans l\'album « %s » + + + %d pièce jointe enregistrée dans l\'album « %s » + %d pièces jointes enregistrées dans l\'album « %s » + + + %d pièce jointe enregistrée dans le dossier « Téléchargements » + %d pièces jointes enregistrées dans le dossier « Téléchargements » + + + %d pièce jointe enregistrée + %d pièces jointes enregistrées + + + Impossible d\'enregistrer %d pièce jointe + Impossible d\'enregistrer %d pièces jointes + + "La pièce jointe du message multimédia a été enregistrée" + "Paramètres" + "Archivée" + "Fermer" + "Message multimédia" + "Avancés" + "Déboguer" + "Notifications" + "Son" + "Mode silencieux" + "Vibreur" + "Bloqué" + "Accusés de réception des messages texte" + "Demander un accusé de réception pour chaque texto envoyé" + "Récupération automatique" + "Récupérer automatiquement les messages multimédias" + "Récupération auto. en itinérance" + "Récupérer automatiquement les messages multimédias en itinérance" + "Messagerie de groupe" + "Utiliser la messagerie multimédia pour envoyer un message unique à plusieurs destinataires" + "Application de messagerie texte par défaut" + "Application de messagerie texte par défaut" + + + "Votre numéro de téléphone" + "Inconnu" + "Sons pour les messages sortants" + "Capturer les messages texte" + "Capturer les données brutes reçues des messages multimédias dans un fichier de stockage externe" + "Capturer les messages multimédias" + "Capturer les données brutes reçues des messages multimédias dans un fichier de stockage externe" + "Alertes par réseau sans fil" + "Options relatives aux messages" + "Copier le texte" + "Afficher les détails" + "Supprimer" + "Transférer" + "Détails du message" + "Type : " + "Message texte" + "Message multimédia" + "De : " + "À : " + "Envoyé : " + "Reçu : " + "Objet : " + "Taille : " + "Priorité : " + "Carte SIM : " + "Élevée" + "Normale" + "Faible" + "Fente SIM %s" + "Adresse d\'expéditeur masquée" + "Impossible d\'envoyer le message pendant le chargement des pièces jointes." + "Impossible de charger la pièce jointe. Veuillez réessayer." + "Le réseau n\'est pas prêt. Réssayez." + "Supprimer le texte" + "Alterner entre l\'entrée de texte et de chiffres" + "Ajouter des participants" + "Confirmer les participants" + "Commencer une nouvelle discussion" + "Sélectionner cet article" + "Lire la vidéo" + "Participants et options" + "Déboguer" + "Participants et options" + "Général" + "Participants à cette conversation" + "Faire un appel" + "Envoyer un message" + "Envoyer un message<br/><small>à partir de %s</small>" + + Envoyer la photo + Envoyer les photos + + + Envoyer le fichier audio + Envoyer les fichiers audio + + + Envoyer la vidéo + Envoyer les vidéos + + + Envoyer la fiche de contact + Envoyer les fiches de contact + + + Envoyer la pièce jointe + Envoyer les pièces jointes + + + %d pièce jointe est prête à être envoyée + %d pièces jointes sont prêtes à être envoyées + + "Envoyer des commentaires" + "Afficher dans la boutique Google Play Store" + "Détails de la version" + "Version %1$s" + "Licences de logiciels libres" + "Notifications" + "Taille maximale des pièces jointes atteinte" + "Échec du chargement de la pièce jointe." + "Ajouter aux contacts?" + "Ajouter un contact" + "Objet" + "Objet : " + "%s %s" + "Chargement de la fiche de contact" + "Impossible de charger la fiche de contact" + "Afficher la fiche de contact" + + %d contact + %d contacts + + "Fiches de contact" + "Anniversaire" + "Remarques" + "Transférer le message" + "Répondre" + "+1" + "+%d" + "Messagerie texte désactivée" + "Pour envoyer des messages, définissez Messagerie comme application de messagerie par défaut" + "Définissez Messagerie comme application de messagerie par défaut" + "Modifier" + "Pour recevoir des messages, vous devez définir l\'application Messagerie comme application de messagerie par défaut." + "Aucune carte SIM préférée n\'a été sélectionnée pour l\'envoi des messages texte" + "Cette application n\'est pas autorisée par le propriétaire de l\'appareil." + "OK" + "Trop de participants dans une conversation" + + Contact non valide + Contacts non valides + + "Impossible de charger l\'image de l\'appareil photo" + "Vous : " + "%s : " + "Brouillon" + "Lorsque vous commencez une conversation, elle s\'affiche ici" + "Les conversations archivées s\'affichent ici" + "Chargement des conversations en cours..." + "Image" + "Extrait audio" + "Vidéo" + "Fiche de contact" + "Message multimédia" + "Annuler" + "Réessayer" + "Entrez le nom d\'un contact ou un numéro de téléphone pour commencer un nouveau message" + "Bloquer" + "Bloquer %s" + "Débloquer %s" + "Bloquer %s?" + "Vous continuerez à recevoir les messages envoyés par ce numéro, mais vous ne recevrez plus de notifications. Cette conversation sera archivée." + "Contacts bloqués" + "DÉBLOQUER" + "Contacts bloqués" + "Choisissez une image dans la bibliothèque de documents" + "Envoi du message en cours..." + "Message envoyé" + "Les données cellulaires sont désactivées. Vérifiez vos paramètres." + "Impossible d\'envoyer des messages en mode Avion" + "Le message n\'a pas pu être envoyé" + "Message téléchargé" + "Les données cellulaires sont désactivées. Vérifiez vos paramètres." + "Impossible de télécharger des messages en mode Avion" + "Le message n\'a pas pu être téléchargé" + "Zéro" + "Un" + "Deux" + "Trois" + "Quatre" + "Cinq" + "Six" + "Sept" + "Huit" + "Neuf" + "Impossible d\'envoyer le message par l\'intermédiaire de %1$s. Erreur  : %2$d." + "Impossible d\'envoyer le message par l\'intermédiaire d\'un fournisseur de services inconnu. Erreur : %1$d." + "Tr : %s" + "Le message n\'a pas été envoyé, car le service n\'est pas activé sur le réseau" + "Le message n\'a pas été envoyé, car l\'adresse de destination est incorrecte" + "Le message n\'a pas été envoyé, car il n\'est pas valide" + "Le message n\'a pas été envoyé, car le contenu est incompatible" + "Le message n\'a pas été envoyé, car il n\'est pas pris en charge" + "Le message n\'a pas été envoyé, car il est trop gros" + "Nouveau message" + "Afficher" + "Image" + "Impossible de trouver une application appropriée." + "Supprimer le destinataire" + "Nouveau message" + "Annuler" + "Modifier le point d\'accès" + "Non défini" + "Nom" + "Nom du point d\'accès" + "MMSC" + "Serveur mandataire pour la messagerie multimédia" + "Port pour la messagerie multimédia" + "MCC" + "MNC" + "Type de point d\'accès" + "Supprimer le point d\'accès" + "Nouveau point d\'accès" + "Enregistrer" + "Supprimer" + "Le champ « Nom » est obligatoire." + "Le champ « Point d\'accès » est obligatoire." + "Le champ MCC doit contenir 3 chiffres." + "Le champ MNC doit contenir 2 ou 3 chiffres." + "Restauration des paramètres de points d\'accès par défaut en cours..." + "Rétablir les valeurs par défaut" + "La réinitialisation des paramètres de points d\'accès par défaut est terminée." + "Sans titre" + "Noms des points d\'accès" + "Noms des points d\'accès" + "Nouveau point d\'accès" + "Les paramètres de point d\'accès ne sont pas disponibles pour cet utilisateur" + "Copier dans le presse-papiers?" + "Copier" + %s" + "Général" + "Avancés" + "Paramètres généraux" + "Paramètres avancés" + "Carte SIM « %s »" + "Envoyer des textos individuels à tous les destinataires. Vous seul recevrez les réponses." + "Envoyer des messages multimédias individuels à tous les destinataires." + "Numéro inconnu" + "Nouveau message" + "Nouveau message." + "Sélecteur de carte SIM" + "%1$s sélectionnée, sélecteur de carte SIM" + "Modifier l\'objet" + "Sélectionner une carte SIM ou modifier l\'objet" + "Maintenez le doigt sur le bouton pour enregistrer des contenus audio" + "Commencer une nouvelle discussion" + "Messagerie" + "Liste des conversations" + "Messagerie" + "Nouveau message" + "Liste des conversations" + "Chargement des conversations en cours..." + "Messages en cours de chargement…" + "Afficher plus de conversations" + "Voir plus de messages" + "Conversation supprimée" + "La conversation a été supprimée. Touchez pour afficher une autre conversation dans Messagerie." + "Bloqué" + "Débloqué" + "L\'espace de stockage est faible. Vous risquez de perdre certaines données." + "Sélectionner les pièces jointes" + "Confirmer la sélection" + "%d sélectionné(s)" + "Veuillez supprimer une ou plusieurs pièces jointes, puis réessayer." + "Vous pouvez essayer d\'envoyer votre message, mais il risque de ne pas être remis sauf si vous supprimez une ou plusieurs pièces jointes." + "Vous ne pouvez envoyer qu\'une vidéo par message. Veuillez supprimer les vidéos supplémentaires, puis réessayer." + "Échec du chargement de la pièce jointe." + "Envoyer quand même" + "Impossible de démarrer la conversation" + "%1$s (%2$s)" + "%s sélectionnée" + diff --git a/res/values-fr/arrays.xml b/res/values-fr/arrays.xml new file mode 100644 index 0000000..6bd25ad --- /dev/null +++ b/res/values-fr/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "aucun objet" + "nosubject" + + + "Oui" + "Non" + "OK" + "Haha" + "Merci" + "J\'accepte." + "Bien" + "J\'arrive." + "OK, je te réponds plus tard." + ":)" + ":(" + + diff --git a/res/values-fr/strings.xml b/res/values-fr/strings.xml new file mode 100644 index 0000000..688c1d5 --- /dev/null +++ b/res/values-fr/strings.xml @@ -0,0 +1,519 @@ + + + + + "SMS/MMS" + "SMS/MMS" + "Sélectionner la conversation" + "Paramètres" + "Envoyer le message" + "Ajouter une pièce jointe" + "Aide" + "Bienvenue" + "Ignorer" + "Suivant >" + "Suivant" + "Quitter" + "Paramètres >" + "Paramètres" + "Vous devez accorder à l\'application de SMS/MMS des autorisations d\'accès aux SMS, au téléphone et aux contacts." + "Vous pouvez modifier les autorisations dans Paramètres > Applications > SMS/MMS > Autorisations." + "Vous pouvez modifier les autorisations dans Paramètres > Applications > SMS/MMS > Autorisations." + "Fréquents" + "Tous les contacts" + "Envoyer au %s" + "Prendre des photos ou filmer une vidéo" + "Sélectionner des images sur cet appareil" + "Enregistrer un fichier audio" + "Sélectionner une photo" + "Le support est sélectionné." + "Le support est désélectionné." + "%d élément(s) sélectionné(s)" + "image %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "image" + "Enregistrer un fichier audio" + "Partager" + "À l\'instant" + "À l\'instant" + + %d min + %d min + + + %d heure + %d heures + + + %d jour + %d jours + + + %d semaine + %d semaines + + + %d mois + %d mois + + + %d an + %d ans + + "Message de catégorie 0" + "Enregistrer" + "L\'appareil manque d\'espace. Pour en libérer, les messages de l\'application SMS/MMS les plus anciens seront supprimés automatiquement." + "Espace de stockage bientôt saturé" + "Il est possible que vous ne puissiez pas envoyer ni recevoir de messages via l\'application de SMS/MMS jusqu\'à ce que votre appareil dispose de plus d\'espace." + "Espace de stockage limité. Il est possible que vous deviez supprimer des SMS." + "Confirmez votre numéro de téléphone" + "Cette étape unique permet de garantir que vos messages de groupe sont correctement envoyés via les SMS/MMS." + "Numéro de téléphone" + "Supprimer tous les messages comportant du contenu multimédia" + "Supprimer les messages datant de plus de %s" + "Supprimer automatiquement les messages datant de plus de %s" + "Ignorer" + "Supprimer tous les messages comportant du contenu multimédia ?" + "Supprimer les messages datant de plus de %s ?" + "Supprimer les messages datant de plus de %s et activer la suppression automatique ?" + "%s a dit" + "Vous avez dit" + "Expéditeur du message : %s" + "Vous avez envoyé un message." + "Envoi en cours…" + "Message non envoyé. Veuillez appuyer ici pour réessayer." + "Message non envoyé. Nouvelle tentative en cours…" + "Renvoyer ou supprimer" + "Veuillez appeler les services d\'urgence. Impossible d\'envoyer votre SMS pour l\'instant." + "Échec." + "Nouveau MMS à télécharger" + "Nouveau message MMS" + "Téléchargement impossible." + "Appuyer ici pour réessayer" + "Appuyer ici pour lancer le téléchargement" + "Télécharger ou supprimer" + "Téléchargement en cours…" + "Le message est arrivé à expiration ou n\'est plus disponible." + "taille : %1$s, expiration : %2$s" + "Impossible d\'envoyer le message. Destinataire non valide." + "Service non activé sur le réseau." + "Impossible d\'envoyer le message en raison de problèmes sur le réseau." + "Le message est arrivé à expiration ou n\'est plus disponible." + "(Aucun objet)" + "Expéditeur inconnu" + "Distribué" + "Impossible de télécharger le message \"%1$s\" de l\'utilisateur suivant : %2$s." + "Impossible d\'effectuer cette opération dans la base de données. Mémoire insuffisante." + "Le message n\'a pas été envoyé." + "Certains messages n\'ont pas été envoyés via les SMS/MMS." + + %d messages dans %d conversation + %d messages dans %d conversations + + "Message non téléchargé." + "Certains messages n\'ont pas été téléchargés via les SMS/MMS." + + %d messages dans %d conversation + %d messages dans %d conversations + + "Échec de l\'envoi du message à %1$s" + "Veuillez appeler les services d\'urgence. Impossible d\'envoyer votre SMS au numéro %1$s pour l\'instant." + + %d nouveau message + %d nouveaux messages + + "Démarrer" + "Caméra indisponible." + "Caméra indisponible." + "Enregistrement vidéo indisponible." + "Impossible d\'enregistrer le fichier multimédia." + "Impossible de prendre la photo." + "Retour" + "Archivées" + "Supprimer" + "Archiver" + "Annuler l\'archivage" + "Désactiver les notifications" + "Activer les notifications" + "Ajouter un contact" + "Télécharger" + "Envoyer" + "Supprimer" + "Supprimer ce message ?" + "Cette action est irréversible." + "Supprimer" + + Supprimer cette conversation ? + Supprimer ces conversations ? + + "Supprimer" + "Annuler" + "À" + "Sélectionner plusieurs images" + "Confirmer la sélection" + "+ %d" + "Impossible d\'enregistrer l\'audio. Veuillez réessayer." + "Impossible de lire l\'audio. Veuillez réessayer." + "Impossible d\'enregistrer l\'audio. Veuillez réessayer." + "Appuyez ici de manière prolongée" + ", " + " " + " : " + " " + "Image" + "Extrait audio" + "Vidéo" + "Fiche de contact" + "Télécharger" + "Répondre par SMS" + "Répondre par MMS" + "Répondre" + + %d participant + %d participants + + "Moi" + "Contact bloqué et archivé" + "Contact débloqué et archivage annulé." + "%d archivée(s)" + "Archivage annulé de %d conversation(s)." + "Notifications désactivées." + "Notifications activées." + "Configuration réussie. Appuyez de nouveau sur \"Envoyer\"." + "L\'application de SMS/MMS a bien été définie comme application de SMS par défaut." + + Supprimer la pièce jointe + Supprimer les pièces jointes + + "Pièce jointe audio" + "Jouer le contenu audio joint" + "Pause" + "Message de %s : %s." + "Échec de la réception du message envoyé par %s : %s. Heure : %s" + "Message envoyé par %s : %s. Heure : %s" + "Message non envoyé à %s : %s. Heure : %s" + "Envoi du message à %s en cours : %s. Heure : %s" + "Échec de l\'envoi du message à %s : %s. Heure : %s" + "Message à %s : %s. Heure : %s" + "Échec de la réception du message envoyé par %s : %s. Heure : %s. %s" + "Message envoyé par %s : %s. Heure : %s. %s" + "Message non envoyé au groupe \"%s\" : %s. Heure : %s" + "Envoi du message au groupe \"%s\" en cours : %s. Heure : %s" + "Échec de l\'envoi du message au groupe \"%s\" : %s. Heure : %s" + "Message au groupe \"%s\" : %s. Heure : %s" + "Échec du message. Appuyez pour réessayer." + "Conversation avec %s" + "Supprimer l\'objet" + "Enregistrer une vidéo" + "Capturer une image fixe" + "Prendre une photo" + "Lancer l\'enregistrement vidéo" + "Passer en caméra plein écran" + "Passer de la caméra frontale à la caméra arrière, et inversement" + "Arrêter l\'enregistrement et joindre la vidéo" + "Arrêter l\'enregistrement vidéo" + "Photos envoyées ou reçues par SMS/MMS" + + %d photo a été enregistrée dans l\'album \"%s\" + %d photos ont été enregistrées dans l\'album \"%s\". + + + %d vidéo a été enregistrée dans l\'album \"%s\". + %d vidéos ont été enregistrées dans l\'album \"%s\". + + + %d pièce jointe a été enregistrée dans l\'album \"%s\". + %d pièces jointes ont été enregistrées dans l\'album \"%s\". + + + %d pièce jointe a été enregistrée dans le dossier \"Téléchargements\". + %d pièces jointes ont été enregistrées dans le dossier \"Téléchargements\". + + + %d pièce jointe a été enregistrée. + %d pièces jointes ont été enregistrées. + + + Impossible d\'enregistrer %d pièce jointe. + Impossible d\'enregistrer %d pièces jointes. + + "Pièce jointe du MMS enregistrée" + "Paramètres" + "Archivées" + "Fermer" + "MMS" + "Paramètres avancés" + "Déboguer" + "Notifications" + "Son" + "Mode silencieux" + "Vibreur" + "Bloqué" + "Accusés de réception de SMS" + "Demander un accusé de réception pour chaque SMS envoyé" + "Récupération automatique" + "Récupérer automatiquement les MMS" + "Récupération auto. en itinérance" + "Récupérer automatiquement les MMS en itinérance" + "Messagerie de groupe" + "Utiliser un MMS pour envoyer un message unique à plusieurs destinataires" + "Application SMS par défaut" + "Application SMS par défaut" + + + "Votre numéro de téléphone" + "Inconnu" + "Sons pour les messages sortants" + "Créer une copie des SMS" + "Créer une copie des données SMS brutes reçues dans un fichier de stockage externe" + "Créer une copie des MMS" + "Créer une copie des données MMS brutes reçues dans un fichier de stockage externe" + "Alertes via le réseau sans fil" + "Options relatives au message" + "Copier le texte" + "Afficher les détails" + "Supprimer" + "Transférer" + "Détails du message" + "Type : " + "SMS" + "MMS" + "Expéditeur : " + "Destinataire : " + "Envoyé le : " + "Reçu le : " + "Objet : " + "Taille : " + "Priorité : " + "SIM : " + "Élevée" + "Normale" + "Basse" + "SIM %s" + "Adresse de l\'expéditeur masquée" + "Impossible d\'envoyer le message pendant le chargement des pièces jointes." + "Impossible de charger la pièce jointe. Veuillez réessayer." + "Le réseau n\'est pas prêt. Veuillez réessayer." + "Supprimer le texte" + "Alterner entre la saisie de texte et de chiffres" + "Ajouter d\'autres participants" + "Confirmer les participants" + "Démarrer une nouvelle conversation" + "Sélectionner cet article" + "Lire la vidéo" + "Participants et options" + "Déboguer" + "Participants et options" + "Général" + "Participants à cette conversation" + "Passer un appel" + "Envoyer un message" + "Envoyer un message<br/><small>avec la carte SIM \"%s\"</small>" + + Envoyer la photo + Envoyer les photos + + + Envoyer le fichier audio + Envoyer les fichiers audio + + + Envoyer la vidéo + Envoyer les vidéos + + + Envoyer la carte de visite + Envoyer les cartes de visite + + + Envoyer la pièce jointe + Envoyer les pièces jointes + + + %d pièce jointe est prête à être envoyée. + %d pièces jointes sont prêtes à être envoyées. + + "Envoyer des commentaires" + "Afficher sur le Google Play Store" + "Informations sur la version" + "Version %1$s" + "Licences Open Source" + "Notifications" + "Taille maximale des pièces jointes atteinte" + "Échec du chargement de la pièce jointe." + "Ajouter aux contacts ?" + "Ajouter un contact" + "Objet" + "Objet : " + "%s%s" + "Chargement de la fiche de contact en cours…" + "Impossible de charger la fiche de contact." + "Afficher la fiche de contact" + + %d contact + %d contacts + + "Fiches de contacts" + "Anniversaire" + "Remarques" + "Transférer le message" + "Répondre" + "+ 1" + "+%d" + "SMS désactivés" + "Pour envoyer des messages, vous devez définir l\'application de SMS/MMS comme application de SMS par défaut." + "Définissez l\'application de SMS/MMS comme application de SMS par défaut." + "Modifier" + "Pour recevoir des messages, vous devez définir l\'application de SMS/MMS comme application de SMS par défaut." + "Aucune carte SIM préférée sélectionnée pour l\'envoi de SMS." + "Cette application n\'est pas autorisée par le propriétaire de l\'appareil." + "OK" + "Trop de participants dans une conversation." + + Contact non valide. + Contacts non valides. + + "Impossible de charger l\'image de la caméra." + "Vous : " + "%s : " + "Brouillon" + "Lorsque vous commencez une conversation, elle s\'affiche ici." + "Les conversations archivées s\'affichent ici." + "Chargement des conversations en cours…" + "Image" + "Extrait audio" + "Vidéo" + "Fiche de contact" + "MMS" + "Annuler" + "Réessayer" + "Saisissez le nom d\'un contact ou un numéro de téléphone pour commencer un nouveau message" + "Bloquer" + "Bloquer \"%s\"" + "Débloquer \"%s\"" + "Bloquer %s ?" + "Vous continuerez à recevoir les messages envoyés par ce numéro, mais vous ne recevrez plus de notifications. Cette conversation sera archivée." + "Contacts bloqués" + "DÉBLOQUER" + "Contacts bloqués" + "Sélectionner une image dans la bibliothèque de documents" + "Envoi du message en cours..." + "Message envoyé." + "Les données cellulaires sont désactivées. Veuillez vérifier vos paramètres." + "Impossible d\'envoyer des messages en mode Avion." + "Envoi du SMS impossible." + "Message téléchargé." + "Les données cellulaires sont désactivées. Veuillez vérifier vos paramètres." + "Impossible de télécharger les messages en mode Avion." + "Impossible de télécharger le message." + "Zéro" + "Un" + "Deux" + "Trois" + "Quatre" + "Cinq" + "Six" + "Sept" + "Huit" + "Neuf" + "Impossible d\'envoyer le message via %1$s. Erreur : %2$d" + "Impossible d\'envoyer le message via un opérateur inconnu. Erreur : %1$d" + "TR : %s" + "Le message n\'a pas été envoyé, car le service n\'est pas activé sur le réseau." + "Le message n\'a pas été envoyé, car l\'adresse de destination est incorrecte." + "Le message n\'a pas été envoyé, car il n\'est pas valide." + "Le message n\'a pas été envoyé, car le contenu est incompatible." + "Le message n\'a pas été envoyé, car il est incompatible." + "Le message n\'a pas été envoyé, car il est trop volumineux." + "Nouveau message" + "Afficher" + "Image" + "Impossible de trouver une application appropriée." + "Supprimer le destinataire" + "Nouveau message" + "Annuler" + "Modifier le point d\'accès" + "Non défini" + "Nom" + "APN" + "MMSC" + "Proxy pour les MMS" + "Port pour les MMS" + "MCC" + "MNC" + "Type d\'APN" + "Supprimer l\'APN" + "Nouvel APN" + "Enregistrer" + "Supprimer" + "Champ \"Nom\" obligatoire." + "APN obligatoire." + "Le champ \"MCC\" doit contenir trois chiffres." + "Le champ \"MNC\" doit contenir deux ou trois chiffres." + "Restauration des paramètres APN par défaut en cours…" + "Rétablir les paramètres par défaut" + "Les paramètres APN par défaut ont bien été réinitialisés." + "Sans titre" + "Points d\'accès" + "APN" + "Nouvel APN" + "Les paramètres de point d\'accès ne sont pas disponibles pour cet utilisateur." + "Copier dans le Presse-papiers ?" + "Copier" + "destinataire : \"%s\"" + "Général" + "Paramètres avancés" + "Paramètres généraux" + "Paramètres avancés" + "Carte SIM \"%s\"" + "Envoyer des SMS individuels à tous les destinataires. Vous seul recevrez les réponses." + "Envoyer un MMS unique à tous les destinataires" + "Depuis un numéro inconnu" + "Nouveau message" + "Nouveau message." + "Sélecteur de carte SIM" + "\"%1$s\" sélectionnée – Sélecteur de carte SIM" + "Modifier l\'objet" + "Sélectionner une carte SIM ou modifier l\'objet" + "Appuyer de manière prolongée pour enregistrer des contenus audio" + "Lancer une conversation" + "SMS/MMS" + "Liste des SMS/MMS" + "SMS/MMS" + "Nouveau message" + "Liste des conversations" + "Chargement des conversations en cours…" + "Chargement des messages en cours…" + "Afficher plus de conversations" + "Afficher d\'autres messages" + "Conversation supprimée." + "La conversation a bien été supprimée. Appuyez pour afficher une autre conversation de SMS/MMS." + "Bloqué" + "Débloqué" + "L\'espace de stockage est faible. Vous risquez de perdre certaines données." + "Sélectionner les pièces jointes" + "Confirmer la sélection" + "%d élément(s) sélectionné(s)" + "Veuillez supprimer une ou plusieurs pièces jointes, puis réessayer." + "Vous pouvez essayer d\'envoyer votre message, mais il risque de ne pas être remis sauf si vous supprimez une ou plusieurs pièces jointes." + "Vous ne pouvez envoyer qu\'une vidéo par message. Veuillez supprimer les vidéos supplémentaires, puis réessayer." + "Échec du chargement de la pièce jointe via les SMS/MMS." + "Envoyer quand même" + "Impossible de lancer la conversation." + "%1$s (%2$s)" + "%s sélectionnée" + diff --git a/res/values-gl-rES/arrays.xml b/res/values-gl-rES/arrays.xml new file mode 100644 index 0000000..1e43974 --- /dev/null +++ b/res/values-gl-rES/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "sen asunto" + "senasunto" + + + "Si" + "Non" + "Aceptar" + "He he" + "Grazas" + "Acepto" + "Excelente" + "Á miña maneira" + "De acordo, póñome en contacto contigo máis tarde" + ":)" + ":(" + + diff --git a/res/values-gl-rES/strings.xml b/res/values-gl-rES/strings.xml new file mode 100644 index 0000000..91a6311 --- /dev/null +++ b/res/values-gl-rES/strings.xml @@ -0,0 +1,519 @@ + + + + + "Mensaxería" + "Mensaxería" + "Seleccionar conversa" + "Configuración" + "Enviar mensaxe" + "Engadir un anexo" + "Axuda" + "Dámosche a benvida" + "Omitir" + "Seguinte >" + "Seguinte" + "Saír" + "Configuración >" + "Configuración" + "Mensaxería necesita permiso para acceder ás SMS, ao teléfono e aos contactos." + "Podes cambiar os permisos en Configuración > Aplicacións > Mensaxería > Permisos." + "Podes cambiar os permisos en Configuración, Aplicacións, Mensaxería, Permisos." + "Frecuentes" + "Todos os contactos" + "Enviar ao %s" + "Capturar fotos ou vídeo" + "Escoller imaxes deste dispositivo" + "Gravar audio" + "Escoller foto" + "O ficheiro multimedia está seleccionado." + "O ficheiro multimedia non está seleccionado." + "%d seleccionado" + "imaxe do %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "imaxe" + "Gravar audio" + "Compartir" + "Agora mesmo" + "Agora" + + %d minutos + %d minuto + + + %d horas + %d hora + + + %d días + %d día + + + %d semanas + unha semana + + + %d meses + un mes + + + %d anos + un ano + + "Mensaxe de clase 0" + "Gardar" + "Este dispositivo ten pouco espazo. Mensaxería eliminará automaticamente as mensaxes antigas para liberar espazo." + "Estase esgotando o espazo de almacenamento" + "É posible que Mensaxería non envíe nin reciba mensaxes ata que haxa máis espazo dispoñible no teu dispositivo." + "Queda pouco almacenamento de SMS restante. É posible que precises eliminar mensaxes." + "Confirma o teu número de teléfono" + "Este paso único garantirá que Mensaxería entregue as túas mensaxes de grupo de forma adecuada." + "Número de teléfono" + "Eliminar todas as mensaxes con ficheiros multimedia" + "Eliminar mensaxes anteriores a %s" + "Eliminar automaticamente mensaxes anteriores a %s" + "Ignorar" + "Queres eliminar todas as mensaxes con ficheiros multimedia?" + "Queres eliminar as mensaxes anteriores a %s?" + "Queres eliminar mensaxes anteriores a %s e activar a eliminación automática?" + "%s dixo" + "Dixeches" + "Mensaxe de %s" + "Enviaches unha mensaxe" + "Enviando…" + "Non se enviou. Toca para tentalo de novo." + "Non se enviou. Tentando de novo…" + "Reenviar ou eliminar" + "Realiza unha chamada de voz aos servizos de emerxencia. Non se puido entregar a túa mensaxe de texto neste momento." + "Erro" + "Nova mensaxe MMS para descargar" + "Nova mensaxe MMS" + "Non se puido descargar" + "Tocar para tentalo de novo" + "Tocar para descargar" + "Descargar ou eliminar" + "Descargando..." + "A mensaxe caducou ou non está dispoñible" + "tamaño: %1$s, caducidade: %2$s" + "Non se pode enviar. O destinatario non é válido." + "Servizo non activado na rede" + "Non se puido enviar debido a un problema de rede" + "A mensaxe caducou ou non está dispoñible" + "(Sen asunto)" + "Remitente descoñecido" + "Entregado" + "Non se puido descargar a mensaxe %1$s de %2$s." + "Non se puido completar a operación da base de datos debido a que queda pouca memoria" + "Mensaxe non enviada" + "Hai mensaxes non enviadas en Mensaxería" + + %d mensaxes en %d conversas + %d mensaxes nunha conversa + + "Non se descargou a mensaxe" + "Algunhas mensaxes non se descargaron en Mensaxería" + + %d mensaxes en %d conversas + %d mensaxes nunha conversa + + "Non se enviou a mensaxe ao %1$s" + "Realiza unha chamada de voz aos servizos de emerxencia. Non se puido entregar a túa mensaxe de texto ao %1$s neste momento." + + %d mensaxes novas + Mensaxe nova + + "Iniciar" + "A cámara non está dispoñible" + "A cámara non está dispoñible" + "A captura de vídeo non está dispoñible" + "Non se pode gardar o ficheiro multimedia" + "Non se pode sacar a foto" + "Atrás" + "Arquivadas" + "Eliminar" + "Arquivar" + "Cancelar o arquivado" + "Desactivar notificacións" + "Activar notificacións" + "Engadir contacto" + "Descargar" + "Enviar" + "Eliminar" + "Queres eliminar esta mensaxe?" + "Esta acción non se pode desfacer." + "Eliminar" + + Queres eliminar estas conversas? + Queres eliminar esta conversa? + + "Eliminar" + "Cancelar" + "Para" + "Seleccionar varias imaxes" + "Confirmar selección" + "+%d" + "Non se pode gravar o audio. Téntao de novo." + "Non se pode reproducir o audio. Téntao de novo." + "Non se puido gardar o audio. Téntao de novo." + "Mantén premido" + ", " + " " + ": " + " " + "Imaxe" + "Clip de audio" + "Vídeo" + "Tarxeta de contacto" + "Descargar" + "Responder mediante SMS" + "Resposta MMS" + "Responde" + + %d participantes + %d participante + + "Eu" + "Contacto bloqueado e arquivado" + "Contacto desbloqueado e sen arquivar" + "%d arquivada/s" + "%d non arquivada/s" + "Notificacións desactivadas" + "Notificacións activadas" + "Todo listo. Toca Enviar de novo." + "Mensaxería estableceuse correctamente como a aplicación de SMS predeterminada." + + Descartar anexos + Descartar anexo + + "Anexo de audio" + "Reproducir anexo de audio" + "Pausa" + "Mensaxe de %s: %s." + "Erro ao recibir a mensaxe de %s: %s. Hora: %s" + "Mensaxe de %s: %s. Hora: %s" + "Non se enviou a mensaxe a %s: %s. Hora: %s" + "Enviando mensaxe a %s: %sE. Hora: %s" + "Erro ao enviar a mensaxe a %s: %s. Hora: %s" + "Mensaxe para %s: %s. Hora: %s" + "Erro ao recibir a mensaxe de %s: %s. Hora: %s. %s" + "Mensaxe de %s: %s. Hora: %s. %s" + "Non se enviou a mensaxe a %s: %s. Hora: %s" + "Enviando mensaxe a %s: %sE. Hora: %s" + "Erro ao enviar a mensaxe a %s: %s. Hora: %s" + "Mensaxe para %s: %s. Hora: %s" + "Erro na mensaxe. Toca para tentalo de novo." + "Conversa con %s" + "Borrar asunto" + "Capturar vídeo" + "Capturar unha imaxe estática" + "Sacar foto" + "Comezar a gravar vídeo" + "Cambiar á cámara de pantalla completa" + "Cambiar entre a cámara dianteira e traseira" + "Deter a gravación e anexar vídeo" + "Deter a gravación de vídeo" + "Fotos de Mensaxería" + + Gardáronse %d fotos no álbum \"%s\" + Gardouse %d foto no álbum \"%s\" + + + Gardáronse %d vídeos no álbum \"%s\" + Gardouse %d vídeo no álbum \"%s\" + + + Gardáronse %d anexos no álbum \"%s\" + Gardouse %d anexo no álbum \"%s\" + + + Gardáronse %d anexos en \"Descargas\" + Gardouse %d anexo en \"Descargas\" + + + Gardáronse %d anexos + Gardouse %d anexo + + + Non se puideron gardar %d anexos + Non se puido gardar %d anexo + + "Anexo de MMS gardado" + "Configuración" + "Arquivadas" + "Pechar" + "MMS" + "Avanzado" + "Depuración" + "Notificacións" + "Son" + "Silencio" + "Vibrar" + "Bloqueado" + "Informes de entrega de SMS" + "Solicita un informe de entrega cando envías un SMS" + "Recuperación automática" + "Recupera mensaxes MMS automaticamente" + "Recuperación automática en itinerancia" + "Recuperación automática de MMS en itinerancia" + "Mensaxería en grupo" + "Utiliza MMS para enviar unha soa mensaxe cando hai varios destinatarios" + "Aplicación de SMS predeterminada" + "Aplicación de SMS predeterminada" + + + "O teu número de teléfono" + "Descoñecido" + "Sons de mensaxes saíntes" + "Pasar SMS" + "Pasa os datos recibidos de SMS sen procesar a un ficheiro de almacenamento externo" + "Pasar MMS" + "Pasa os datos recibidos de MMS sen procesar a un ficheiro de almacenamento externo" + "Alertas sen fíos" + "Opcións de mensaxes" + "Copiar texto" + "Ver detalles" + "Eliminar" + "Reenviar" + "Detalles da mensaxe" + "Tipo: " + "Mensaxe de texto" + "Mensaxe multimedia" + "De: " + "Para: " + "Enviada: " + "Recibida: " + "Asunto: " + "Tamaño: " + "Prioridade: " + "SIM: " + "Alta" + "Normal" + "Baixa" + "SIM %s" + "Enderezo do remitente oculto" + "Non é posible enviar a mensaxe ao cargar anexos." + "Non se pode cargar o anexo. Téntao de novo." + "A rede non está lista. Téntao de novo." + "Eliminar texto" + "Cambiar entre introducir texto e números" + "Engadir máis participantes" + "Confirmar participantes" + "Iniciar nova conversa" + "Selecciona este elemento" + "Reproducir vídeo" + "Persoas e opcións" + "Depurar" + "Persoas e opcións" + "Xeral" + "Persoas que participan nesta conversa" + "Facer unha chamada" + "Enviar mensaxe" + "Envía a mensaxe<br/><small>desde %s</small>" + + Enviar fotos + Enviar foto + + + Enviar gravacións de audio + Enviar gravación de audio + + + Enviar vídeos + Enviar vídeo + + + Enviar tarxetas de contacto + Enviar tarxeta de contacto + + + Enviar anexos + Enviar anexo + + + %d anexos listos para enviar + Un anexo listo para enviar + + "Dános a túa opinión" + "Ver en Google Play Store" + "Información da versión" + "Versión %1$s" + "Licenzas de código aberto" + "Notificacións" + "Acadouse o límite para anexos" + "Non se puido cargar o anexo." + "Queres engadir este número aos teus contactos?" + "Engadir contacto" + "Asunto" + "Asunto: " + "%s%s" + "Cargando tarxeta de contacto" + "Non se puido cargar a tarxeta de contacto" + "Ver tarxeta de contacto" + + %d contactos + %d contacto + + "Tarxetas de contacto" + "Aniversario" + "Notas" + "Reenviar mensaxe" + "Responder" + "+1" + "+%d" + "SMS desactivadas" + "Establece Mensaxería como a aplicación de SMS predeterminada para enviar mensaxes" + "Establece Mensaxería como a aplicación de SMS predeterminada" + "Cambiar" + "Establece Mensaxería como a aplicación de SMS predeterminada para recibir mensaxes" + "Non se seleccionou unha SIM preferida para o envío de mensaxes SMS" + "O propietario do dispositivo non permite esta aplicación." + "Aceptar" + "Hai demasiados participantes nunha conversa" + + Contactos non válidos + Contacto non válido + + "Non se puido cargar a imaxe da cámara" + "Ti: " + "%s: " + "Borrador" + "Cando inicies unha nova conversa, aparecerá aquí" + "As conversas arquivadas aparecen aquí" + "Cargando conversas..." + "Imaxe" + "Clip de audio" + "Vídeo" + "Tarxeta de contacto" + "MMS" + "Desfacer" + "Tentar de novo" + "Introduce o nome dun contacto ou o número de teléfono para iniciar unha mensaxe nova" + "Bloquear" + "Bloquear %s" + "Desbloquear %s" + "Queres bloquear %s?" + "Seguirás recibindo mensaxes deste número, pero xa non se notificarán. Arquivarase esta conversa." + "Contactos bloqueados" + "DESBLOQUEAR" + "Contactos bloqueados" + "Escolle unha imaxe da biblioteca de documentos" + "Enviando a mensaxe" + "Mensaxe enviada" + "Os datos móbiles están desactivados. Comproba a túa configuración." + "Non se poden enviar mensaxes en modo avión" + "Erro ao enviar a mensaxe" + "Mensaxe descargada" + "Os datos móbiles están desactivados. Comproba a túa configuración." + "Non se poden descargar mensaxes no modo avión" + "Non se puido descargar a mensaxe" + "Cero" + "Un" + "Dous" + "Tres" + "Catro" + "Cinco" + "Seis" + "Sete" + "Oito" + "Nove" + "Non se pode enviar a mensaxe con %1$s. Erro: %2$d" + "Non se pode enviar a mensaxe cun operador descoñecido. Erro: %1$d" + "Rv: %s" + "Mensaxe non enviada: servizo non activado na rede" + "Mensaxe non enviada: enderezo de destino non válido" + "Mensaxe non enviada: mensaxe non válida" + "Mensaxe non enviada: contido non compatible" + "Mensaxe non enviada: mensaxe non compatible" + "Mensaxe non enviada: demasiado longa" + "Mensaxe nova" + "Ver" + "Imaxe" + "Non se puido encontrar unha aplicación adecuada" + "Elimina o destinatario" + "Mensaxe nova" + "Cancelar" + "Editar punto de acceso" + "Sen configurar" + "Nome" + "APN" + "MMSC" + "Proxy MMS" + "Porto de MMS" + "MCC" + "MNC" + "Tipo de APN" + "Eliminar APN" + "Novo APN" + "Gardar" + "Descartar" + "O nome do campo non pode estar baleiro." + "O APN non pode estar baleiro." + "O campo MCC debe conter 3 díxitos." + "O campo MNC debe conter 2 ou 3 díxitos." + "Restaurando a configuración de APN predeterminada." + "Restablecer aos valores predeterminados" + "Completouse o restablecemento da configuración de APN predeterminada." + "Sen título" + "Nomes do punto de acceso" + "APN" + "Novo APN" + "A configuración do nome do punto de acceso non está dispoñible para este usuario" + "Copiar no portapapeis?" + "Copiar" + "en %s" + "Xeral" + "Avanzada" + "Configuración xeral" + "Configuración avanzada" + "SIM \"%s\"" + "Enviar mensaxes SMS individuais a todos os destinatarios. Só ti recibirás as respostas" + "Enviar unha mensaxe MMS simple a todos os destinatarios" + "Número descoñecido" + "Mensaxe nova" + "Mensaxe nova." + "Selector de SIM" + "Seleccionouse %1$s, selector da SIM" + "Editar asunto" + "Seleccionar a SIM ou editar o asunto" + "Tocar e manter premido para gravar audio" + "Iniciar nova conversa" + "Mensaxería" + "Lista de Mensaxería" + "Mensaxería" + "Mensaxe nova" + "Lista de conversas" + "Cargando conversas" + "Cargando mensaxes" + "Ver máis conversas" + "Ver máis mensaxes" + "Conversa eliminada" + "Eliminouse a conversa. Toca para mostrar unha conversa de Mensaxería diferente" + "Bloqueado" + "Desbloqueado" + "Queda pouco espazo de almacenamento. É posible que se perdan algúns datos." + "Seleccionar anexos" + "Confirmar selección" + "%d seleccionado/s" + "Elimina un ou varios anexos e téntao de novo." + "Podes probar a enviar a túa mensaxe, pero é posible que non se entregue a menos que elimines un ou varios anexos." + "Só podes enviar un vídeo por mensaxe. Elimina os vídeos adicionais e téntao de novo." + "Mensaxería non puido cargar o anexo." + "Enviar igualmente" + "Non se puido iniciar a conversa" + "%1$s (%2$s)" + "Seleccionouse %s" + diff --git a/res/values-gu-rIN/arrays.xml b/res/values-gu-rIN/arrays.xml new file mode 100644 index 0000000..89975e0 --- /dev/null +++ b/res/values-gu-rIN/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "કોઇ વિષય નહીં" + "વિષય નથી" + + + "હા" + "નહીં" + "ઑકે" + "હીહી" + "આભાર" + "હું સહમત છું" + "સરસ" + "મારી અનુકૂળતાએ" + "ઑકે, હું તમને પછી મળું છું" + ":)" + ":(" + + diff --git a/res/values-gu-rIN/strings.xml b/res/values-gu-rIN/strings.xml new file mode 100644 index 0000000..866823c --- /dev/null +++ b/res/values-gu-rIN/strings.xml @@ -0,0 +1,519 @@ + + + + + "મેસેજિંગ" + "મેસેજિંગ" + "વાર્તાલાપ પસંદ કરો" + "સેટિંગ્સ" + "સંદેશ મોકલો" + "એક જોડાણ ઉમેરો" + "સહાય" + "સ્વાગત છે" + "છોડી દો" + "આગલું >" + "આગલું" + "બહાર નીકળો" + "સેટિંગ્સ >" + "સેટિંગ્સ" + "મેસેજિંગને SMS, ફોન અને સંપર્કોની પરવાનગીની જરૂર છે." + "તમે સેટિંગ્સ > એપ્લિકેશન્સ > મેસેજિંગ > પરવાનગીઓમાં પરવાનગીઓ બદલી શકો છો." + "તમે સેટિંગ્સ, એપ્લિકેશન્સ, મેસેજિંગ, પરવાનગીઓમાં પરવાનગીઓ બદલી શકો છો." + "વારંવાર" + "તમામ સંપર્કો" + "%s પર મોકલો" + "ચિત્રો અથવા વિડિઓ કેપ્ચર કરો" + "આ ઉપકરણથી છબીઓ પસંદ કરો" + "ઑડિઓ રેકોર્ડ કરો" + "ફોટો પસંદ કરો" + "મીડિયા પસંદ થયેલ છે." + "મીડિયા પસંદ કરેલ નથી." + "%d પસંદ કર્યાં" + "છબી %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "છબી" + "ઑડિઓ રેકોર્ડ કરો" + "શેર કરો" + "હમણાં જ" + "હમણાં" + + %d મિનિટ + %d મિનિટ + + + %d કલાક + %d કલાક + + + %d દિવસ + %d દિવસ + + + %d અઠવાડિયા + %d અઠવાડિયા + + + %d મહિના + %d મહિના + + + %d વર્ષ + %d વર્ષ + + "વર્ગ 0 સંદેશ" + "સાચવો" + "ઉપકરણ પર જગ્યા ઓછી છે. મેસેજિંગ જગ્યા ખાલી કરવા માટે જૂના સંદેશા આપમેળે કાઢી નાખશે." + "સ્ટોરેજ સ્થાન સમાપ્ત થયું" + "તમારા ઉપકરણ પર વધુ સ્થાન ઉપલબ્ધ ન થાય ત્યાં સુધી મેસેજિંગ સંદેશા મોકલી અથવા પ્રાપ્ત કરી શકશે નહીં." + "ઓછો SMS સંગ્રહ. તમારે કેટલાક સંદેશા કાઢી નાખવાની જરૂર હોઈ શકે છે." + "તમારા ફોન નંબરની પુષ્ટિ કરો" + "મેસેજિંગ તમારા જૂથ સંદેશાનું યોગ્ય રીતે વિતરણ કરે તે બાબતની આ એક-વારનું પગલું ખાતરી કરશે." + "ફોન નંબર" + "મીડિયા સાથેના તમામ સંદેશા કાઢી નાખો" + "%s કરતાં જૂનાં સંદેશા કાઢી નાખો" + "%s કરતા જૂના સંદેશા આપમેળે કાઢી નાખો" + "અવગણો" + "મીડિયા સાથેના તમામ સંદેશા કાઢી નાખીએ?" + "%s કરતા જૂના સંદેશા કાઢી નાખીએ?" + "%s કરતા જૂનાં સંદેશા કાઢી નાખીએ અને આપમેળે કાઢી નાખવું ચાલુ કરીએ?" + "%s એ કહ્યું" + "તમે કહ્યું" + "%s તરફથી સંદેશ" + "તમે એક સંદેશ મોકલ્યો" + "મોકલી રહ્યાં છે…" + "મોકલાયો નથી. ફરીથી પ્રયાસ કરવા માટે ટચ કરો." + "મોકલાયું નથી. ફરીથી પ્રયાસ કરી રહ્યું છે…" + "ફરીથી મોકલો અથવા કાઢી નાખો" + "કૃપા કરીને કટોકટીની સેવાઓ માટે એક વૉઇસ કૉલ કરો. તમારો ટેક્સ્ટ સંદેશ આ સમયે વિતરિત કરી શકાયો નથી." + "નિષ્ફળ થયું" + "ડાઉનલોડ કરવા માટેનો નવો MMS સંદેશ" + "નવો MMS સંદેશ" + "ડાઉનલોડ કરી શક્યાં નથી" + "ફરીથી પ્રયત્ન કરવા ટચ કરો" + "ડાઉનલોડ કરવા ટચ કરો" + "ડાઉનલોડ કરો અથવા કાઢી નાખો" + "ડાઉનલોડ કરી રહ્યાં છે..." + "સંદેશની સમયસીમા સમાપ્ત થઈ અથવા ઉપલબ્ધ નથી" + "કદ: %1$s, સમય સમાપ્તિ: %2$s" + "મોકલી શકતા નથી. પ્રાપ્તકર્તા માન્ય નથી." + "નેટવર્ક પર સેવા સક્રિય કરી નથી" + "નેટવર્ક સમસ્યાને કારણે મોકલી શકાયો નથી" + "સંદેશની સમયસીમા સમાપ્ત થઈ અથવા ઉપલબ્ધ નથી" + "(કોઇ વિષય નથી)" + "અજાણ્યો પ્રેષક" + "વિતરિત" + "%2$s તરફનાં %1$s સંદેશા ડાઉનલોડ કરી શકાયા નથી." + "ઓછી મેમરીને કારણે ડેટાબેસ ક્રિયા પૂર્ણ કરી શકાઈ નથી" + "સંદેશ મોકલ્યો નથી" + "મેસેજિંગમાં કેટલાક સંદેશા મોકલ્યાં નથી" + + %d વાર્તાલાપમાં %d સંદેશા + %d વાર્તાલાપમાં %d સંદેશા + + "સંદેશ ડાઉનલોડ થયેલ નથી" + "મેસેજિંગમાં કેટલાક સંદેશા ડાઉનલોડ થયાં નથી" + + %d વાર્તાલાપમાં %d સંદેશા + %d વાર્તાલાપમાં %d સંદેશા + + "%1$s ને સંદેશ મોકલાયો નથી" + "કટોકટી સેવાઓને કૃપા કરીને એક વૉઇસ કૉલ કરો. %1$s ને તમારો ટેક્સ્ટ સંદેશ આ સમયે વિતરિત કરી શકાયો નથી." + + %d નવા સંદેશા + %d નવા સંદેશા + + "પ્રારંભ કરો" + "કૅમેરો ઉપલબ્ધ નથી" + "કૅમેરો ઉપલબ્ધ નથી" + "વિડિઓ કેપ્ચર ઉપલબ્ધ નથી" + "મીડિયા સાચવી શકતા નથી" + "ચિત્ર લઈ શકતા નથી" + "પાછળ" + "આર્કાઇવ કરેલા" + "કાઢી નાખો" + "આર્કાઇવ" + "આર્કાઇવ.કરેલ નથી" + "સૂચનાઓ બંધ કરો" + "સૂચનાઓ ચાલુ કરો" + "સંપર્ક ઉમેરો" + "ડાઉનલોડ કરો" + "મોકલો" + "કાઢી નાખો" + "આ સંદેશ કાઢી નાખીએ?" + "આ ક્રિયા પૂર્વવત્ કરી શકાતી નથી." + "કાઢી નાખો" + + આ વાર્તાલાપ કાઢી નાખીએ? + આ વાર્તાલાપ કાઢી નાખીએ? + + "કાઢી નાખો" + "રદ કરો" + "પ્રતિ" + "બહુવિધ છબીઓ પસંદ કરો" + "પસંદગીની પુષ્ટિ કરો" + "+%d" + "ઑડિઓ રેકોર્ડ કરી શકાતો નથી. ફરીથી પ્રયાસ કરો." + "ઑડિઓ ચલાવી શકાતો નથી. ફરીથી પ્રયાસ કરો." + "ઑડિઓ સાચવી શકાયો નથી. ફરીથી પ્રયાસ કરો." + "ટચ કરો અને પકડો" + ", " + " " + ": " + " " + "ચિત્ર" + "ઑડિઓ ક્લિપ" + "વિડિઓ" + "સંપર્ક કાર્ડ" + "ડાઉનલોડ કરો" + "SMS દ્વારા જવાબ આપો" + "MMS દ્વારા જવાબ આપો" + "જવાબ આપો" + + %d સહભાગીઓ + %d સહભાગીઓ + + "મારા" + "સંપર્ક અવરોધિત કર્યો અને આર્કાઇવ કર્યો" + "સંપર્ક અનાવરોધિત અને અનઆર્કાઇવ કર્યો" + "%d આર્કાઇવ કર્યાં" + "%d અનઆર્કાઇવ કર્યા" + "સૂચનાઓ બંધ કરી" + "સૂચનાઓ ચાલુ કરી" + "બધું સેટ થયું. મોકલો ને ફરીથી ટચ કરો." + "ડિફોલ્ટ SMS એપ્લિકેશન તરીકે મેસેજિંગ સફળતાપૂર્વક સેટ થયું." + + જોડાણો નિકાળો + જોડાણો નિકાળો + + "ઑડિઓ જોડાણ" + "ઑડિઓ જોડાણ ચલાવો" + "થોભો" + "%s તરફથી સંદેશ: %s." + "%s તરફથી સંદેશ નિષ્ફળ: %s. સમય: %s." + "%s તરફથી સંદેશ: %s. સમય: %s." + "%s ને વણમોકલાયેલ સંદેશ: %s. સમય: %s." + "%s ને સંદેશ મોકલી રહ્યું છે: %s. સમય: %s." + "%s ને સંદેશ મોકલવું નિષ્ફળ: %s. સમય: %s." + "%s ને સંદેશ: %s. સમય: %s." + "%s તરફથી સંદેશ નિષ્ફળ: %s. સમય: %s. %s." + "%s તરફથી સંદેશ: %s. સમય: %s. %s." + "%s ને વણમોકલાયેલ સંદેશ: %s. સમય: %s." + "%s ને સંદેશ મોકલી રહ્યું છે: %s. સમય: %s." + "%s ને સંદેશ મોકલવું નિષ્ફળ: %s. સમય: %s." + "%s ને સંદેશ: %s. સમય: %s." + "નિષ્ફળ સંદેશ. ફરી પ્રયાસ કરવા ટચ કરો." + "%s સાથે વાર્તાલાપ" + "વિષય કાઢી નાખો" + "વિડિઓ કેપ્ચર કરો" + "એક સ્થિર છબી કેપ્ચર કરો" + "ફોટો લો" + "વિડિઓ રેકોર્ડિંગ પ્રારંભ કરો" + "પૂર્ણ સ્ક્રીન કૅમેરા પર સ્વિચ કરો" + "આગળનાં અને પાછળનાં કૅમેરા વચ્ચે સ્વિચ કરો" + "રેકોર્ડિંગ બંધ કરો અને વિડિઓ જોડો" + "વિડિઓ રેકોર્ડિંગ બંધ કરો" + "મેસેજિંગ ફોટા" + + \"%s\" આલ્બમમાં %d ફોટા સાચવ્યાં + \"%s\" આલ્બમમાં %d ફોટા સાચવ્યાં + + + \"%s\" આલ્બમમાં %d વિડિઓઝ સાચવી + \"%s\" આલ્બમમાં %d વિડિઓઝ સાચવી + + + \"%s\" આલ્બમમાં %d જોડાણો સાચવ્યાં + \"%s\" આલ્બમમાં %d જોડાણો સાચવ્યાં + + + \"ડાઉનલોડ્સ\"માં %d જોડાણો સાચવ્યાં + \"ડાઉનલોડ્સ\"માં %d જોડાણો સાચવ્યાં + + + %d જોડાણ સાચવ્યાં + %d જોડાણ સાચવ્યાં + + + %d જોડાણ સાચવી શક્યાં નથી + %d જોડાણ સાચવી શક્યાં નથી + + "સાચવેલ MMS જોડાણ" + "સેટિંગ્સ" + "આર્કાઇવ કરેલા" + "બંધ કરો" + "MMS" + "વિગતવાર" + "ડીબગ" + "સૂચનાઓ" + "ધ્વનિ" + "શાંત" + "વાઇબ્રેટ" + "અવરોધિત" + "SMS વિતરણ રિપોર્ટ્સ" + "તમે મોકલેલા દરેક SMS માટે વિતરણ રિપોર્ટની વિનંતી કરો" + "સ્વતઃ-પુનર્પ્રાપ્ત કરો" + "MMS સંદેશા સ્વતઃ-પુનર્પ્રાપ્ત કરો" + "રોમિંગ સ્વતઃ-પુનર્પ્રાપ્ત કરો" + "રોમિંગ પર હોય ત્યારે MMS આપમેળે પુનર્પ્રાપ્ત કરો" + "જૂથ મેસેજિંગ" + "બહુવિધ પ્રાપ્તકર્તાઓ હોય ત્યારે એક જ સંદેશ મોકલવા માટે MMS નો ઉપયોગ કરો" + "ડિફોલ્ટ SMS એપ્લિકેશન" + "ડિફોલ્ટ SMS એપ્લિકેશન" + + + "તમારો ફોન નંબર" + "અજાણ્યો" + "આઉટગોઇંગ સંદેશ ધ્વનિઓ" + "SMS ડમ્પ કરો" + "પ્રાપ્ત થયેલ SMS અપૂર્ણ ડેટાને બાહ્ય સંગ્રહ ફાઇલમાં ડમ્પ કરો" + "MMS ડમ્પ કરો" + "પ્રાપ્ત થયેલ MMS અપૂર્ણ ડેટાને બાહ્ય સંગ્રહ ફાઇલમાં ડમ્પ કરો" + "વાયરલેસ ચેતવણીઓ" + "સંદેશ વિકલ્પો" + "ટેક્સ્ટ કૉપિ કરો" + "વિગતો જુઓ" + "કાઢી નાખો" + "ફોરવર્ડ કરો" + "સંદેશ વિગતો" + "પ્રકાર: " + "ટેક્સ્ટ સંદેશ" + "મલ્ટિમીડિયા સંદેશ" + "અહીંથી: " + "પ્રતિ: " + "મોકલ્યું: " + "પ્રાપ્ત કર્યુ: " + "વિષય: " + "કદ: " + "પ્રાધાન્યતા: " + "SIM: " + "ઉચ્ચ" + "સામાન્ય" + "નિમ્ન" + "SIM %s" + "પ્રેષક સરનામું છુપાયેલ છે" + "જોડાણો લોડ કરતી વખતે સંદેશ મોકલી શકાતો નથી." + "જોડાણ લોડ કરી શકાતું નથી. ફરીથી પ્રયાસ કરો." + "નેટવર્ક તૈયાર નથી. ફરીથી પ્રયાસ કરો." + "ટેક્સ્ટ કાઢી નાખો" + "ટેક્સ્ટ અને સંખ્યાઓ દાખલ કરવા વચ્ચે સ્વિચ કરો" + "વધુ સહભાગીઓ ઉમેરો" + "સહભાગીઓની પુષ્ટિ કરો" + "નવો વાર્તાલાપ પ્રારંભ કરો" + "આ આઇટમ પસંદ કરો" + "વિડિઓ ચલાવો" + "લોકો અને વિકલ્પો" + "ડીબગ" + "લોકો અને વિકલ્પો" + "સામાન્ય" + "આ વાર્તાલાપમાંના લોકો" + "એક કૉલ કરો" + "સંદેશ મોકલો" + "%s</small> દ્વારા <br/><small>સંદેશ મોકલો" + + ફોટા મોકલો + ફોટા મોકલો + + + ઑડિઓ મોકલો + ઑડિઓ મોકલો + + + વિડિઓઝ મોકલો + વિડિઓઝ મોકલો + + + સંપર્ક કાર્ડ્સ મોકલો + સંપર્ક કાર્ડ્સ મોકલો + + + જોડાણ મોકલો + જોડાણ મોકલો + + + મોકલવા માટે %d જોડાણ તૈયાર છે + મોકલવા માટે %d જોડાણ તૈયાર છે + + "પ્રતિસાદ મોકલો" + "Google Play Store માં જુઓ" + "સંસ્કરણ માહિતી" + "સંસ્કરણ %1$s" + "ઓપન સોર્સ લાઇસેંસેસ" + "સૂચનાઓ" + "જોડાણની મર્યાદા સુધી પહોંચ્યાં" + "જોડાણ લોડ કરવામાં નિષ્ફળ." + "સંપર્કોમાં ઉમેરીએ?" + "સંપર્ક ઉમેરો" + "વિષય" + "વિષય: " + "%s%s" + "સંપર્ક કાર્ડ લોડ કરી રહ્યું છે" + "સંપર્ક કાર્ડ લોડ કરી શકાયું નથી" + "સંપર્ક કાર્ડ જુઓ" + + %d સંપર્કો + %d સંપર્કો + + "સંપર્ક કાર્ડ્સ" + "જન્મદિવસ" + "નોંધો" + "સંદેશ ફોરવર્ડ કરો" + "જવાબ આપો" + "પસંદ" + "+%d" + "SMS અક્ષમ કરેલ છે" + "મોકલવા માટે, મેસેજિંગને ડિફોલ્ટ SMS એપ્લિકેશન તરીકે સેટ કરો" + "મેસેજિંગને ડિફોલ્ટ SMS એપ્લિકેશન તરીકે સેટ કરો" + "બદલો" + "સંદેશા પ્રાપ્ત કરવા માટે, મેસેજિંગને ડિફોલ્ટ SMS એપ્લિકેશન તરીકે સેટ કરો" + "SMS સંદેશા મોકલવા માટે પસંદીદા SIM પસંદ કરેલ નથી" + "આ એપ્લિકેશનને ઉપકરણ માલિક દ્વારા મંજૂરી નથી." + "ઑકે" + "વાર્તાલાપમાં ઘણાં બધા સહભાગીઓ છે" + + અમાન્ય સંપર્કો + અમાન્ય સંપર્કો + + "કૅમેરા છબી લોડ કરી શકાઈ નથી" + "તમે: " + "%s: " + "ડ્રાફ્ટ" + "એકવાર તમે એક નવો વાર્તાલાપ પ્રારંભ કરી લો તે પછી, તમે તેને અહીં સૂચિબદ્ધ જોશો" + "આર્કાઇવ કરેલ વાર્તાલાપ અહીં દેખાશે" + "વાર્તાલાપ લોડ કરી રહ્યાં છે…" + "ચિત્ર" + "ઑડિઓ ક્લિપ" + "વિડિઓ" + "સંપર્ક કાર્ડ" + "MMS" + "પૂર્વવત્ કરો" + "ફરી પ્રયાસ કરો" + "એક નવો સંદેશ પ્રારંભ કરવા માટે સંપર્ક નામ અથવા ફોન નંબર દાખલ કરો" + "અવરોધિત કરો" + "%s ને અવરોધિત કરો" + "%s ને અનાવરોધિત કરો" + "%s ને અવરોધિત કરીએ?" + "તમે આ નંબરથી સંદેશા પ્રાપ્ત કરવાનું ચાલુ રાખશો પરંતુ હવેથી સૂચિત કરવામાં આવશે નહીં. આ વાર્તાલાપ આર્કાઇવ કરવામાં આવશે." + "અવરોધિત કરેલા સંપર્કો" + "અનાવરોધિત કરો" + "અવરોધિત કરેલા સંપર્કો" + "દસ્તાવેજ લાઇબ્રેરીમાંથી છબી પસંદ કરો" + "સંદેશ મોકલી રહ્યાં છીએ" + "સંદેશ મોકલ્યો" + "સેલ્યુલર ડેટા બંધ છે. તમારી સેટિંગ્સ તપાસો." + "એરપ્લેન મોડમાં સંદેશા મોકલી શકાતા નથી" + "સંદેશ મોકલી શકાયો નથી" + "સંદેશ ડાઉનલોડ થયો" + "સેલ્યુલર ડેટા બંધ છે. તમારી સેટિંગ્સ તપાસો." + "એરપ્લેન મોડમાં સંદેશા ડાઉનલોડ કરી શકાતા નથી" + "સંદેશ ડાઉનલોડ કરી શકાયો નથી" + "શૂન્ય" + "એક" + "બે" + "ત્રણ" + "ચાર" + "પાંચ" + "છ" + "સાત" + "આઠ" + "નવ" + "%1$s સાથે સંદેશ મોકલી શકાતો નથી, ભૂલ %2$d" + "અજાણ્યાં કેરીઅર સાથે સંદેશ મોકલી શકાતો નથી, ભૂલ %1$d" + "ફોરવર્ડ: %s" + "સંદેશ મોકલાયો નથી: નેટવર્ક પર સેવા સક્રિય કરેલ નથી" + "સંદેશ મોકલાયો નથી: અમાન્ય લક્ષ્યસ્થાન સરનામું" + "સંદેશ મોકલાયો નથી: અમાન્ય સંદેશ" + "સંદેશ મોકલાયો નથી: અસમર્થિત સામગ્રી" + "સંદેશ મોકલાયો નથી: અસમર્થિત સંદેશ" + "સંદેશ મોકલ્યો નથી: ખૂબ મોટો" + "નવો સંદેશ" + "જુઓ" + "છબી" + "કોઈ યોગ્ય એપ્લિકેશન મળી શકી નથી" + "પ્રાપ્તકર્તા દૂર કરો" + "નવો સંદેશ" + "રદ કરો" + "અ‍ૅક્સેસ પોઇન્ટ સંપાદિત કરો" + "સેટ નથી" + "નામ" + "APN" + "MMSC" + "MMS પ્રોક્સી" + "MMS પોર્ટ" + "MCC" + "MNC" + "APN પ્રકાર" + "APN કાઢી નાખો" + "નવું APN" + "સાચવો" + "છોડી દો" + "નામ ફીલ્ડ ખાલી હોઈ શકતી નથી." + "APN ખાલી હોઈ શકતું નથી." + "MCC ફીલ્ડ 3 અંકોની હોવી આવશ્યક છે." + "MNC ફીલ્ડ 2 અથવા 3 અંકોની હોવી આવશ્યક છે." + "ડિફોલ્ટ APN સેટિંગ્સ પુનર્સ્થાપિત કરવી." + "ડિફોલ્ટ પર ફરીથી સેટ કરો" + "ડિફોલ્ટ APN સેટિંગ્સ ફરીથી સેટ કરો પૂર્ણ થયું." + "મથાળા વગર" + "ઍક્સેસ પોઇન્ટનું નામ" + "APNs" + "નવું APN" + "અ‍ૅક્સેસ પોઇન્ટનું નામ સેટિંગ્સ આ વપરાશકર્તા માટે ઉપલબ્ધ નથી" + "ક્લિપબોર્ડ પર કૉપિ કરીએ?" + "કૉપિ કરો" + "%s પર" + "સામાન્ય" + "વિગતવાર" + "સામાન્ય સેટિંગ્સ" + "વિગતવાર સેટિંગ્સ" + "\"%s\" SIM" + "તમામ પ્રાપ્તકર્તાઓને વ્યક્તિગત SMS સંદેશાઓ મોકલો. ફક્ત તમને જ કોઇ જવાબો મળશે" + "તમામ પ્રાપ્તકર્તાઓને એક જ MMS મોકલો" + "અજાણ્યો નંબર" + "નવો સંદેશ" + "નવો સંદેશ." + "SIM પસંદગીકર્તા" + "%1$s પસંદ કર્યા, SIM પસંદગીકર્તા" + "વિષય સંપાદિત કરો" + "SIM પસંદ કરો અથવા વિષય સંપાદિત કરો" + "ઑડિઓ રેકોર્ડ કરવા માટે ટચ કરો અને પકડો" + "નવો વાર્તાલાપ પ્રારંભ કરો" + "મેસેજિંગ" + "મેસેજિંગ સૂચિ" + "મેસેજિંગ" + "નવો સંદેશ" + "વાર્તાલાપની સૂચિ" + "વાર્તાલાપ લોડ કરી રહ્યાં છે" + "સંદેશા લોડ કરી રહ્યું છે" + "વધુ વાર્તાલાપ જુઓ" + "વધુ સંદેશા જુઓ" + "વાર્તાલાપ કાઢી નખાયો" + "વાર્તાલાપ કાઢી નાખ્યો. એક ભિન્ન મેસેજિંગ વાર્તાલાપ દર્શાવવા માટે ટચ કરો" + "અવરોધિત" + "અનાવરોધિત" + "સંગ્રહ સ્થાન ઓછું છે. કેટલોક ડેટા ખોવાઈ શકે છે." + "જોડાણો પસંદ કરો" + "પસંદગીની પુષ્ટિ કરો" + "%d પસંદ કર્યાં" + "કૃપા કરીને એક અથવા વધુ જોડાણો દૂર કરો અને ફરીથી પ્રયાસ કરો." + "તમે તમારો સંદેશ મોકલવાનો પ્રયાસ કરી શકો છો, પરંતુ તમે એક અથવા વધુ જોડાણો દૂર નહીં કરો ત્યાં સુધી તે વિતરિત કરવામાં આવી શકશે નહીં." + "તમે પ્રતિ સંદેશ ફક્ત એક વિડિઓ મોકલી શકો છો. કૃપા કરીને અતિરિક્ત વિડિઓઝ દૂર કરો અને ફરી પ્રયાસ કરો." + "જોડાણ લોડ કરવામાં મેસેજિંગ નિષ્ફળ." + "કોઈપણ રીતે મોકલો" + "વાર્તાલાપ પ્રારંભ કરી શકાયો નથી" + "%1$s (%2$s)" + "%s પસંદ કરી" + diff --git a/res/values-hi/arrays.xml b/res/values-hi/arrays.xml new file mode 100644 index 0000000..abdcedf --- /dev/null +++ b/res/values-hi/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "कोई विषय नहीं" + "कोई विषय नहीं" + + + "हां" + "नहीं" + "ठीक" + "हेहे" + "धन्यवाद" + "मैं सहमत हूं" + "बढ़िया" + "मेरे रास्ते में रहने के दौरान" + "ठीक है, मैं आपसे बाद में संपर्क करूंगा/करूंगी" + ":)" + ":(" + + diff --git a/res/values-hi/strings.xml b/res/values-hi/strings.xml new file mode 100644 index 0000000..c281680 --- /dev/null +++ b/res/values-hi/strings.xml @@ -0,0 +1,519 @@ + + + + + "संदेश सेवा" + "संदेश सेवा" + "वार्तालाप चुनें" + "सेटिंग" + "संदेश भेजें" + "कोई अटैचमेंट जोड़ें" + "सहायता" + "स्वागत है" + "अभी नहीं" + "अगला >" + "अगला" + "बाहर निकलें" + "सेटिंग >" + "सेटिंग" + "संदेश सेवा को SMS, फ़ोन और संपर्कों के लिए अनुमति की आवश्यकता होती है." + "आप अनुमतियों को सेटिंग > ऐप्स > संदेश सेवा > अनुमतियां में बदल सकते हैं." + "आप अनुमतियों को सेटिंग, ऐप्‍स, संदेश सेवा, अनुमतियां में बदल सकते हैं." + "अक्सर" + "सभी संपर्क" + "%s पर भेजें" + "चित्र या वीडियो कैप्चर करें" + "इस डिवाइस के चित्र चुनें" + "ऑडियो रिकॉर्ड करें" + "फ़ोटो चुनें" + "मीडिया को चुना है." + "मीडिया को नहीं चुना है." + "%d चुने गए" + "चित्र %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "चित्र" + "ऑडियो रिकॉर्ड करें" + "साझा करें" + "अभी-अभी" + "अभी" + + %d मिनट + %d मिनट + + + %d घंटे + %d घंटे + + + %d दिन + %d दिन + + + %d सप्‍ताह + %d सप्‍ताह + + + %d महीने + %d महीने + + + %d वर्ष + %d वर्ष + + "वर्ग 0 संदेश" + "जोड़ें" + "डिवाइस में स्थान कम है. स्थान खाली करने के लिए संदेश सेवा पुराने संदेशों को अपने आप हटा देगी." + "मेमोरी स्‍थान समाप्‍त हो रहा है" + "हो सकता है कि आपके डिवाइस पर अधिक स्‍थान उपलब्‍ध होने तक संदेश सेवा संदेशों को ना भेजे या प्राप्‍त ना करे." + "कम SMS मेमोरी. आपको संदेशों को हटाने की आवश्‍यकता हो सकती है." + "अपने फ़ोन नंबर की पुष्टि करें" + "यह एक-बार का चरण यह सुनिश्चित करेगा कि संदेश सेवा आपके समूह संदेशों को ठीक से वितरित करेगी." + "फ़ोन नंबर" + "मीडिया वाले सभी संदेशों को हटाएं" + "%s से पुराने संदेशों को हटाएं" + "%s से पुराने संदेशों को स्‍वत: हटाएं" + "अनदेखा करें" + "मीडिया वाले सभी संदेशों को हटाएं?" + "%s से पुराने संदेशों को हटाएं?" + "%s से पुराने संदेश हटाएं और स्‍वत: हटाना चालू करें?" + "प्रेषक: %s" + "आपने कहा" + "%s की ओर से संदेश" + "आपने एक संदेश भेजा" + "भेजा जा रहा है..." + "नहीं भेजा गया. पुन: प्रयास करने के लिए स्‍पर्श करें." + "नहीं भेजा गया. पुन: प्रयास किया जा रहा है…" + "फिर से भेजें या हटाएं" + "कृपया आपातकालीन सेवाओं को वॉइस कॉल करें. आपका लेख संदेश इस समय वितरित नहीं किया जा सकता." + "विफल रहा" + "डाउनलोड करने के लिए नया MMS संदेश" + "नया MMS संदेश" + "डाउनलोड नहीं किया जा सका" + "पुन: प्रयास करने के लिए स्पर्श करें" + "डाउनलोड करने के लिए स्‍पर्श करें" + "डाउनलोड करें या हटाएं" + "डाउनलोड किया जा रहा है..." + "संदेश की समय सीमा समाप्त हो गई या वह उपलब्ध नहीं है" + "आकार: %1$s, समाप्‍ति: %2$s" + "भेजा नहीं जा सकता. प्राप्‍तकर्ता मान्‍य नहीं है." + "नेटवर्क पर सेवा सक्रिय नहीं है" + "नेटवर्क समस्‍या के कारण भेजा नहीं जा सका" + "संदेश की समय सीमा समाप्त हो गई या वह उपलब्ध नहीं है" + "(कोई विषय नहीं)" + "अज्ञात प्रेषक" + "डिलीवर कर दिया गया" + "%2$s से %1$s संदेश डाउनलोड नहीं किया जा सका." + "कम मेमोरी के कारण डेटाबेस कार्रवाई पूर्ण नहीं किया जा सका" + "संदेश नहीं भेजा गया" + "कुछ संदेशों को संदेश सेवा में नहीं भेजा गया है" + + %d वार्तालापों में %d संदेश + %d वार्तालापों में %d संदेश + + "संदेश डाउनलोड नहीं किया गया" + "कुछ संदेशों को संदेश सेवा में डाउनलोड नहीं किया गया है" + + %d वार्तालापों में %d संदेश + %d वार्तालापों में %d संदेश + + "%1$s को संदेश नहीं भेजा गया" + "कृपया आपातकालीन सेवाओं को वॉइस कॉल करें. %1$s को भेजा गया आपका लेख संदेश इस समय वितरित नहीं किया जा सकता." + + %d नए संदेश + %d नए संदेश + + "प्रारंभ करें" + "कैमरा उपलब्ध नहीं है" + "कैमरा उपलब्ध नहीं है" + "वीडियो कैप्‍चर उपलब्‍ध नहीं है" + "मीडिया सहेजा नहीं जा सकता" + "चित्र नहीं लिया जा सकता" + "पीछे" + "संग्रहीत" + "हटाएं" + "संग्रहीत करें" + "संग्रह से निकालें" + "नोटिफिकेशन बंद करें" + "नोटिफिकेशन चालू करें" + "संपर्क जोड़ें" + "डाउनलोड करें" + "भेजें" + "हटाएं" + "इस संदेश को हटाएं?" + "इस क्रिया को पूर्ववत नहीं किया जा सकता." + "हटाएं" + + इन वार्तालापों को हटाएं? + इन वार्तालापों को हटाएं? + + "हटाएं" + "अभी नहीं" + "प्रति" + "एकाधिक चित्र चुनें" + "चयन की दुबारा पूछें" + "+%d" + "ऑडियो रिकॉर्ड नहीं किया जा सकता. पुनः प्रयास करें." + "ऑडियो नहीं चलाया जा सकता. पुन: प्रयास करें." + "ऑडियो सहेजा नहीं जा सका. पुनः प्रयास करें." + "स्पर्श करके रखें" + ", " + " " + ": " + " " + "चित्र" + "ऑडियो क्लिप" + "वीडियो" + "संपर्क कार्ड" + "डाउनलोड करें" + "SMS के द्वारा उत्तर दें" + "MMS से उत्तर दें" + "उत्तर दें" + + %d प्रतिभागी + %d प्रतिभागी + + "मुझे" + "संपर्क अवरोधित तथा संग्रहीत किया गया" + "संपर्क को अनवरोधित किया गया और संग्रह से निकाला गया" + "%d को संग्रहीत किया गया" + "%d को संग्रह से निकाला गया" + "नोटिफ़िकेशन बंद हैं" + "नोटिफ़िकेशन चालू हैं" + "बिल्‍कुल तैयार. भेजें को पुन: स्‍पर्श करें." + "संदेश सेवा को डिफ़ॉल्ट SMS ऐप के रूप में सफलतापूर्वक सेट कर दिया गया है." + + अटैचमेंट छोड़ें + अटैचमेंट छोड़ें + + "ऑडियो अटैचमेंट" + "ऑडियो अटैचमेंट चलाएं" + "रोकें" + "%s की ओर से संदेश: %s." + "%s का भेजने में विफल रहा संदेश: %s. समय: %s." + "%s का संदेश: %s. समय: %s." + "%s को नहीं भेजा गया संदेश: %s. समय: %s." + "%s को संदेश भेजा जा रहा है: %s. समय: %s." + "%s को भेजने में विफल रहा संदेश: %s. समय: %s." + "%s के लिए संदेश: %s. समय: %s." + "%s का विफल रहा संदेश: %s. समय: %s. %s." + "%s का संदेश: %s. समय: %s. %s." + "%s को नहीं भेजा गया संदेश: %s. समय: %s." + "%s को संदेश भेजा जा रहा है: %s. समय: %s." + "%s को भेजने में विफल रहा संदेश: %s. समय: %s." + "%s के लिए संदेश: %s. समय: %s." + "विफल रहा संदेश. पुन: प्रयास करने के लिए स्पर्श करें." + "%s के साथ बातचीत" + "विषय हटाएं" + "वीडियो कैप्चर करें" + "स्थिर चित्र कैप्चर करें" + "चित्र लें" + "वीडियो रिकॉर्ड करना प्रारंभ करें" + "पूर्ण स्क्रीन कैमरा पर स्विच करें" + "सामने और पीछे वाले कैमरे के बीच स्‍विच करें" + "रिकॉर्डिंग रोकें और वीडियो अटैच करें" + "वीडियो रिकॉर्ड करना बंद करें" + "संदेश सेवा फ़ोटो" + + %d फ़ोटो \"%s\" एल्बम में सहेजी गईं + %d फ़ोटो \"%s\" एल्बम में सहेजी गईं + + + %d वीडियो \"%s\" एल्बम में सहेजे गए + %d वीडियो \"%s\" एल्बम में सहेजे गए + + + %d अटैचमेंट \"%s\" एल्बम में सहेजे गए + %d अटैचमेंट \"%s\" एल्बम में सहेजे गए + + + %d अटैचमेंट \"डाउनलोड\" में सहेजे गए + %d अटैचमेंट \"डाउनलोड\" में सहेजे गए + + + %d अटैचमेंट सहेजे गए + %d अटैचमेंट सहेजे गए + + + %d अटैचमेंट सहेजे नहीं जा सके + %d अटैचमेंट सहेजे नहीं जा सके + + "MMS अटैचमेंट सहेजा गया" + "सेटिंग" + "संग्रहीत" + "बंद करें" + "MMS" + "अतिरिक्त सेटिंग" + "डीबग" + "नोटिफिकेशन" + "ध्वनि" + "मौन" + "वाइब्रेट" + "अवरोधित" + "SMS वितरण रिपोर्ट" + "आपके द्वारा भेजे गए प्रत्येक SMS की वितरण रिपोर्ट का अनुरोध करें" + "स्वतः पुनर्प्राप्ति" + "MMS संदेशों को अपने आप प्राप्त करें" + "रोमिंग स्वतः पुनर्प्राप्ति" + "रोमिंग के दौरान MMS अपने आप प्राप्त करें" + "समूह संदेश सेवा" + "एक से अधिक प्राप्तकर्ता होने पर एक ही संदेश भेजने के लिए MMS का उपयोग करें" + "डिफ़ॉल्ट SMS ऐप" + "डिफ़ॉल्ट SMS ऐप" + + + "आपका फ़ोन नंबर" + "अज्ञात" + "आउटगोइंग संदेश ध्वनि" + "SMS डंप करें" + "बाहरी मेमोरी फ़ाइल में प्राप्त किया गया SMS अपरिष्कृत डेटा डंप करें" + "MMS डंप करें" + "बाहरी मेमोरी फ़ाइल में प्राप्त किया गया MMS अपरिष्कृत डेटा डंप करें" + "वायरलेस अलर्ट" + "संदेश के विकल्प" + "लेख की प्रतिलिपि बनाएं" + "विवरण देखें" + "हटाएं" + "अग्रेषित करें" + "संदेश के विवरण" + "प्रकार: " + "लेख संदेश" + "मल्टीमीडिया संदेश" + "प्रेषक: " + "प्रति: " + "भेजा गया: " + "प्राप्त हुआ: " + "विषय: " + "आकार: " + "प्राथमिकता: " + "सिम: " + "उच्च" + "सामान्य" + "निम्न" + "सिम %s" + "प्रेषक का पता छिपाएं" + "अटैचमेंट लोड करते समय संदेश नहीं भेजा जा सकता." + "अटैचमेंट लोड नहीं किया जा सकता. पुन: प्रयास करें." + "नेटवर्क तैयार नहीं है. पुनः प्रयास करें." + "लेख हटाएं" + "लेख और संख्याएं डालने के बीच स्विच करें" + "और अधिक प्रतिभागी जोड़ें" + "प्रतिभागियों की पुष्‍टि करें" + "नई बातचीत प्रारंभ करें" + "यह आइटम चुनें" + "वीडियो चलाएं" + "लोग और विकल्प" + "डीबग" + "लोग और विकल्प" + "सामान्य" + "इस वार्तालाप में लोग" + "कॉल करें" + "संदेश भेजें" + "<br/><small>from %s</small> से संदेश भेजें" + + फ़ोटो भेजें + फ़ोटो भेजें + + + ऑडियो भेजें + ऑडियो भेजें + + + वीडियो भेजें + वीडियो भेजें + + + संपर्क कार्ड भेजें + संपर्क कार्ड भेजें + + + अटैचमेंट भेजें + अटैचमेंट भेजें + + + %d अटैचमेंट भेजे जाने के लिए तैयार हैं + %d अटैचमेंट भेजे जाने के लिए तैयार हैं + + "फ़ीडबैक भेजें" + "Google Play स्टोर में देखें" + "वर्शन जानकारी" + "वर्शन %1$s" + "ओपन सोर्स लाइसेंस" + "नोटिफिकेशन" + "अटैचमेंट सीमा पर पहुंच गए" + "अटैचमेंट लोड करने में विफल रहा." + "संपर्कों में जोड़ें?" + "संपर्क जोड़ें" + "विषय" + "विषय: " + "%s%s" + "संपर्क कार्ड लोड हो रहा है" + "संपर्क कार्ड लोड नहीं किया जा सका" + "संपर्क कार्ड देखें" + + %d संपर्क + %d संपर्क + + "संपर्क कार्ड" + "जन्मदिन" + "नोट" + "संदेश अग्रेषित करें" + "जवाब दें" + "+1" + "+%d" + "SMS अक्षम है" + "भेजने के लिए, संदेश सेवा को डिफ़ॉल्ट SMS ऐप के रूप में सेट करें" + "संदेश सेवा को डिफ़ॉल्ट SMS ऐप के रूप में सेट करें" + "बदलें" + "संदेश प्राप्‍त करने के लिए, संदेश सेवा को डिफ़ॉल्‍ट SMS ऐप के रूप में सेट करें" + "SMS संदेश भेजने के लिए कोई भी पसंदीदा सिम नहीं चुनी गई है" + "डिवाइस स्‍वामी द्वारा इस ऐप्स की अनुमति नहीं दी गई है." + "ठीक" + "बातचीत में बहुत अधिक प्रतिभागी हैं" + + अमान्‍य संपर्क + अमान्‍य संपर्क + + "कैमरे का चित्र लोड नहीं किया जा सका" + "आप: " + "%s: " + "ड्राफ़्ट" + "आपके द्वारा नया वार्तालाप प्रारंभ करने के बाद, वह आपको यहां सूचीबद्ध दिखाई देगा" + "संग्रहीत वार्तालाप यहां दिखाई देते हैं" + "वार्तालाप लोड हो रहे हैं…" + "चित्र" + "ऑडियो क्लिप" + "वीडियो" + "संपर्क कार्ड" + "MMS" + "वापस लाएं" + "पुन: प्रयास करें" + "नया संदेश प्रारंभ करने के लिए कोई संपर्क नाम या फ़ोन नंबर डालें" + "अवरुद्ध करें" + "%s को अवरुद्ध करें" + "%s को अनवरोधित करें" + "%s को अवरोधित करें?" + "आपको इस संदेश से नंबर प्राप्‍त होते रहेंगे लेकिन अब उनकी सूचना नहीं दी जाएगी. इस वार्तालाप को संग्रहीत किया जाएगा." + "अवरोधित संपर्क" + "अनवरोधित करें" + "अवरोधित संपर्क" + "दस्‍तावेज़ लाइब्रेरी से चित्र चुनें" + "संदेश भेजा जा रहा है" + "संदेश भेजा गया" + "सेलुलर डेटा बंद हो गया है. अपनी सेटिंग जांचें." + "हवाई जहाज़ मोड में संदेश नहीं भेजे जा सकते" + "संदेश भेजा नहीं जा सका" + "संदेश डाउनलोड किया गया" + "सेल्‍युलर डेटा बंद हो गया है. अपनी सेटिंग जांचें." + "संदेश को हवाई जहाज़ मोड में डाउनलोड नहीं किया जा सकता" + "संदेश डाउनलोड नहीं किया जा सका" + "शून्य" + "एक" + "दो" + "तीन" + "चार" + "पांच" + "छह" + "सात" + "आठ" + "नौ" + "%1$s के साथ संदेश नहीं भेजा जा सकता, त्रुटि %2$d" + "अज्ञात वाहक के साथ संदेश नहीं भेजा जा सकता, त्रुटि %1$d" + "अग्रेषित: %s" + "संदेश नहीं भेजा गया: नेटवर्क पर सेवा सक्रिय नहीं है" + "संदेश नहीं भेजा गया: अमान्य गंतव्य पता" + "संदेश नहीं भेजा गया: अमान्य संदेश" + "संदेश नहीं भेजा गया: असमर्थित सामग्री" + "संदेश नहीं भेजा गया: असमर्थित संदेश" + "संदेश नहीं भेजा गया: बहुत बड़ा है" + "नया संदेश" + "देखें" + "चित्र" + "उपयुक्त ऐप्लिकेशन नहीं मिला" + "प्राप्तकर्ता को निकालें" + "नया संदेश" + "अभी नहीं" + "पहुंच बिंदु संपादित करें" + "सेट नहीं है" + "नाम" + "APN" + "MMSC" + "MMS प्रॉक्‍सी" + "MMS पोर्ट" + "MCC" + "MNC" + "APN प्रकार" + "APN हटाएं" + "नया APN" + "सहेजें" + "छोड़ें" + "नाम फ़ील्‍ड खाली नहीं हो सकता." + "APN खाली नहीं हो सकता." + "MCC फ़ील्‍ड में 3 अंक होने चाहिए." + "MCC फ़ील्‍ड में 2 या 3 अंक होने चाहिए." + "डिफ़ॉल्‍ट APN सेटिंग पुनर्स्थापित हो रही हैं." + "डिफ़ॉल्ट पर रीसेट करें" + "डिफ़ॉल्‍ट APN सेटिंग रीसेट करना पूर्ण हुआ." + "शीर्षक रहित" + "पहुंच बिंदु के नाम" + "APN" + "नया APN" + "एैक्सेस बिंदु नाम सेटिंग इस उपयोगकर्ता के लिए उपलब्ध नहीं हैं" + "क्लिपबोर्ड पर कॉपी बनाएं?" + "कॉपी बनाएं" + "%s पर" + "सामान्य" + "अतिरिक्त सेटिंस" + "सामान्य सेटिंग" + "अतिरिक्त सेटिंग" + "\"%s\" सिम" + "सभी प्राप्‍तकर्ताओं को व्‍यक्‍तिगत SMS संदेश भेजें. कोई भी जवाब केवल आपको प्राप्‍त होंगे" + "सभी प्राप्‍तकर्ताआें को एकल MMS भेजें" + "अज्ञात नंबर" + "नया संदेश" + "नया संदेश." + "सिम चयनकर्ता" + "%1$s चयनित, SIM चयनकर्ता" + "विषय संपादित करें" + "सिम चुनें या विषय संपादित करें" + "ऑडियो रिकॉर्ड करने के लिए स्‍पर्श करके रखें" + "नई बातचीत प्रारंभ करें" + "संदेश सेवा" + "संदेश सेवा सूची" + "संदेश सेवा" + "नया संदेश" + "वार्तालाप सूची" + "बातचीत लोड हो रही हैं…" + "संदेश लोड हो रहे हैं" + "और बातचीत देखें" + "अधिक संदेश देखें" + "वार्तालाप हटा दिया गया है" + "वार्तालाप हटा दिया गया. कोई भिन्न संदेश सेवा वार्तालाप दिखाने के लिए स्पर्श करें" + "अवरोधित" + "अनवरोधित" + "मेमोरी स्‍थान कम है. कुछ डेटा खो सकता है." + "अटैचमेंट चुनें" + "चयन की पुष्टि करें" + "%d चुने गए" + "कृपया एक या अधिक अटैचमेंट निकालें और पुन: प्रयास करें." + "आप अपना संदेश भेजकर देख सकते हैं, लेकिन यह तब तक वितरित नहीं होगा जब तक कि आप एक या कुछ और अटैचमेंट निकाल नहीं देते." + "आप प्रति संदेश केवल एक वीडियो भेज सकते हैं. कृपया अतिरिक्‍त वीडियो निकालें और पुन: प्रयास करें." + "संदेश सेवा अटैचमेंट लोड करने में विफल रही." + "फिर भी भेजें" + "बातचीत प्रारंभ नहीं की जा सकी" + "%1$s (%2$s)" + "%s चुनी गई" + diff --git a/res/values-hr/arrays.xml b/res/values-hr/arrays.xml new file mode 100644 index 0000000..5ebf706 --- /dev/null +++ b/res/values-hr/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "nema predmeta" + "nosubject" + + + "Da" + "Ne" + "U redu" + "He-he" + "Hvala" + "Slažem se" + "Lijepo" + "Krećem" + "U redu, javit ću se kasnije" + ":)" + ":(" + + diff --git a/res/values-hr/strings.xml b/res/values-hr/strings.xml new file mode 100644 index 0000000..e12b64f --- /dev/null +++ b/res/values-hr/strings.xml @@ -0,0 +1,545 @@ + + + + + "Slanje poruka" + "Slanje poruka" + "Odabir razgovora" + "Postavke" + "Pošalji poruku" + "Dodajte privitak" + "Pomoć" + "Dobro došli" + "Preskoči" + "Dalje >" + "Dalje" + "Izlaz" + "Postavke >" + "Postavke" + "Slanje poruka treba dopuštenje za SMS, Telefon i Kontakte." + "Dopuštenja možete promijeniti u Postavkama > Aplikacije > Slanje poruka > Dopuštenja." + "Dopuštenja možete promijeniti tako da u Postavkama otvorite Aplikacije, Slanje poruka, Dopuštenja." + "Često kontaktirani" + "Svi kontakti" + "Pošalji na %s" + "Snimite fotografije ili videozapis" + "Odaberite slike s ovog uređaja" + "Snimanje zvuka" + "Odaberite fotografiju" + "Medij je odabran." + "Odabir medija poništen je." + "Odabrano: %d" + "slika %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "slika" + "Snimanje zvuka" + "Dijeljenje" + "Upravo sada" + "Sad" + + %d min + %d min + %d min + + + %d sat + %d sata + %d sati + + + %d dan + %d dana + %d dana + + + %d tjedan + %d tjedna + %d tjedana + + + %d mjesec + %d mjeseca + %d mjeseci + + + %d godina + %d godine + %d godina + + "Poruka klase 0" + "Spremi" + "Na uređaju ponestaje prostora. Slanje poruka automatski će izbrisati starije poruke da bi se oslobodio prostor." + "Ponestaje prostora za pohranu" + "Slanje poruka možda neće slati ili primati poruke dok se na uređaju ne oslobodi više prostora." + "Nema dovoljno prostora za pohranu SMS-a. Možda ćete morati izbrisati poruke." + "Potvrđivanje telefonskog broja" + "Taj jednokratni korak omogućit će Slanju poruka pravilnu isporuku vaših grupnih poruka." + "Telefonski broj" + "Izbriši sve poruke s medijskim sadržajima" + "Izbriši poruke starije od %s" + "Automatski izbriši poruke starije od %s" + "Zanemari" + "Izbrisati sve poruke s medijskim sadržajima?" + "Izbrisati poruke starije od %s?" + "Izbrisati poruke starije od %s i uključiti automatsko brisanje?" + "%s kaže" + "Rekli ste" + "Poruka pošiljatelja %s" + "Poslali ste poruku" + "Slanje…" + "Nije poslano. Dodirnite za ponovni pokušaj." + "Nije poslano. Pokušavamo ponovo…" + "Ponovno slanje ili brisanje" + "Nazovite hitnu službu. Vaša tekstna poruka nije isporučena." + "Nije poslano" + "Nova MMS poruka za preuzimanje" + "Nova MMS poruka" + "Preuzimanje nije moguće" + "Dodirnite za ponovni pokušaj" + "Dodirnite za preuzimanje" + "Preuzimanje ili brisanje" + "Preuzimanje..." + "Poruka je istekla ili nije dostupna" + "veličina: %1$s, istek: %2$s" + "Nije poslano. Primatelj nije važeći." + "Usluga nije aktivirana na mreži" + "Slanje nije uspjelo zbog problema s mrežom" + "Poruka je istekla ili nije dostupna" + "(Bez predmeta)" + "Nepoznati pošiljatelj" + "Isporučeno" + "Preuzimanje poruke %1$s od pošiljatelja %2$s nije uspjelo." + "Radnja baze podataka nije dovršena zbog nedostatka memorije" + "Poruka nije poslana" + "Neke poruke nisu poslane u Slanju poruka" + + %d poruka u %d razgovoru + %d poruke u %d razgovora + %d poruka u %d razgovora + + "Poruka nije preuzeta" + "Neke poruke nisu preuzete u Slanje poruka" + + %d poruka u %d razgovoru + %d poruke u %d razgovora + %d poruka u %d razgovora + + "Poruka na broj %1$s nije poslana" + "Nazovite hitnu službu. Vaša tekstna poruka na broj %1$s nije isporučena." + + %d nova poruka + %d nove poruke + %d novih poruka + + "Započni" + "Fotoaparat nije dostupan" + "Fotoaparat nije dostupan" + "Snimanje videozapisa nije dostupno" + "Medij nije spremljen" + "Nije moguće snimiti sliku" + "Natrag" + "Arhivirano" + "Izbriši" + "Arhiviraj" + "Poništi arhiviranje" + "Isključi obavijesti" + "Uključi obavijesti" + "Dodaj kontakt" + "Preuzmi" + "Pošalji" + "Izbriši" + "Želite li izbrisati tu poruku?" + "Tu radnju nije moguće poništiti." + "Izbriši" + + Izbrisati razgovore? + Izbrisati razgovore? + Izbrisati razgovore? + + "Izbriši" + "Odustani" + "Odredište" + "Odaberi više slika" + "Potvrdite odabir" + "+%d" + "Audioporuka nije snimljena. Pokušajte ponovo." + "Audioporuku nije moguće reproducirati. Pokušajte ponovo." + "Audioporuka nije spremljena. Pokušajte ponovo." + "Dodirnite i zadržite" + ", " + " " + ": " + " " + "Slika" + "Audioisječak" + "Videozapis" + "Kartica kontakta" + "Preuzmi" + "Odgovorite putem SMS-a" + "Odgovor MMS-om" + "Odgovorite" + + %d sudionik + %d sudionika + %d sudionika + + "Ja" + "Kontakt je blokiran i arhiviran" + "Kontakt deblokiran i dearhiviran" + "Arhivirano: %d" + "Poništena arhiviranja: %d" + "Obavijesti isključene" + "Obavijesti uključene" + "Sve je postavljeno. Ponovo dodirnite Pošalji." + "Slanje poruka uspješno je postavljeno kao zadana aplikacija za SMS." + + Odbacivanje privitaka + Odbacivanje privitaka + Odbacivanje privitaka + + "Audioprivitak" + "Reproduciraj audioprivitak" + "Pauziraj" + "Poruka pošiljatelja %s: %s." + "Poruka pošiljatelja %s nije uspjela: %s. Vrijeme: %s" + "Poruka pošiljatelja %s: %s. Vrijeme: %s" + "Poruka za kontakt %s nije poslana: %s. Vrijeme: %s" + "Slanje poruke kontaktu %s: %s. Vrijeme: %s" + "Poruka za kontakt %s nije uspjela: %s. Vrijeme: %s" + "Poruka kontaktu %s: %s. Vrijeme: %s" + "Neuspjela poruka pošiljatelja %s: %s. Vrijeme: %s. %s" + "Poruka pošiljatelja %s: %s. Vrijeme: %s. %s" + "Poruka za grupu %s nije poslana: %s. Vrijeme: %s" + "Slanje poruke grupi %s: %s. Vrijeme: %s" + "Poruka za grupu %s nije uspjela: %s. Vrijeme: %s" + "Poruka grupi %s: %s. Vrijeme: %s" + "Poruka nije uspjela. Dodirnite za ponovni pokušaj." + "Razgovor sa sljedećima: %s" + "Brisanje predmeta" + "Snimi videozapis" + "Snimi fotografiju" + "Snimi sliku" + "Početak snimanja videozapisa" + "Prebacivanje na fotoaparat na punom zaslonu" + "Prebacivanje između prednjeg i stražnjeg fotoaparata" + "Zaustavi snimanje i priloži videozapis" + "Zaustavi snimanje videozapisa" + "Fotografije Slanja poruka" + + %d fotografija spremljena je u album \"%s\" + %d fotografije spremljene su u album \"%s\" + %d fotografija spremljeno je u album \"%s\" + + + %d videozapis spremljen je u album \"%s\" + %d videozapisa spremljena su u album \"%s\" + %d videozapisa spremljeno je u album \"%s\" + + + %d privitak spremljen je u album \"%s\" + %d privitka spremljena su u album \"%s\" + %d privitaka spremljeno je u album \"%s\" + + + %d privitak spremljen je u \"Preuzimanja\" + %d privitka spremljena su u \"Preuzimanja\" + %d privitaka spremljeno je u \"Preuzimanja\" + + + %d privitak spremljen + %d privitka spremljena + %d privitaka spremljeno + + + Spremanje %d privitka nije uspjelo + Spremanje %d privitaka nije uspjelo + Spremanje %d privitaka nije uspjelo + + "MMS privitak spremljen" + "Postavke" + "Arhivirano" + "Zatvori" + "MMS" + "Napredno" + "Otklanjanje pogrešaka" + "Obavijesti" + "Zvuk" + "Bešumno" + "Vibriranje" + "Blokirano" + "Izvješća o isporuci SMS-a" + "Zahtijevanje izvješća o isporuci za svaki poslani SMS" + "Automatski dohvat" + "Automatsko dohvaćanje MMS-ova" + "Automatski dohvati u roamingu" + "Automatsko dohvaćanje MMS-ova u roamingu" + "Grupne poruke" + "Slanje jedne poruke za više primatelja pomoću MMS-a" + "Zadana aplikacija za SMS" + "Zadana aplikacija za SMS" + + + "Vaš telefonski broj" + "Nepoznato" + "Zvukovi odlazne poruke" + "Sigurnosno kopiraj SMS-ove" + "Sigurnosno kopiranje neobrađenih podataka primljenih SMS-ova u datoteku vanjske pohrane" + "Sigurnosno kopiraj MMS-ove" + "Sigurnosno kopiranje neobrađenih podataka primljenih MMS-ova u datoteku vanjske pohrane" + "Upozorenja putem mobilne mreže" + "Opcije poruka" + "Kopiraj tekst" + "Prikaz pojedinosti" + "Izbriši" + "Proslijedi" + "Pojedinosti poruke" + "Vrsta: " + "SMS poruka" + "Multimedijska poruka" + "Šalje: " + "Prima: " + "Poslano: " + "Primljeno: " + "Predmet: " + "Veličina: " + "Prioritet: " + "SIM: " + "Visok" + "Uobičajeni" + "Niski" + "SIM %s" + "Skrivena adresa pošiljatelja" + "Slanje poruke nije moguće tijekom učitavanja privitaka." + "Privitak nije učitan. Pokušajte ponovo." + "Mreža nije spremna. Pokušajte ponovo." + "Brisanje teksta" + "Prebacivanje između unosa teksta i brojeva" + "Dodaj još sudionika" + "Potvrdi sudionike" + "Započnite novi razgovor" + "Odaberite ovu stavku" + "Reproduciraj videozapis" + "Osobe i opcije" + "Otkloni pogreške" + "Osobe i opcije" + "Općenito" + "Osobe u ovom razgovoru" + "Uputite poziv" + "Pošaljite poruku" + "Pošalji poruku<br/><small>sa SIM kartice %s</small>" + + Pošaljite fotografije + Pošaljite fotografije + Pošaljite fotografije + + + Pošaljite audiozapise + Pošaljite audiozapise + Pošaljite audiozapise + + + Pošaljite videozapise + Pošaljite videozapise + Pošaljite videozapise + + + Pošaljite kartice kontakata + Pošaljite kartice kontakata + Pošaljite kartice kontakata + + + Pošaljite privitke + Pošaljite privitke + Pošaljite privitke + + + %d privitak spreman je za slanje + %d privitka spremna su za slanje + %d privitaka spremno je za slanje + + "Slanje povratnih informacija" + "Prikaži u Trgovini Google Play" + "Informacije o verziji" + "Verzija %1$s" + "Licence otvorenog koda" + "Obavijesti" + "Dosegnuto je ograničenje za privitke" + "Učitavanje privitka nije uspjelo." + "Želite li dodati u Kontakte?" + "Dodavanje kontakta" + "Predmet" + "Predmet: " + "%s%s" + "Učitavanje kartice kontakta" + "Kartica kontakta nije učitana" + "Prikaz kartice kontakta" + + %d kontakt + %d kontakta + %d kontakata + + "Kartice kontakata" + "Rođendan" + "Bilješke" + "Proslijedi poruku" + "Odgovori" + "+1" + "+%d" + "SMS je onemogućen" + "Postavite Slanje poruka kao zadanu aplikaciju za SMS da biste slali poruke" + "Postavite Slanje poruka kao zadanu aplikaciju za SMS" + "Promijeni" + "Postavite Slanje poruka kao zadanu aplikaciju za SMS da biste primali poruke" + "Nije odabran preferirani SIM za slanje SMS poruka" + "Vlasnik uređaja nije odobrio upotrebu te aplikacije." + "U redu" + "Previše sudionika u razgovoru" + + Nevažeći kontakti + Nevažeći kontakti + Nevažeći kontakti + + "Slika nije učitana" + "Vi: " + "%s: " + "Skica" + "Ovdje će biti naveden novi razgovor kada ga pokrenete" + "Arhivirani razgovori prikazuju se ovdje" + "Učitavanje razgovora..." + "Slika" + "Audioisječak" + "Videozapis" + "Kartica kontakta" + "MMS" + "Poništi" + "Pokušaj ponovo" + "Unesite ime kontakta ili telefonski broj da biste započeli novu poruku" + "Blokiraj" + "Blokiranje %s" + "Deblokiraj %s" + "Blokirati %s?" + "I dalje ćete primati poruke s tog broja, ali više nećete primati obavijesti o tome. Razgovor će se arhivirati." + "Blokirani kontakti" + "DEBLOKIRAJ" + "Blokirani kontakti" + "Odaberite sliku iz knjižnice dokumenata" + "Slanje poruke" + "Poruka je poslana" + "Mobilni su podaci isključeni. Provjerite postavke." + "Ne možete slati poruke u načinu rada u zrakoplovu" + "Slanje poruke nije uspjelo" + "Poruka preuzeta" + "Mobilni su podaci isključeni. Provjerite postavke." + "Nije moguće preuzimanje poruka u načinu rada u zrakoplovu" + "Preuzimanje poruke nije uspjelo" + "Nula" + "Jedan" + "Dva" + "Tri" + "Četiri" + "Pet" + "Šest" + "Sedam" + "Osam" + "Devet" + "Poruka nije poslana putem operatera %1$s, pogreška %2$d" + "Poruka nije poslana putem nepoznatog mobilnog operatera, pogreška %1$d" + "Fwd: %s" + "Poruka nije poslana jer usluga nije aktivirana na mreži" + "Poruka nije poslana jer odredišna adresa nije važeća" + "Poruka nije poslana jer nije važeća" + "Poruka nije poslana jer sadržaj nije podržan" + "Poruka nije poslana jer nije podržana" + "Poruka nije poslana jer je prevelika" + "Nova poruka" + "Prikaz" + "Slika" + "Nije pronađena odgovarajuća aplikacija" + "Uklanjanje primatelja" + "Nova poruka" + "Odustani" + "Uređivanje pristupne točke" + "Nije postavljeno" + "Naziv" + "APN" + "MMSC" + "MMS proxy" + "MMS priključak" + "MCC" + "MNC" + "Vrsta APN-a" + "Izbriši APN" + "Novi APN" + "Spremi" + "Odbaci" + "Polje naziva ne može biti prazno." + "APN ne može biti prazan." + "MCC polje mora imati 3 znamenke." + "Polje MNC mora imati 2 ili 3 znamenke." + "Vraćaju se zadane postavke APN-a." + "Vrati na zadano" + "Dovršeno je vraćanje zadanih postavki APN-a." + "Bez naziva" + "Nazivi pristupnih točaka" + "APN-ovi" + "Novi APN" + "Postavke naziva pristupne točke nisu dostupne ovom korisniku" + "Kopirati u međuspremnik?" + "Kopiraj" + "na %s" + "Općenito" + "Napredno" + "Opće postavke" + "Napredne postavke" + "\"%s\" SIM" + "Slanje pojedinačnih SMS poruka svim primateljima. Eventualne odgovore dobit ćete samo vi" + "Slanje pojedinačnog MMS-a svim primateljima" + "Nepoznati broj" + "Nova poruka" + "Nova poruka." + "Gumb za odabir SIM-a" + "%1$s odabran, gumb za odabir SIM-a" + "Uredi predmet" + "Odabir SIM-a ili uređivanje predmeta" + "Dodirnite i zadržite da biste snimili zvuk" + "Započnite novi razgovor" + "Slanje poruka" + "Popis Slanja poruka" + "Slanje poruka" + "Nova poruka" + "Popis razgovora" + "Učitavanje razgovora" + "Učitavanje poruka" + "Prikaži više razgovora" + "Prikaži više poruka" + "Razgovor je izbrisan" + "Razgovor izbrisan. Dodirnite za prikaz drugog razgovora iz Slanja poruka" + "Blokirano" + "Deblokirano" + "Nedostaje prostora za pohranu. Neki se podaci mogu izgubiti." + "Odaberite privitke" + "Potvrdite odabir" + "Odabrano: %d" + "Uklonite jedan ili više privitaka i pokušajte ponovo." + "Možete pokušati poslati poruku, ali možda neće biti dostavljena ako ne uklonite jedan ili više privitaka." + "Možete poslati samo jedan videozapis u poruci. Uklonite dodatne videozapise i pokušajte ponovo." + "Slanje poruka nije uspjelo učitati privitak." + "Svejedno pošalji" + "Pokretanje razgovora nije uspjelo" + "%1$s (%2$s)" + "Odabrani SIM: %s" + diff --git a/res/values-hu/arrays.xml b/res/values-hu/arrays.xml new file mode 100644 index 0000000..36c0cd0 --- /dev/null +++ b/res/values-hu/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "nincs tárgy" + "nincs tárgy" + + + "Igen" + "Nem" + "OK" + "Haha!" + "Köszönöm!" + "Egyetértek" + "Ügyes" + "Úton vagyok" + "Rendben, később jelentkezem" + ":)" + ":(" + + diff --git a/res/values-hu/strings.xml b/res/values-hu/strings.xml new file mode 100644 index 0000000..cd254f2 --- /dev/null +++ b/res/values-hu/strings.xml @@ -0,0 +1,519 @@ + + + + + "Üzenetváltás" + "Üzenetváltás" + "Beszélgetés kijelölése" + "Beállítások" + "Üzenet küldése" + "Melléklet hozzáadása" + "Súgó" + "Üdvözöljük!" + "Kihagyás" + "Következő >" + "Tovább" + "Kilépés" + "Beállítások >" + "Beállítások" + "A Messaging engedélyt kér a következőkhöz: SMS, telefon és névjegyek." + "Az engedélyeket a Beállítások > Alkalmazások > Messaging > Engedélyek lehetőségnél módosíthatja." + "Az engedélyeket a Beállítások, Alkalmazások, Messaging, Engedélyek lehetőségnél módosíthatja." + "Gyakran használt névjegyek" + "Az összes névjegy" + "Küldés: %s" + "Kép vagy videó rögzítése" + "Képek kiválasztása erről az eszközről" + "Hanganyag rögzítése" + "Fotó kiválasztása" + "Kijelölte a médiaelemet." + "Visszavonta a médiaelem kijelölését." + "%d kiválasztva" + "kép %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "kép" + "Hanganyag rögzítése" + "Megosztás" + "Éppen most" + "Most" + + %d perce + %d perce + + + %d órája + %d órája + + + %d napja + %d napja + + + %d hét + egy hét + + + %d hónap + egy hónap + + + %d év + egy év + + "0. osztályú üzenet" + "Mentés" + "Az eszközön kevés a tárhely. A Messaging automatikusan törli a régebbi üzeneteket tárhely felszabadítása érdekében." + "Kevés a szabad terület" + "A Messaging nem küldhet vagy fogadhat üzenetet, amíg nem áll rendelkezésre több hely az eszközön." + "Kevés a tárhely az SMS-eknek. Lehet, hogy üzeneteket kell törölnie." + "Telefonszám megerősítése" + "Ez az egyszeri lépés biztosítja, hogy a Messaging megfelelően továbbítsa a csoportos üzeneteket." + "Telefonszám" + "Az összes médiatartalommal bíró üzenet törlése" + "A(z) %s időtartamnál régebbi üzenetek törlése" + "A(z) %s időtartamnál régebbi üzenetek automatikus törlése" + "Mellőzés" + "Törli az összes, médiát tartalmazó üzenetet?" + "Törli a(z) %s időtartamnál régebbi üzeneteket?" + "Törli a(z) %s időtartamnál régebbi üzeneteket, és bekapcsolja az automatikus törlést?" + "%s a következőt mondta:" + "Ön a következőt mondta" + "%s üzenete:" + "Ön elküldött egy üzenetet" + "Küldés…" + "Nem lehetett elküldeni. Érintse meg az újbóli próbálkozáshoz." + "Nem lehetett elküldeni. Újabb próbálkozás…" + "Újraküldés vagy törlés" + "Kérjük, tárcsázza a segélyhívót. A szöveges üzenetet jelenleg nem tudjuk elküldeni." + "Sikertelen" + "Új letölthető MMS üzenet" + "Új MMS üzenet" + "Nem sikerült letölteni" + "Érintse meg az újabb próbálkozáshoz" + "Érintse meg a letöltéshez" + "Letöltés vagy törlés" + "Letöltés…" + "Az üzenet vagy lejárt, vagy nem érhető el." + "méret: %1$s, lejárat: %2$s" + "Nem lehetett elküldeni. A címzett érvénytelen." + "A szolgáltatás nincs aktiválva a hálózaton" + "Hálózati probléma miatt nem lehetett elküldeni" + "Az üzenet vagy lejárt, vagy nem érhető el." + "(Nincs tárgy)" + "Ismeretlen feladó" + "Kézbesítve" + "Nem sikerült letölteni %2$s%1$s” tárgyú levelét." + "Kevés a memória, ezért nem sikerült végrehajtani a műveletet az adatbázison" + "A rendszer nem küldte el az üzenetet" + "A Messaging egyes üzeneteit nem lehetett elküldeni" + + %d üzenet %d beszélgetésben + %d üzenet egy beszélgetésben + + "Az üzenet nincs letöltve" + "A Messaging egyes üzeneteit nem lehetett letölteni" + + %d üzenet %d beszélgetésben + %d üzenet egy beszélgetésben + + "Nem sikerült elküldeni az üzenetet a(z) %1$s számra" + "Kérjük, tárcsázza a segélyhívót. A szöveges üzenetet jelenleg nem tudjuk elküldeni erre a számra: %1$s." + + %d új üzenet + Új üzenet + + "Indítás" + "A kamera nem érhető el" + "A kamera nem érhető el" + "A videorögzítés nem érhető el" + "Nem lehetett menteni a médiát" + "Nem lehet képet készíteni" + "Vissza" + "Archiválva" + "Törlés" + "Archiválás" + "Archiválás visszavonása" + "Értesítések kikapcsolása" + "Értesítések bekapcsolása" + "Névjegy hozzáadása" + "Letöltés" + "Küldés" + "Törlés" + "Törli ezt az üzenetet?" + "Ez a művelet nem vonható vissza." + "Törlés" + + Törli ezeket a beszélgetéseket? + Törli ezt a beszélgetést? + + "Törlés" + "Mégse" + "Címzett" + "Több kép kiválasztása" + "Kiválasztás megerősítése" + "+%d" + "Nem sikerült rögzíteni a hangot. Próbálja újra." + "A hanganyag nem játszható le. Próbálja újra." + "Nem sikerült elmenteni a hangot. Próbálja újra." + "Érintés és tartás" + ", " + " " + ": " + " " + "Kép" + "Hangfelvétel" + "Videó" + "Névjegykártya" + "Letöltés" + "Válasz SMS-ben" + "Válasz MMS-ben" + "Válasz" + + %d résztvevő + %d résztvevő + + "Én" + "Ismerős letiltva és archiválva" + "Névjegy tiltása és archiválása visszavonva" + "%d archiválva" + "%d archiválása megszüntetve" + "Értesítések kikapcsolva" + "Értesítések bekapcsolva" + "Minden készen áll. Érintse meg újra a Küldés lehetőséget." + "A Messaging sikeresen beállítva alapértelmezett SMS-alkalmazásként." + + Mellékletek elvetése + Melléklet elvetése + + "Hangmelléklet" + "Hangmelléklet lejátszása" + "Szünet" + "%s üzenete: %s." + "Sikertelen üzenet %s feladótól: %s. Idő: %s." + "Üzenet %s feladótól: %s. Idő: %s." + "Elküldetlen üzenet a(z) %s csoportnak: %s. Idő: %s." + "Üzenet küldése a(z) %s csoportnak: %s. Idő: %s." + "Sikertelen üzenet a(z) %s csoportnak: %s. Idő: %s." + "Üzenet a(z) %s csoportnak: %s. Idő: %s." + "Sikertelen üzenet %s feladótól: %s. Idő: %s. %s" + "Üzenet %s feladótól: %s. Idő: %s. %s" + "Elküldetlen üzenet a(z) %s csoportnak: %s. Idő: %s." + "Üzenet küldése a(z) %s csoportnak: %s. Idő: %s." + "Sikertelen üzenet a(z) %s csoportnak: %s. Idő: %s." + "Üzenet a(z) %s csoportnak: %s. Idő: %s." + "Sikertelen üzenet. Érintse meg az újrapróbáláshoz." + "Beszélgetés a következőkkel: %s" + "Tárgy törlése" + "Videó rögzítése" + "Állókép rögzítése" + "Kép készítése" + "Videofelvétel megkezdése" + "Váltás teljes képernyős kameranézetre" + "Váltás az elülső és a hátulsó kamera között" + "Rögzítés leállítása és a videó csatolása" + "Videofelvétel leállítása" + "Messaging-fotók" + + %d fotó mentve a következő albumba: %s + %d fotó mentve a következő albumba: %s + + + %d videó mentve a következő albumba: %s + %d videó mentve a következő albumba: %s + + + %d melléklet mentve a következő albumba: %s + %d melléklet mentve a következő albumba: %s + + + %d melléklet mentve a „Letöltések” közé + %d melléklet mentve a „Letöltések” közé + + + %d melléklet sikeresen mentve + %d melléklet sikeresen mentve + + + %d melléklet mentése nem sikerült + %d melléklet mentése nem sikerült + + "Mentett MMS-melléklet" + "Beállítások" + "Archiválva" + "Bezárás" + "MMS" + "Speciális" + "Hibakeresés" + "Értesítések" + "Hang" + "Néma" + "Rezgés" + "Letiltva" + "SMS-kézbesítési jelentések" + "Kézbesítési jelentés kérése minden elküldött SMS-ről" + "Automatikus letöltés" + "MMS-üzenetek automatikus letöltése" + "Auto. letöltés barangoláskor" + "MMS-ek automatikus lekérése adatbarangoláskor" + "Csoportos üzenetküldés" + "Küldjön MMS-t, amikor több címzettnek szeretne üzenetet küldeni." + "Alapértelmezett SMS-alkalmazás" + "Alapértelmezett SMS-alkalmazás" + + + "Az Ön telefonszáma" + "Ismeretlen" + "Kimenő üzenetek hangja" + "SMS kiírása" + "Beérkező nyers SMS-adatok kiírása külső tárolófájlba" + "MMS kiírása" + "Beérkező nyers MMS-adatok kiírása külső tárolófájlba" + "Vezeték nélküli értesítések" + "Üzenetbeállítások" + "Szöveg másolása" + "Részletek megjelenítése" + "Törlés" + "Továbbítás" + "Üzenet részletei" + "Típus: " + "Szöveges üzenet" + "Multimédiás üzenet" + "Küldő: " + "Címzett: " + "Elküldve: " + "Érkezett: " + "Tárgy: " + "Méret: " + "Prioritás: " + "SIM: " + "Magas" + "Normál" + "Alacsony" + "%s. SIM" + "A küldő címe elrejtve" + "A mellékletek betöltése közben nem lehet elküldeni az üzenetet." + "Nem lehetett betölteni a mellékletet. Próbálja újra." + "A hálózat nem áll készen. Próbálja újra." + "Szöveg törlése" + "Váltás szöveg és számok bevitele között" + "További résztvevők hozzáadása" + "Résztvevők megerősítése" + "Új beszélgetés indítása" + "Elem kiválasztása" + "Videó lejátszása" + "Személyek és beállítások" + "Hibakeresés" + "Személyek és beállítások" + "Általános" + "A jelen beszélgetésben részt vevő személyek" + "Hívásindítás" + "Üzenet küldése" + "Üzenet küldése<br/><small>a következő SIM kártyáról: %s</small>" + + Fotók küldése + Fotó küldése + + + Hangok küldése + Hang küldése + + + Videók küldése + Videó küldése + + + Névjegykártyák küldése + Névjegykártya küldése + + + Mellékletek küldése + Melléklet küldése + + + %d melléklet készen áll a küldésre + Egy melléklet készen áll a küldésre + + "Visszajelzés küldése" + "Megtekintés a Google Play Áruházban" + "Verzióadatok" + "Verzió: %1$s" + "Nyílt forráskódú licencek" + "Értesítések" + "Elérte a mellékletekre vonatkozó korlátot" + "Nem sikerült betölteni a mellékletet." + "Hozzáadja a Névjegyekhez?" + "Névjegy hozzáadása" + "Tárgy" + "Tárgy: " + "%s%s" + "Névjegykártya betöltése" + "Nem lehetett betölteni a névjegykártyát" + "Névjegykártya megtekintése" + + %d névjegy + %d névjegy + + "Névjegykártyák" + "Születésnap" + "Jegyzetek" + "Üzenet továbbítása" + "Válasz" + "+1" + "+%d" + "SMS-ek letiltva" + "A küldéshez állítsa be, hogy a Messaging legyen az alapértelmezett SMS-alkalmazás" + "A Messaging beállítása alapértelmezett SMS-alkalmazásként" + "Módosítás" + "Üzenetek fogadásához állítsa be, hogy a Messaging legyen az alapértelmezett SMS-alkalmazás" + "Nincs SIM kiválasztva az SMS-küldéshez" + "Az eszköz tulajdonosa nem engedélyezi ezt az alkalmazást." + "OK" + "Túl sok a résztvevő a beszélgetésben" + + Érvénytelen névjegy + Érvénytelen névjegy + + "Nem lehetett betölteni a kamera képét" + "Ön: " + "%s: " + "Piszkozat" + "Amikor új beszélgetésbe kezd, az itt jelenik meg" + "Itt jelennek meg az archivált beszélgetések" + "Beszélgetések betöltése…" + "Kép" + "Hangfelvétel" + "Videó" + "Névjegykártya" + "MMS" + "Visszavonás" + "Újra" + "Új üzenet létrehozásához adjon meg egy névjegyet vagy telefonszámot" + "Tiltás" + "%s letiltása" + "%s feloldása" + "Letiltja ezt: %s?" + "Továbbra is kap üzeneteket erről a számról, ám a rendszer nem küld róluk értesítést. A beszélgetést a rendszer archiválja." + "Letiltott ismerősök" + "FELOLD" + "Letiltott ismerősök" + "Kép választása a dokumentumtárból" + "Üzenet küldése" + "Üzenet elküldve" + "A mobiladat-kapcsolat ki van kapcsolva. Ellenőrizze beállításait." + "Repülős módban nem lehet üzeneteket küldeni" + "Az üzenetet nem sikerült elküldeni" + "Üzenet letöltve" + "A mobiladat-kapcsolat ki van kapcsolva. Ellenőrizze beállításait." + "Repülős üzemmódban nem lehet üzenetet letölteni" + "Az üzenetet nem sikerült letölteni" + "Nulla" + "Egy" + "Kettő" + "Három" + "Négy" + "Öt" + "Hat" + "Hét" + "Nyolc" + "Kilenc" + "Nem lehet elküldeni az üzenetet a következő szolgáltatónál: %1$s (hibakód: %2$d)" + "Nem lehet elküldeni az üzenetet egy ismeretlen szolgáltatónál (hibakód: %1$d)" + "Továbbítás: %s" + "Az üzenet nincs elküldve: a szolgáltatás nincs aktiválva a hálózaton" + "Az üzenet küldése sikertelen: a címzett címe érvénytelen" + "Az üzenet küldése sikertelen: érvénytelen üzenet" + "Az üzenet küldése sikertelen: nem támogatott tartalom" + "Az üzenet küldése sikertelen: nem támogatott üzenet" + "Az üzenet nincs elküldve: túl nagy" + "Új üzenet" + "Megtekintés" + "Kép" + "Nem található megfelelő alkalmazás" + "Címzett eltávolítása" + "Új üzenet" + "Mégse" + "Hozzáférési pont szerkesztése" + "Nincs megadva" + "Név" + "APN" + "MMSC" + "MMS-proxy" + "MMS-port" + "MCC" + "MNC" + "APN típusa" + "APN törlése" + "Új APN" + "Mentés" + "Elvetés" + "A Név mező nem lehet üres." + "Az APN nem lehet üres." + "Az MCC mezőnek három számjegyet kell tartalmaznia." + "Az MNC mezőbe 2 vagy 3 számjegyet kell írni." + "Alapértelmezett APN-beállítások visszaállítása." + "Visszaállítás alaphelyzetbe" + "Az alapértelmezett APN-beállítások visszaállítása befejeződött." + "Névtelen" + "Hozzáférési pontok nevei" + "APN-ek" + "Új APN" + "A hozzáférési pont nevének beállításai nem érhetők el ennél a felhasználónál" + "Átmásolja a vágólapra?" + "Másolás" + "fogadás a következőn: %s" + "Általános" + "Speciális" + "Általános beállítások" + "Speciális beállítások" + "„%s” SIM" + "Külön SMS küldése valamennyi címzettnek. Csak Ön kap választ" + "Egyetlen MMS küldése valamennyi címzettnek" + "Ismeretlen szám" + "Új üzenet" + "Új üzenet" + "SIM kártya kiválasztása" + "A(z) %1$s SIM kártya kiválasztva (SIM-választó)" + "Tárgy szerkesztése" + "SIM kártya kiválasztása vagy a tárgy szerkesztése" + "Hangfelvétel készítéséhez tartsa nyomva" + "Új beszélgetés indítása" + "Messaging" + "Messaging-lista" + "Messaging" + "Új üzenet" + "Beszélgetéslista" + "Beszélgetések betöltése" + "Üzenetek betöltése" + "További beszélgetések megtekintése" + "További üzenetek megtekintése" + "Beszélgetés törölve" + "Beszélgetés törölve. Másik Messaging-beszélgetés megjelenítéséhez érintse meg." + "Letiltva" + "Letiltás visszavonva" + "Kevés a tárhely. Lehet, hogy bizonyos adatok elvesztek." + "Mellékletek kiválasztása" + "Kiválasztás megerősítése" + "%d kiválasztva" + "Távolítson el legalább egy mellékletet, majd próbálja újra." + "Megpróbálhatja elküldeni az üzenetet, de előfordulhat, hogy az csak akkor lesz kézbesíthető, ha legalább egy mellékletet eltávolít." + "Üzenetenként csak egy videót lehet küldeni. Távolítsa el a többi videót, és próbálja újra." + "A Messaging nem tudta betölteni a mellékletet." + "Küldés mindenképpen" + "Nem sikerült elindítani a beszélgetést" + "%1$s (%2$s)" + "%s kiválasztva" + diff --git a/res/values-hy-rAM/arrays.xml b/res/values-hy-rAM/arrays.xml new file mode 100644 index 0000000..d520d07 --- /dev/null +++ b/res/values-hy-rAM/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "անվերնագիր" + "անվերնագիր" + + + "Այո" + "Ոչ" + "Լավ" + "Հե-հե" + "Շնորհակալություն" + "Համաձայն եմ" + "Գերազանց է" + "Ճանապարհին եմ" + "Լավ, կմիանամ ձեզ ավելի ուշ" + ":)" + ":(" + + diff --git a/res/values-hy-rAM/strings.xml b/res/values-hy-rAM/strings.xml new file mode 100644 index 0000000..4d01fef --- /dev/null +++ b/res/values-hy-rAM/strings.xml @@ -0,0 +1,519 @@ + + + + + "Հաղորդակցում" + "Հաղորդակցում" + "Ընտրեք խոսակցությունը" + "Կարգավորումներ" + "Ուղարկել հաղորդագրություն" + "Ավելացնել կցորդ" + "Օգնություն" + "Բարի գալուստ" + "Բաց թողնել" + "Հաջորդը >" + "Հաջորդը" + "Ելք" + "Կարգավորումներ >" + "Կարգավորումներ" + "Հաղորդակցում հավելվածին հարկավոր է թույլտվություն SMS-ներին, հեռախոսին ու կոնտակտներին:" + "Կարող եք փոխել թույլտվությունները՝ անցնելով Կարգավորումներ > Հավելվածներ > Հաղորդակցում > Թույլտվություններ:" + "Կարող եք փոխել թույլտվությունները՝ անցնելով Կարգավորումներ, Հավելվածներ, Հաղորդակցում, Թույլտվություններ:" + "Հաճախակի օգտագործվող կոնտակտներ" + "Բոլոր կոնտակտները" + "Ուղարկել %s համարին" + "Լուսանկարել կամ տեսագրել" + "Ընտրեք պատկերներ այս սարքից" + "Ձայնագրել ձայնանյութ" + "Ընտրել լուսանկար" + "Մեդիա ֆայլն ընտրված է:" + "Մեդիա ֆայլն ապընտրված է:" + "%d ընտրված" + "պատկեր %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "պատկեր" + "Ձայնագրել ձայնանյութ" + "Տարածել" + "Հենց հիմա" + "Այժմ" + + %d րոպե + %d րոպե + + + %d ժամ + %d ժամ + + + %d օր + %d օր + + + %d շաբաթ + %d շաբաթ + + + %d ամիս + %d ամիս + + + %d տարի + %d տարի + + "Class 0 հաղորդագրություն" + "Պահել" + "Սարքի վրա բավարար ազատ տարածք չկա: Հաղորդակցում հավելվածը ինքնաշխատորեն կջնջի հին հաղորդագրությունները, որպեսզի տեղ ազատի:" + "Պահոցային տարածքը սպառվում է" + "Հաղորդակցումը չի կարող ուղարկել կամ ստանալ հաղորդագրություններ, քանի դեռ սարքում բավարար տարածք չկա:" + "SMS պահոցի ծավալը փոքր է: Հնարավոր է՝ հաղորդագրություններ ջնջելու կարիք լինի:" + "Հաստատեք ձեր հեռախոսահամարը" + "Սա մեկանգամյա քայլ է, որի շնորհիվ Հաղորդակցումը անսխալ կուղարկի ձեր խմբային հաղորդագրությունները:" + "Հեռախոսահամար" + "Ջնջել մեդիաֆայլեր պարունակող բոլոր հաղորդագրությունները" + "Ջնջել %s-ից հին հաղորդագրությունները" + "Ավտոմատ ջնջել %s-ից հին հաղորդագրությունները" + "Անտեսել" + "Ջնջե՞լ մեդիա պարունակող բոլոր հաղորդագրությունները:" + "Ջնջե՞լ %s-ից հին հաղորդագրությունները:" + "Ջնջե՞լ %s-ից հին հաղորդագրությունները և միացնել ավտոմատ հեռացումը:" + "%s-ը ասել է" + "Դուք ասել եք" + "Հաղորդագրություն %s-ից" + "Դուք հաղորդագրություն եք ուղարկել" + "Ուղարկվում է…" + "Չի ուղարկվել: Հպեք՝ կրկին փորձելու համար:" + "Չի ուղարկվել: Փորձում է կրկին…" + "Կրկին ուղարկել թե ջնջել" + "Խնդրում ենք հեռախոսազանգ կատարել արտակարգ իրավիճակների ծառայություններին: Ձեր տեքստային հաղորդագրությունն այս պահին չի կարող առաքվել:" + "Ձախողվեց" + "Նոր MMS հաղորդագրություն՝ ներբեռնելու հաար" + "Նոր MMS հաղորդագրություն" + "Չհաջողվեց ներբեռնել" + "Հպեք՝ նորից փորձելու համար" + "Հպեք՝ ներբեռնելու համար" + "Ներբեռնել թե ջնջել" + "Ներբեռնում…" + "Հաղորդագրության ժամկետը սպառվել է կամ հասանելի չէ" + "չափը՝ %1$s , պիտանիությունը՝ %2$s" + "Հնարավոր չէ ուղարկել: Ստացողը վավեր չէ:" + "Ծառայությունն ակտիվացված չէ ցանցում" + "Հնարավոր չէ ուղարկել ցանցային խնդրի պատճառով" + "Հաղորդագրության ժամկետը սպառվել է կամ հասանելի չէ" + "(Անվերնագիր)" + "Անհայտ ուղարկող" + "Առաքված է" + "Հնարավոր չէ ներբեռնել %1$s հաղորդագրությունը %2$s-ից:" + "Չստացվեց ավարտել տվյալների շտեմարանի աշխատանքը՝ փոքր հիշողության պատճառով" + "Հաղորդագրությունը չի ուղարկվել" + "Հաղորդակցման որոշ հաղորդագրություններ չեն ուղարկվել" + + %d հաղորդագրություն %d խոսակցությունում + %d հաղորդագրություն %d խոսակցությունում + + "Հաղորդագրությունը չի ներբեռնվել" + "Որոշ հաղորդագրություններ չեն ներբեռնվել Հաղորդակցման մեջ" + + %d հաղորդագրություն %d խոսակցությունում + %d հաղորդագրություն %d խոսակցությունում + + "Հաղորդագրությունը %1$s համարին չի ուղարկվել" + "Խնդրում ենք հեռախոսազանգ կատարել արտակարգ իրավիճակների ծառայություններին: Ձեր տեքստային հաղորդագրությունն այս պահին չի կարող առաքվել %1$s համարին:" + + %d նոր հաղորդագրություն + %d նոր հաղորդագրություն + + "Սկսել" + "Խցիկն անհասանելի է" + "Խցիկն անհասանելի է" + "Տեսանյութը հասանելի չէ" + "Հնարավոր չէ պահել մեդիա ֆայլը" + "Չհաջողվեց լուսանկարել" + "Հետ" + "Արխիվացված" + "Ջնջել" + "Արխիվ" + "Ապարխիվացնել" + "Անջատել ծանուցումները" + "Միացնել ծանուցումները" + "Ավելացնել կոնտակտ" + "Ներբեռնել" + "Ուղարկել" + "Ջնջել" + "Ջնջե՞լ այս հաղորդագրությունը" + "Այս գործողությունը չի կարող հետարկվել:" + "Ջնջել" + + Ջնջե՞լ այս խոսակցությունները: + Ջնջե՞լ այս խոսակցությունները: + + "Ջնջել" + "Չեղարկել" + "Ստացող`" + "Ընտրել մի քանի պատկեր" + "Հաստատել ընտրությունը" + "+%d" + "Հնարավոր չէ ձայնագրել: Կրկին փորձեք:" + "Հնարավոր չէ նվագարկել աուդիո ֆայլը: Կրկին փորձեք:" + "Հնարավոր չէր պահել աուդիո ֆայլը: Կրկին փորձեք:" + "Հպել և պահել" + ", " + " " + ": " + " " + "Նկար" + "Ձայնահոլովակ" + "Տեսանյութ" + "Կոնտակտային քարտ" + "Ներբեռնել" + "Պատասխանել SMS-ով" + "Պատասխանել MMS-ով" + "Պատասխանել" + + %d մասնակից + %d մասնակից + + "Ես" + "Կոնտակտն արգելափակված և արխիվացված է" + "Կոնտակտն արգելաբացվեց և ապաարխիվացվեց" + "Արխիվացվեց %d խոսակցություն" + "Ապաարխիվացվեց %d խոսակցություն" + "Ծանուցումներն անջատվեցին" + "Ծանուցումները միացվեցին" + "Սահմանված է: Հպեք Ուղարկել կրկին:" + "Հաղորդակցումը դարձավ SMS-ների կանխադրված հավելված:" + + Հեռացնել կցորդները + Հեռացնել կցորդները + + "Կցված ձայնային ֆայլ" + "Նվագարկել աուդիո կցորդը" + "Դադարեցնել" + "Հաղորդագրություն %s-ից՝ %s:" + "Չառաքված հաղորդագրություն %s-ից՝ %s: Ժամանակ՝ %s:" + "Հաղորդագրություն %s-ից՝ %s: Ժամանակ՝ %s:" + "%s կոնտակտին չուղարկված հաղորդագրություն՝ %s: Ժամանակ՝ %s:" + "Հաղորդագրության ուղարկում %s կոնտակտին՝ %s: Ժամանակ՝ %s:" + "%s կոնտակտին չառաքված հաղորդագրություն՝ %s: Ժամանակ՝ %s:" + "%s կոնտակտին հասցեագրված հաղորդագրություն՝ %s: Ժամանակ՝ %s:" + "Չառաքված հաղորդագրություն %s-ից՝ %s: Ժամանակ՝ %s: %s:" + "Հաղորդագրություն %s-ից՝ %s: Ժամանակ՝ %s: %s:" + "%s խմբին չուղարկված հաղորդագրություն՝ %s: Ժամանակ՝ %s:" + "Հաղորդագրության ուղարկում %s խմբին՝ %s: Ժամանակ՝ %s:" + "%s խմբին չառաքված հաղորդագրություն՝ %s: Ժամանակ՝ %s:" + "%s խմբին հասցեագրված հաղորդագրություն՝ %s: Ժամանակ՝ %s:" + "Հաղորդագրությունը չի առաքվել: Հպեք՝ նորից փորձելու համար:" + "Զրույց %s-ի հետ" + "Ջնջել վերնագիրը" + "Տեսագրել" + "Նկարահանել ստատիկ պատկեր" + "Լուսանկարել" + "Սկսել տեսաձայնագրումը" + "Անցնել լիաէկրան ֆոտոխցիկի" + "Փոխարկել դիմացի և հետևի տեսախցիկները" + "Կանգնեցնել ձայնագրումը և կցել տեսանյութը" + "Դադարեցնել տեսանկարահանումը" + "Հաղորդակցման լուսանկարներ" + + «%s» ալբոմում պահվել է %d լուսանկար + «%s» ալբոմում պահվել է %d լուսանկար + + + «%s» ալբոմում պահվել է %d տեսանյութ + «%s» ալբոմում պահվել է %d տեսանյութ + + + «%s» ալբոմում պահվել է %d կցորդ + «%s» ալբոմում պահվել է %d կցորդ + + + «Ներբեռնումներ» պանակում պահվել է %d կցորդ + «Ներբեռնումներ» պանակում պահվել է %d կցորդ + + + %d կցորդ պահվեց + %d կցորդ պահվեց + + + Չհաջողվեց պահել %d կցորդ + Չհաջողվեց պահել %d կցորդ + + "MMS կցորդը պահվեց" + "Կարգավորումներ" + "Արխիվացված" + "Փակել" + "MMS" + "Հավելյալ" + "Կարգաբերում" + "Ծանուցումներ" + "Ձայն" + "Լուռ" + "Թրթռոց" + "Արգելափակված է" + "SMS-ի առաքման հաշվետվություններ" + "Առաքման ծանուցում հայցել յուրաքանչյուր ուղարկվող հաղորդագրության համար" + "Ինքնաառբերում" + "Ավտոմատ առբերել MMS հաղորդագրությունները" + "Ավտոմատ առբերում ռոումինգում" + "Ինքնաշխատորեն առբերել MMS-ը ռոումինգում" + "Խմբային հաղորդագրություններ" + "Օգտագործել MMS՝ մեկ հաղորդագրությունը մի քանի ստացողների ուղարկելու համար" + "SMS-ների կանխադրված հավելված" + "SMS-ների կանխադրված հավելված" + + + "Ձեր հեռախոսահամարը" + "Անհայտ է" + "Ելից հաղորդագրությունների ձայներ" + "Տեղափոխել SMS հաղորդագրությունները" + "Տեղափոխել ստացված SMS հաղորդագրությունների չմշակված տվյալները արտաքին հիշողության մեջ" + "Տեղափոխել MMS հաղորդագրությունները" + "Տեղափոխել ստացված MMS հաղորդագրությունների չմշակված տվյալները արտաքին հիշողության մեջ" + "Ծանուցումներ անլար կապի միջոցով" + "Հաղորդագրության ընտրանքներ" + "Պատճենել տեքստը" + "Տեսնել մանրամասները" + "Ջնջել" + "Առաջ" + "Հաղորդագրության մանրամասները" + "Տեսակ՝ " + "Տեքստային հաղորդագրություն" + "Մուլտիմեդիա հաղորդագրություն" + "Ումից` " + "Ում` " + "Ուղարկվել է՝ " + "Ստացվել է՝ " + "Վերնագիր՝ " + "Չափ՝ " + "Առաջնահերթություն՝ " + "SIM՝ " + "Բարձր" + "Սովորական" + "Ցածր" + "SIM %s" + "Թաքնված ուղարկողի հասցեն" + "Կցորդները բեռնելիս հնարավոր չէ ուղարկել հաղորդագրությունը:" + "Հնարավոր չէ բեռնել կցորդը: Փորձեք կրկին:" + "Ցանցը պատրաստ չէ: Նորից փորձեք:" + "Ջնջել տեքստը" + "Փոխարկել թվեր և տեքստ մուտքագրելու ռեժիմների միջև" + "Ավելացնել այլ մասնակիցների" + "Հաստատեք մասնակիցներին" + "Սկսել նոր խոսակցություն" + "Ընտրել այս նյութը" + "Նվագարկել տեսանյութը" + "Մարդիկ և ընտրանքներ" + "Կարգաբերել" + "Մարդիկ և ընտրանքներ" + "Ընդհանուր" + "Այս խոսակցության մասնակիցները" + "Զանգահարել" + "Ուղարկել հաղորդագրություն" + "Ուղարկել հաղորդագրություն<br/><small>%s-ից</small>" + + Ուղարկել լուսանկարները + Ուղարկել լուսանկարները + + + Ուղարկել ձայնագրությունները + Ուղարկել ձայնագրությունները + + + Ուղարկել տեսանյութերը + Ուղարկել տեսանյութերը + + + Ուղարկել կոնտակտի քարտերը + Ուղարկել կոնտակտի քարտերը + + + Ուղարկել կցորդները + Ուղարկել կցորդները + + + %d կցորդ պատրաստ է ուղարկելու համար + %d կցորդ պատրաստ է ուղարկելու համար + + "Ուղարկել կարծիք" + "Դիտել Google Play Store-ում" + "Տեղեկություններ տարբերակի մասին" + "Տարբերակ %1$s" + "Բաց աղբյուրով ծրագրերի լիցենզիաներ" + "Ծանուցումներ" + "Կցորդի սահմանաչափը լրացել է" + "Չհաջողվեց բեռնել կցորդը:" + "Ավելացնե՞լ կոնտակտներում" + "Ավելացնել կոնտակտ" + "Թեմա" + "Թեմա՝ " + "%s%s" + "Կոնտակտի քարտը բեռնվում է" + "Հնարավոր չէ բեռնել կոնտակտի քարտը" + "Դիտել կոնտակտի քարտը" + + %d կոնտակտ + %d կոնտակտ + + "Կոնտակտների քարտեր" + "Ծննդյան օր" + "Գրառումներ" + "Փոխանցել հաղորդագրությունը" + "Պատասխանել" + "+1" + "+%d" + "SMS-ն անջատված է" + "Հաղորդագրություններ ուղարկելու համար դարձրեք Հաղորդակցումը SMS-ների կանխադրված հավելված" + "Դարձրեք Հաղորդակցումը SMS-ների կանխադրված հավելված" + "Փոխել" + "Հաղորդագրություններ ստանալու համար դարձրեք Հաղորդակցումը SMS-ների կանխադրված հավելված" + "Դուք չեք նշել նախընտրելի SIM քարտը՝ SMS հաղորդագրություններն ուղարկելու համար" + "Այս հավելվածը սարքի սեփականատիրոջ կողմից թույլատրված չէ:" + "Լավ" + "Չափից շատ մասնակիցներ կան զրույցին" + + Անվավեր կոնտակտներ + Անվավեր կոնտակտներ + + "Չհաջողվեց բեռնել տեսախցիկի պատկերը" + "Դուք՝ " + "%s՝ " + "Սևագիր" + "Հենց նոր զրույց սկսեք, այն կտեսնեք այս ցուցակում" + "Արխիվացված խոսակցությունները հայտնվում են այստեղ" + "Խոսակցությունները բեռնվում են…" + "Նկար" + "Ձայնահոլովակ" + "Տեսանյութ" + "Կոնտակտային քարտ" + "MMS" + "Հետարկել" + "Կրկնել" + "Մուտքագրեք կոնտակտի անունը կամ հեռախոսահամարը՝ նոր հաղորդագրություն սկսելու համար" + "Արգելափակել" + "Արգելափակել %s-ին" + "Արգելաբացել %s-ին" + "Արգելափակե՞լ %s-ը:" + "Դուք կշարունակեք ստանալ հաղորդագրություններ այս հեռախոսահամարից, բայց չեք ծանուցվի դրանց մասին: Այս խոսակցությունը կարխիվացվի:" + "Արգելափակված կոնտակտներ" + "ԱՐԳԵԼԱԲԱՑԵԼ" + "Արգելափակված կոնտակտներ" + "Ընտրեք պատկեր՝ փաստաթղթերի գրադարանից" + "Հաղորդագրությունն ուղարկվում է" + "Հաղորդագրությունն ուղարկվել է" + "Բջջային տվյալների կապն անջատված է: Ստուգեք ձեր կարգավորումները:" + "Հնարավոր չէ ուղարկել հաղորդագրություններ Թռիչքի ռեժիմում" + "Հաղորդագրությունը չհաջողվեց ուղարկել" + "Հաղորդագրությունը բեռնվել է" + "Բջջային տվյալների կապն անջատված է: Ստուգեք ձեր կարգավորումները:" + "Հնարավոր չէ ներբեռնել հաղորդագրությունները Ինքնաթիռի ռեժիմում" + "Հաղորդագրությունը չներբեռնվեց" + "Զրո" + "Մեկ" + "Երկու" + "Երեք" + "Չորս" + "Հինգ" + "Վեց" + "Յոթ" + "Ութ" + "Ինը" + "Հնարավոր չէ ուղարկել հաղորդագրությունը %1$s-ի միջոցով: Սխալի կոդ՝ %2$d" + "Հնարավոր չէ ուղարկել հաղորդագրությունը անհայտ օպերատորի միջոցով: Սխալի կոդ՝ %1$d" + "Փխնց՝ %s" + "Հաղորդագրությունը չի ուղարկվել. Ծառայությունն ակտիվացված չէ ցանցում" + "Հաղորդագրությունը չի ուղարկվել. ստացողի հասցեն սխալ է" + "Հաղորդագրությունը չի ուղարկվել. անվավեր հաղորդագրություն" + "Հաղորդագրությունը չի ուղարկվել. չաջակցվող բովանդակություն" + "Հաղորդագրությունը չի ուղարկվել. չաջակցվող հաղորդագրություն" + "Հաղորդագրությունը չուղարկվեց: Այն չափազանց մեծ է" + "Նոր հաղորդագրություն" + "Դիտել" + "Պատկեր" + "Հնարավոր չէր գտնել համապատասխան հավելված" + "Հեռացնել ստացողին" + "Նոր հաղորդագրություն" + "Չեղարկել" + "Խմբագրել մուտքի կետը" + "Կարգավորված չէ" + "Անուն" + "APN" + "MMSC" + "MMS պրոքսի" + "MMS միացք" + "MCC" + "MNC" + "APN-ի տեսակը" + "Ջնջել APN-ը" + "Նոր APN" + "Պահել" + "Հրաժարվել" + "Անվանման դաշտը պետք է լրացված լինի:" + "APN-ը չի կարող դատարկ լինել:" + "MCC դաշտը պետք է 3 նիշ ունենա:" + "MNC դաշտը պետք է լինի առնվազն 2 կամ 3 թվանշան:" + "Լռելյայն APN կարգավորումների վերականգնում:" + "Վերականգնել կանխադրվածը" + "Սկզբնական APN կարգավորումների վերակարգավորումն ավարտված է:" + "Անվերնագիր" + "Մատչման կետերի անունները" + "APN-ներ" + "Նոր APN" + "Մատչման կետի անվան կարգավորումները հասանելի չեն այս օգտվողին" + "Պատճենել սեղմատախտակին" + "Պատճենել" + "%s-ին" + "Ընդհանուր" + "Հավելյալ" + "Ընդհանուր կարգավորումներ" + "Ընդլայնված կարգավորումներ" + %s» SIM" + "Ուղարկել անհատական ​​SMS հաղորդագրություններ բոլոր ստացողներին: Միայն դուք կստանաք պատասխաններ" + "Բոլոր ստացողներին ուղարկել առանձին MMS" + "Անհայտ համար" + "Նոր հաղորդագրություն" + "Նոր հաղորդագրություն:" + "SIM քարտի ընտրում" + "Ընտրված է %1$s-ը, SIM քարտի ընտրում" + "Խմբագրել վերնագիրը" + "Ընտրել SIM քարտ կամ խմբագրել վերնագիրը" + "Հպեք և պահեք՝ ձայնագրելու համար" + "Սկսել նոր խոսակցություն" + "Հաղորդակցում" + "Հաղորդակցման ցանկ" + "Հաղորդակցում" + "Նոր հաղորդագրություն" + "Խոսակցությունների ցանկ" + "Խոսակցությունները բեռնվում են" + "Հաղորդագրությունների բեռնում" + "Տեսնել այլ զրույցներ" + "Դիտել ավելի շատ հաղորդագրություններ" + "Զրույցը ջնջված է" + "Խոսակցությունը ջնջվել է: Հպեք՝ Հաղորդակցման մեջ այլ խոսակցություն դիտելու համար" + "Արգելափակված է" + "Ապաարգելափակված է" + "Ազատ տարածքը բավարար չէ: Որոշ տվյալներ չեն պահվի:" + "Ընտրել կցորդներ" + "Հաստատել ընտրությունը" + "%d ընտրված" + "Հեռացրեք մեկ կամ ավելի կցորդ, ապա նորից փորձեք:" + "Կարող եք փորձել ուղարկել հաղորդագրությունը, սակայն այն չի կարող առաքվել, եթե չհեռացնեք մեկ կամ ավելի կցորդ:" + "Ամեն մի հաղորդագրության համար կարող եք միայն մեկ տեսանյութ ուղարկել: Հեռացրեք ավելորդ տեսանյութերը և նորից փորձեք:" + "Կցորդը չբեռնվեց:" + "Ուղարկել ամեն դեպքում" + "Չհաջողվեց սկսել խոսակցությունը" + "%1$s (%2$s)" + "Ընտրված է %s քարտը" + diff --git a/res/values-in/arrays.xml b/res/values-in/arrays.xml new file mode 100644 index 0000000..ce2892e --- /dev/null +++ b/res/values-in/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "tanpa subjek" + "nosubject" + + + "Ya" + "Tidak" + "Oke" + "Hehe" + "Terima kasih" + "Saya setuju" + "Bagus" + "Dalam perjalanan" + "Oke, saya akan menghubungi Anda nanti" + ":)" + ":(" + + diff --git a/res/values-in/strings.xml b/res/values-in/strings.xml new file mode 100644 index 0000000..3529301 --- /dev/null +++ b/res/values-in/strings.xml @@ -0,0 +1,519 @@ + + + + + "Perpesanan" + "Perpesanan" + "Pilih percakapan" + "Setelan" + "Kirim Pesan" + "Tambahkan lampiran" + "Bantuan" + "Selamat Datang" + "Lewati" + "Berikutnya >" + "Berikutnya" + "Keluar" + "Setelan >" + "Setelan" + "Perpesanan perlu izin ke SMS, Telepon, dan Kontak" + "Anda dapat mengubah izin di Setelan > Aplikasi > Perpesanan > Izin." + "Anda dapat mengubah izin di Setelan, Aplikasi, Messaging, Permissions." + "Sering" + "Semua kontak" + "Kirim ke %s" + "Jepret gambar atau rekam video" + "Pilih gambar dari perangkat ini" + "Rekam audio" + "Pilih foto" + "Media ini dipilih." + "Media ini batal dipilih." + "%d dipilih" + "gambar %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "gambar" + "Rekam audio" + "Bagikan" + "Baru saja" + "Sekarang" + + %d mnt + %d mnt + + + %d jam + %d jam + + + %d hari + %d hari + + + %d minggu + seminggu + + + %d bulan + sebulan + + + %d tahun + setahun + + "Pesan kelas 0" + "Simpan" + "Perangkat hampir kehabisan ruang. Perpesanan akan secara otomatis menghapus pesan lama untuk mengosongkan ruang." + "Ruang penyimpanan hampir habis" + "Perpesanan tidak dapat mengirim atau menerima pesan hingga lebih banyak ruang tersedia di perangkat Anda." + "Penyimpanan SMS tinggal sedikit. Anda perlu menghapus pesan." + "Konfirmasi nomor telepon Anda" + "Langkah satu kali ini akan memastikan bahwa Perpesanan mengirim pesan grup dengan tepat." + "Nomor telepon" + "Hapus semua pesan dengan media" + "Hapus pesan yang sudah lebih dari %s" + "Hapus otomatis pesan yang sudah lebih dari %s" + "Abaikan" + "Hapus semua pesan dengan media?" + "Hapus pesan yang sudah lebih dari %s?" + "Hapus pesan yang sudah lebih dari %s dan aktifkan fitur hapus otomatis?" + "%s mengatakan" + "Anda mengatakan" + "Pesan dari %s" + "Anda telah mengirim pesan" + "Mengirim…" + "Tidak dikirim. Sentuh untuk mencoba lagi." + "Tidak dikirim. Mencoba lagi..." + "Kirim ulang atau hapus" + "Lakukan panggilan suara ke layanan darurat. SMS Anda tidak dapat dikirim saat ini." + "Gagal" + "Pesan MMS baru untuk diunduh" + "Pesan MMS baru" + "Tidak dapat mengunduh" + "Sentuh untuk mencoba lagi" + "Sentuh untuk mengunduh" + "Unduh atau hapus" + "Mengunduh..." + "Pesan sudah tidak berlaku atau tidak tersedia" + "ukuran: %1$s, waktu berakhir: %2$s" + "Tidak dapat mengirim. Penerima tidak valid." + "Layanan tidak diaktifkan pada jaringan" + "Tidak dapat mengirim karena masalah jaringan" + "Pesan sudah tidak berlaku atau tidak tersedia" + "(Tanpa subjek)" + "Pengirim tidak diketahui" + "Telah Diantar" + "Tidak dapat mengunduh pesan %1$s dari %2$s." + "Tidak dapat menyelesaikan operasi basis data karena memori terlalu kecil" + "Pesan tidak dikirim" + "Beberapa pesan tidak terkirim di Perpesanan" + + %d pesan dalam %d percakapan + %d pesan dalam satu percakapan + + "Pesan tidak diunduh" + "Beberapa pesan tidak diunduh di Perpesanan" + + %d pesan dalam %d percakapan + %d pesan dalam satu percakapan + + "Pesan ke %1$s tidak terkirim" + "Lakukan panggilan suara ke layanan darurat. SMS Anda ke %1$s tidak dapat dikirim saat ini." + + %d pesan baru + Pesan baru + + "Mulai" + "Tidak ada kamera" + "Tidak ada kamera" + "Tidak ada rekaman video" + "Tidak dapat menyimpan media" + "Tidak dapat memfoto" + "Kembali" + "Diarsipkan" + "Hapus" + "Arsipkan" + "Batalkan pengarsipan" + "Nonaktifkan pemberitahuan" + "Aktifkan pemberitahuan" + "Tambahkan kontak" + "Unduh" + "Kirim" + "Hapus" + "Hapus pesan ini?" + "Tindakan ini tidak dapat diurungkan." + "Hapus" + + Hapus percakapan ini? + Hapus percakapan ini? + + "Hapus" + "Batal" + "Kepada" + "Pilih beberapa gambar" + "Konfirmasi pilihan" + "+%d" + "Tidak dapat merekam audio. Coba lagi." + "Tidak dapat memutar audio. Coba lagi." + "Tidak dapat menyimpan audio. Coba lagi." + "Sentuh & tahan" + ", " + " " + ": " + " " + "Gambar" + "Klip audio" + "Video" + "Kartu kontak" + "Unduh" + "Balas via SMS" + "Balas via MMS" + "Balas" + + %d peserta + %d peserta + + "Saya" + "Kontak diblokir & diarsipkan" + "Kontak tidak diblokir lagi & batal diarsipkan" + "%d diarsipkan" + "%d batal diarsipkan" + "Notifikasi dinonaktifkan" + "Notifikasi diaktifkan" + "Semua siap. Sentuh Kirim lagi." + "Perpesanan berhasil disetel sebagai aplikasi SMS default." + + Buang lampiran + Buang lampiran + + "Lampiran audio" + "Putar lampiran audio" + "Jeda" + "Pesan dari %s: %s." + "Pesan gagal dari %s: %s. Waktu: %s." + "Pesan dari %s: %s. Waktu: %s." + "Pesan yang tidak terkirim ke %s: %s. Waktu: %s." + "Mengirim pesan ke %s: %s. Waktu: %s." + "Pesan gagal ke %s: %s. Waktu: %s." + "Pesan ke %s: %s. Waktu: %s." + "Pesan gagal dari %s: %s. Waktu: %s. %s." + "Pesan dari %s: %s. Waktu: %s. %s." + "Pesan yang tidak terkirim ke %s: %s. Waktu: %s." + "Mengirim pesan ke %s: %s. Waktu: %s." + "Pesan gagal ke %s: %s. Waktu: %s." + "Pesan ke %s: %s. Waktu: %s." + "Pesan gagal. Sentuh untuk mencoba lagi." + "Percakapan dengan %s" + "Hapus subjek" + "Ambil video" + "Ambil gambar diam" + "Ambil gambar" + "Mulai merekam video" + "Beralih ke kamera layar penuh" + "Beralih antara kamera depan dan belakang" + "Hentikan perekaman dan lampirkan video" + "Berhenti merekam video" + "Foto Perpesanan" + + %d foto disimpan ke album \"%s\" + %d foto disimpan ke album \"%s\" + + + %d video disimpan ke album \"%s\" + %d video disimpan ke album \"%s\" + + + %d lampiran disimpan ke album \"%s\" + %d lampiran disimpan ke album \"%s\" + + + %d lampiran disimpan ke \"Unduhan\" + %d lampiran disimpan ke \"Unduhan\" + + + %d lampiran disimpan + %d lampiran disimpan + + + Tidak dapat menyimpan %d lampiran + Tidak dapat menyimpan %d lampiran + + "Lampiran MMS yang disimpan" + "Setelan" + "Diarsipkan" + "Tutup" + "MMS" + "Lanjutan" + "Debug" + "Notifikasi" + "Suara" + "Senyap" + "Getar" + "Diblokir" + "Laporan pengiriman SMS" + "Minta laporan pengiriman dari setiap SMS yang Anda kirimkan" + "Ambil otomatis" + "Otomatis ambil pesan MMS" + "Ambil otomatis saat roaming" + "Otomatis ambil MMS saat roaming" + "Perpesanan grup" + "Gunakan MMS untuk mengirimkan pesan tunggal saat ada beberapa penerima" + "Aplikasi SMS default" + "Aplikasi SMS default" + + + "Nomor telepon Anda" + "Tidak dikenal" + "Pesan suara keluar" + "Buang SMS" + "Buang data mentah SMS yang diterima ke dalam file penyimpanan eksternal" + "Buang MMS" + "Buang data mentah MMS yang diterima ke file penyimpanan eksternal" + "Lansiran nirkabel" + "Opsi pesan" + "Salin teks" + "Lihat detail" + "Hapus" + "Teruskan" + "Detail pesan" + "Jenis: " + "Pesan teks" + "Pesan multimedia" + "Dari: " + "Kepada: " + "Dikirim: " + "Diterima: " + "Subjek: " + "Ukuran: " + "Prioritas: " + "SIM: " + "Tinggi" + "Normal" + "Rendah" + "SIM %s" + "Alamat pengirim tersembunyi" + "Tidak dapat mengirim pesan saat memuat lampiran." + "Tidak dapat memuat lampiran. Coba lagi." + "Jaringan tidak siap. Coba lagi." + "Hapus teks" + "Beralih antara memasukkan teks dan angka" + "Tambahkan peserta lainnya" + "Konfirmasi peserta" + "Mulai percakapan baru" + "Pilih item ini" + "Putar video" + "Orang & opsi" + "Debug" + "Orang & opsi" + "Umum" + "Orang dalam percakapan ini" + "Lakukan panggilan telepon" + "Kirim pesan" + "Kirim pesan<br/><small>dari %s</small>" + + Kirim foto + Kirim foto + + + Kirim audio + Kirim audio + + + Kirim video + Kirim video + + + Kirim kartu kontak + Kirim kartu kontak + + + Kirim lampiran + Kirim lampiran + + + %d lampiran siap dikirim + Satu lampiran siap dikirim + + "Kirim masukan" + "Lihat di Google Play Store" + "Info versi" + "Versi %1$s" + "Lisensi sumber terbuka" + "Notifikasi" + "Batas lampiran tercapai" + "Gagal memuat lampiran." + "Tambahkan ke Kontak?" + "Tambahkan Kontak" + "Subjek" + "Subjek: " + "%s%s" + "Memuat kartu kontak" + "Tidak dapat memuat kartu kontak" + "Lihat kartu kontak" + + %d kontak + %d kontak + + "Kartu kontak" + "Ulang Tahun" + "Catatan" + "Teruskan pesan" + "Balas" + "+1" + "+%d" + "SMS dinonaktifkan" + "Untuk mengirim, setel Perpesanan sebagai aplikasi SMS default" + "Setel Perpesanan sebagai aplikasi SMS default" + "Ganti" + "Untuk menerima pesan, setel Perpesanan sebagai aplikasi SMS default." + "Tidak ada SIM yang dipilih untuk mengirim pesan SMS" + "Aplikasi ini tidak diizinkan oleh pemilik perangkat." + "Oke" + "Terlalu banyak peserta dalam percakapan" + + Kontak tidak valid + Kontak tidak valid + + "Tidak dapat memuat gambar kamera" + "Anda: " + "%s: " + "Draf" + "Setelah memulai percakapan baru, Anda akan melihatnya tercantum di sini" + "Arsip percakapan muncul di sini" + "Memuat percakapan..." + "Gambar" + "Klip audio" + "Video" + "Kartu kontak" + "MMS" + "Urungkan" + "Coba lagi" + "Masukkan nama atau nomor telepon yang dapat dihubungi untuk memulai pesan baru" + "Blokir" + "Blokir %s" + "Bebaskan %s" + "Blokir %s?" + "Anda akan terus menerima pesan dari nomor ini, tetapi tidak akan diberi tahu lagi. Percakapan ini akan diarsipkan." + "Kontak yang diblokir" + "BEBASKAN" + "Kontak yang diblokir" + "Pilih gambar dari perpustakaan dokumen" + "Mengirim pesan" + "Pesan terkirim" + "Data seluler dinonaktifkan. Periksa setelan." + "Tidak dapat mengirim pesan dalam mode Pesawat" + "Pesan tidak dapat dikirim" + "Pesan diunduh" + "Data seluler dinonaktifkan. Periksa setelan." + "Tidak dapat mengunduh pesan dalam mode Pesawat" + "Pesan tidak dapat diunduh" + "Nol" + "Satu" + "Dua" + "Tiga" + "Empat" + "Lima" + "Enam" + "Tujuh" + "Delapan" + "Sembilan" + "Tidak dapat mengirim pesan dengan %1$s, kesalahan %2$d" + "Tidak dapat mengirim pesan dengan operator tak dikenal, kesalahan %1$d" + "Trs: %s" + "Pesan tidak terkirim: layanan tidak diaktifkan di jaringan" + "Pesan tidak terkirim: alamat tujuan tidak valid" + "Pesan tidak terkirim: pesan tidak valid" + "Pesan tidak terkirim: konten tidak didukung" + "Pesan tidak terkirim: pesan tidak didukung" + "Pesan tidak terkirim: terlalu besar" + "Pesan baru" + "Lihat" + "Gambar" + "Aplikasi yang sesuai tidak dapat ditemukan" + "Hapus penerima" + "Pesan baru" + "Batal" + "Edit titik akses" + "Belum disetel" + "Nama" + "APN" + "MMSC" + "Proxy MMS" + "Port MMS" + "MCC" + "MNC" + "Jenis APN" + "Hapus APN" + "APN baru" + "Simpan" + "Buang" + "Bidang Nama wajib diisi." + "APN wajib diisi." + "Bidang MCC harus terdiri dari 3 digit." + "Bidang MNC harus 2 atau 3 digit." + "Memulihkan setelan APN default." + "Setel ulang ke default" + "Penyetelan ulang setelan APN default selesai." + "Tanpa judul" + "Nama Titik Akses" + "APN" + "APN baru" + "Setelan Nama Titik Akses tidak tersedia untuk pengguna ini" + "Salin ke papan klip?" + "Salin" + "kepada %s" + "Umum" + "Lanjutan" + "Setelan umum" + "Setelan lanjutan" + "SIM \"%s\"" + "Kirimkan pesan SMS individual ke semua penerima. Hanya Anda yang akan mendapatkan balasan" + "Kirimkan satu MMS ke semua penerima" + "Nomor tidak dikenal" + "Pesan baru" + "Pesan baru." + "Pemilih SIM" + "%1$s dipilih, pemilih SIM" + "Edit subjek" + "Pilih SIM atau edit subjek" + "Sentuh dan tahan untuk merekam audio" + "Mulai percakapan baru" + "Perpesanan" + "Daftar Perpesanan" + "Perpesanan" + "Pesan baru" + "Daftar percakapan" + "Memuat percakapan" + "Memuat pesan" + "Lihat percakapan lain" + "Lihat pesan lainnya" + "Percakapan telah dihapus" + "Percakapan dihapus. Sentuh untuk menampilkan percakapan Perpesanan yang berbeda" + "Diblokir" + "Dibebaskan" + "Sedikit ruang penyimpanan. Beberapa data mungkin hilang." + "Pilih lampiran" + "Konfirmasi pilihan" + "%d dipilih" + "Hapus satu atau beberapa lampiran dan coba lagi." + "Anda dapat mencoba mengirim pesan, tetapi pesan tersebut mungkin tidak terkirim, kecuali Anda menghapus satu atau beberapa lampiran." + "Anda hanya dapat mengirim satu video per pesan. Hapus video tambahan dan coba lagi." + "Perpesanan gagal memuat lampiran." + "Tetap kirim" + "Tidak dapat memulai percakapan" + "%1$s (%2$s)" + "%s dipilih" + diff --git a/res/values-is-rIS/arrays.xml b/res/values-is-rIS/arrays.xml new file mode 100644 index 0000000..f8d4803 --- /dev/null +++ b/res/values-is-rIS/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "ekkert efni" + "ekkertefni" + + + "Já" + "Nei" + "Í lagi" + "Hehe" + "Takk" + "Ég er sammála" + "Flott" + "Er á leiðinni" + "Ókei, ég fæ að hafa samband síðar" + ":)" + ":(" + + diff --git a/res/values-is-rIS/strings.xml b/res/values-is-rIS/strings.xml new file mode 100644 index 0000000..4a54e14 --- /dev/null +++ b/res/values-is-rIS/strings.xml @@ -0,0 +1,519 @@ + + + + + "Skilaboð" + "Skilaboð" + "Veldu samtal" + "Stillingar" + "Senda skilaboð" + "Bæta viðhengi við" + "Hjálp" + "Komdu fagnandi" + "Sleppa" + "Áfram >" + "Áfram" + "Hætta" + "Stillingar >" + "Stillingar" + "Skilaboð þurfa heimild til að fá aðgang að SMS-skilaboðum, síma og tengiliðum." + "Þú getur breytt heimildum undir Stillingar > Forrit > Skilaboð > Heimildir." + "Þú getur breytt heimildum undir Stillingar, Forrit, Skilaboð, Heimildir." + "Algengir" + "Allir tengiliðir" + "Senda í %s" + "Taka myndir eða myndskeið" + "Velja myndir úr þessu tæki" + "Taka upp hljóð" + "Velja mynd" + "Efnið er valið." + "Efnið er ekki valið." + "%d valin" + "mynd %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "mynd" + "Taka upp hljóð" + "Deila" + "Rétt í þessu" + "Núna" + + %d mín. + %d mín. + + + %d klukkustund + %d klukkustundir + + + %d dagur + %d dagar + + + %d vika + %d vikur + + + %d mánuður + %d mánuðir + + + %d ár + %d ár + + "Einnota skilaboð" + "Vista" + "Lítið pláss er í tækinu. Skilaboðaforritið mun sjálfkrafa eyða gömlum skilaboðum til að losa um pláss." + "Geymslurýmið er senn á þrotum" + "Ekki er víst að Skilaboð geti sent eða tekið á móti skilaboðum fyrr en meira pláss er til staðar í tækinu." + "SMS-geymsla að fyllast. Þú gætir þurft að eyða skilaboðum." + "Staðfestu símanúmerið þitt" + "Þetta þarf að gera í eitt skipti til að tryggja að Skilaboð komi hópskilaboðunum þínum til skila á réttan hátt." + "Símanúmer" + "Eyða öllum skilaboðum með margmiðlunarefni" + "Eyða skilaboðum sem eru eldri en %s" + "Eyða sjálfkrafa skilaboðum sem eru eldri en %s" + "Hunsa" + "Eyða öllum skilaboðum með margmiðlunarefni?" + "Eyða skilaboðum sem eru eldri en %s?" + "Eyða skilaboðum sem eru eldri en %s og kveikja á sjálfvirkri eyðingu?" + "%s sagði" + "Þú sagðir" + "Skilaboð sem %s sendi" + "Þú sendir skilaboð" + "Sendir…" + "Ekki sent. Snertu til að reyna aftur." + "Ekki sent. Reynir aftur…" + "Endursenda eða eyða" + "Hringdu í neyðarþjónustuna. Ekki tókst að senda textaskilaboðin þín á þessari stundu." + "Mistókst" + "Ný MMS-skilaboð til að sækja" + "Ný MMS-skilaboð" + "Ekki hægt að sækja" + "Snertu til að reyna aftur" + "Snertu til að sækja" + "Sækja eða eyða" + "Sækir…" + "Skilaboð útrunnin eða ekki aðgengileg" + "stærð: %1$s, útrunnið: %2$s" + "Ekki er hægt að senda. Viðtakandinn er ekki gildur." + "Símkerfið hefur ekki gert þjónustuna virka" + "Sending mistókst vegna símkerfisvandamáls" + "Skilaboð útrunnin eða ekki aðgengileg" + "(Ekkert efni)" + "Óþekktur sendandi" + "Sent" + "Ekki var hægt að sækja skilaboðin %1$s sem %2$s sendi." + "Lítið minni varð til þess að ekki var hægt að ljúka gagnagrunnsaðgerðinni" + "Skilaboð ekki send" + "Einhver skilaboð voru ekki send í Skilaboðum" + + %d skilaboð í %d samtali + %d skilaboð í %d samtölum + + "Skilaboð ekki sótt" + "Einhver skilaboð voru ekki sótt í Skilaboð" + + %d skilaboð í %d samtali + %d skilaboð í %d samtölum + + "Skilaboð til %1$s voru ekki send" + "Hringdu í neyðarþjónustuna. Ekki tókst að senda textaskilaboðin þín í númerið %1$s á þessari stundu." + + %d ný skilaboð + %d ný skilaboð + + "Byrja" + "Myndavélin er ekki tiltæk" + "Myndavélin er ekki tiltæk" + "Upptaka myndskeiða er ekki tiltæk" + "Ekki er hægt að vista efni" + "Ekki er hægt að taka mynd" + "Til baka" + "Geymsla" + "Eyða" + "Setja í geymslu" + "Taka úr geymslu" + "Slökkva á tilkynningum" + "Kveikja á tilkynningum" + "Bæta tengilið við" + "Sækja" + "Senda" + "Eyða" + "Eyða þessum skilaboðum?" + "Ekki er hægt að afturkalla þessa aðgerð." + "Eyða" + + Eyða þessum samtölum? + Eyða þessum samtölum? + + "Eyða" + "Hætta við" + "Til" + "Velja margar myndir" + "Staðfesta val" + "+%d" + "Ekki er hægt að taka upp hljóð. Reyndu aftur." + "Ekki er hægt að spila hljóð. Reyndu aftur." + "Ekki var hægt að vista hljóð. Reyndu aftur." + "Haltu inni" + ", " + " " + ": " + " " + "Mynd" + "Hljóðbútur" + "Myndskeið" + "Tengiliðaspjald" + "Sækja" + "Svara með SMS" + "Svara með MMS-skilaboðum" + "Svara" + + %d þátttakandi + %d þátttakendur + + "Ég" + "Tengiliður settur á bannlista og í geymslu" + "Tengiliður tekinn af bannlista og úr geymslu" + "%d sett í geymslu" + "%d tekin úr geymslu" + "Slökkt á tilkynningum" + "Kveikt á tilkynningum" + "Allt til reiðu. Snertu Senda aftur." + "Skilaboð voru valin sem sjálfgefið SMS-forrit." + + Fleygja viðhengjum + Fleygja viðhengjum + + "Hljóðviðhengi" + "Spila hljóðviðhengi" + "Hlé" + "Skilaboð sem %s sendi: %s." + "Misheppnuð skilaboð sem %s sendi: %s. Tími: %s." + "Skilaboð sem %s sendi: %s. Tími: %s." + "Ósend skilaboð til %s: %s. Tími: %s." + "Sendir skilaboð til %s: %s. Tími: %s." + "Misheppnuð skilaboð til %s: %s. Tími: %s." + "Skilaboð til %s: %s. Tími: %s." + "Misheppnuð skilaboð sem %s sendi: %s. Tími: %s. %s." + "Skilaboð sem %s sendi: %s. Tími: %s. %s." + "Ósend skilaboð til %s: %s. Tími: %s." + "Sendir skilaboð til %s: %s. Tími: %s." + "Misheppnuð skilaboð til %s: %s. Tími: %s." + "Skilaboð til %s: %s. Tími: %s." + "Misheppnuð skilaboð. Snertu til að reyna aftur." + "Samtal við %s" + "Eyða efni" + "Taka upp myndskeið" + "Taka kyrrmynd" + "Taka mynd" + "Byrja að taka upp myndskeið" + "Skipta yfir í myndavél á öllum skjánum" + "Skipt á milli fremri og aftari myndavélar" + "Stöðva upptöku og hengja myndskeið við" + "Stöðva upptöku myndskeiðs" + "Skilaboðamyndir" + + %d mynd vistuð í „%s“ möppu + %d myndir vistaðar í „%s“ möppu + + + %d myndskeið vistað í „%s“ möppu + %d myndskeið vistuð í „%s“ möppu + + + %d viðhengi vistað í „%s“ möppu + %d viðhengi vistuð í „%s“ möppu + + + %d viðhengi vistað í „Niðurhal“ + %d viðhengi vistuð í „Niðurhal“ + + + %d viðhengi vistað + %d viðhengi vistuð + + + Ekki tókst að vista %d viðhengi + Ekki tókst að vista %d viðhengi + + "MMS-viðhengi vistað" + "Stillingar" + "Geymsla" + "Loka" + "MMS" + "Ítarlegt" + "Villuleita" + "Tilkynningar" + "Hljóð" + "Hljóðlaust" + "Titringur" + "Á bannlista" + "SMS-skilatilkynningar" + "Óska eftir skilatilkynningu fyrir hver send SMS-skilaboð" + "Sækja sjálfkrafa" + "Sækja MMS-skilaboð sjálfkrafa" + "Sækja sjálfkrafa í reiki" + "Sækja MMS sjálfkrafa í reiki" + "Hópskilaboð" + "Nota MMS til að senda ein skilaboð þegar viðtakendur eru margir" + "Sjálfgefið SMS-forrit" + "Sjálfgefið SMS-forrit" + + + "Símanúmerið þitt" + "Óþekkt" + "Hljóð fyrir send skilaboð" + "Vista SMS-gögn" + "Vista ósniðin SMS-gögn í ytri geymsluskrá" + "Vista MMS-gögn" + "Vista ósniðin MMS-gögn í ytri geymsluskrá" + "Þráðlausar viðvaranir" + "Skilaboðavalkostir" + "Afrita texta" + "Skoða upplýsingar" + "Eyða" + "Áframsenda" + "Skilaboðaupplýsingar" + "Gerð: " + "Textaskilaboð" + "Margmiðlunarskilaboð" + "Frá: " + "Til: " + "Sent: " + "Móttekið: " + "Efni: " + "Stærð: " + "Forgangur: " + "SIM-kort: " + "Hár" + "Venjulegur" + "Lágur" + "SIM-kort %s" + "Falið netfang sendanda" + "Ekki er hægt að senda skilaboð á meðan verið er að hlaða viðhengi." + "Ekki er hægt að hlaða viðhengi. Reyndu aftur." + "Netið er ekki tilbúið. Reyndu aftur." + "Eyða texta" + "Skipta á milli innsláttar texta og talna" + "Bæta við fleiri þátttakendum" + "Staðfesta þátttakendur" + "Byrja nýtt samtal" + "Velja þetta atriði" + "Spila myndskeið" + "Fólk og valkostir" + "Villuleita" + "Fólk og valkostir" + "Almennt" + "Fólk í þessu samtali" + "Hringja símtal" + "Senda skilaboð" + "Senda skilaboð<br/><small>úr %s</small>" + + Senda myndir + Senda myndir + + + Senda hljóð + Senda hljóð + + + Senda myndskeið + Senda myndskeið + + + Senda tengiliðaspjöld + Senda tengiliðaspjöld + + + Senda viðhengi + Senda viðhengi + + + %d viðhengi tilbúið til sendingar + %d viðhengi tilbúin til sendingar + + "Senda ábendingu" + "Skoða í Google Play Store" + "Útgáfuupplýsingar" + "Útgáfa %1$s" + "Leyfi opins kóða" + "Tilkynningar" + "Hámarki viðhengja náð" + "Mistókst að hlaða viðhengið." + "Bæta við tengiliði?" + "Bæta tengilið við" + "Efni" + "Efni: " + "%s%s" + "Hleður tengiliðaspjald" + "Ekki var hægt að hlaða tengiliðaspjald" + "Skoða tengiliðaspjald" + + %d tengiliður + %d tengiliðir + + "Tengiliðaspjöld" + "Fæðingardagur" + "Athugasemdir" + "Áframsenda skilaboð" + "Svara" + "+1" + "+%d" + "Slökkt á SMS" + "Til að senda skaltu velja Skilaboð sem sjálfgefið SMS-forrit" + "Veldu Skilaboð sem sjálfgefið SMS-forrit" + "Breyta" + "Til að fá skilaboð skaltu velja Skilaboð sem sjálfgefið SMS-forrit" + "Ekkert SIM-kort valið fyrir sendingu SMS-skilaboða" + "Eigandi tækisins leyfir ekki þetta forrit." + "Í lagi" + "Of margir þátttakendur í samtali" + + Ógildir tengiliðir + Ógildir tengiliðir + + "Ekki var hægt að hlaða mynd frá myndavél" + "Þú: " + "%s: " + "Drög" + "Þegar þú hefur nýtt samtal birtist það hér" + "Samtöl í geymslu birtast hér" + "Hleður samtöl…" + "Mynd" + "Hljóðbútur" + "Myndskeið" + "Tengiliðaspjald" + "MMS" + "Afturkalla" + "Reyna aftur" + "Sláðu inn nafn eða símanúmer tengiliðar til að búa til ný skilaboð" + "Setja á bannlista" + "Útiloka %s" + "Taka %s af bannlista" + "Loka á %s?" + "Þú heldur áfram að fá skilaboð úr þessu númeri en færð ekki lengur tilkynningar. Þetta samtal verður sett í geymslu." + "Útilokaðir tengiliðir" + "TAKA AF BANNLISTA" + "Útilokaðir tengiliðir" + "Velja mynd úr skjalasafni" + "Sendir skilaboð" + "Skilaboð send" + "Slökkt er á farsímagögnum. Athugaðu stillingarnar." + "Ekki er hægt að senda skilaboð þegar flugstilling er virk" + "Ekki var hægt að senda skilaboðin" + "Skilaboð sótt" + "Slökkt er á farsímagögnum. Athugaðu stillingarnar." + "Ekki er hægt að sækja skilaboð þegar flugstilling er virk" + "Ekki var hægt að sækja skilaboðin" + "Núll" + "Einn" + "Tveir" + "Þrír" + "Fjórir" + "Fimm" + "Sex" + "Sjö" + "Átta" + "Níu" + "Ekki er hægt að senda skilaboð í gegnum %1$s, villa %2$d" + "Ekki er hægt að senda skilaboð í gegnum óþekkt símafyrirtæki, villa %1$d" + "Áfrs.: %s" + "Skilaboðin voru ekki send. Símkerfið hefur ekki gert þjónustuna virka" + "Skilaboðin voru ekki send: Ógildur áfangastaður" + "Skilaboðin voru ekki send: Ógild skilaboð" + "Skilaboðin voru ekki send: Óstutt innihald" + "Skilaboðin voru ekki send: Óstudd skilaboð" + "Skilaboðin voru ekki send: Of stór" + "Ný skilaboð" + "Skoða" + "Mynd" + "Viðeigandi forrit fannst ekki" + "Fjarlægja viðtakanda" + "Ný skilaboð" + "Hætta við" + "Breyta aðgangsstað" + "Ekki stillt" + "Heiti" + "Aðgangsstaður" + "MMSC" + "Proxy-þjónn fyrir MMS" + "MMS-gátt" + "MCC" + "MNC" + "Tegund aðgangsstaðar" + "Eyða aðgangsstað" + "Nýr aðgangsstaður" + "Vista" + "Fleygja" + "Reiturinn fyrir heiti má ekki vera auður." + "Heiti aðgangsstaðar má ekki vera autt." + "MCC-reitur verður að innihalda 3 tölustafi." + "MNC-reitur verður að innihalda 2 eða 3 tölustafi." + "Endurheimtir sjálfgefnar stillingar aðgangsstaðar." + "Núllstilla" + "Sjálfgefnar stillingar aðgangsstaðar endurheimtar." + "Ónefnt" + "Heiti aðgangsstaða" + "Aðgangsstaðir" + "Nýr aðgangsstaður" + "Stillingar fyrir heiti aðgangsstaðar eru ekki í boði fyrir þennan notanda" + "Afrita á klippiborð?" + "Afrita" + %s" + "Almennt" + "Ítarlegt" + "Almennar stillingar" + "Ítarlegar stillingar" + "SIM-kortið „%s“" + "Senda sérstök SMS-skilaboð til hvers viðtakanda fyrir sig. Svör verða aðeins send til þín" + "Senda ein MMS-skilaboð til allra viðtakenda" + "Óþekkt númer" + "Ný skilaboð" + "Ný skilaboð." + "Val á SIM-korti" + "%1$s valið, val á SIM-korti" + "Breyta efni" + "Velja SIM-kort eða breyta efni" + "Haltu inni til að taka upp hljóð" + "Hefja nýtt samtal" + "Skilaboð" + "Skilaboðalisti" + "Skilaboð" + "Ný skilaboð" + "Samtalalisti" + "Hleður samtöl" + "Hleður skilaboð" + "Skoða fleiri samtöl" + "Skoða fleiri skilaboð" + "Samtali eytt" + "Samtali eytt. Snertu til að sýna annað samtal í Skilaboðum" + "Á bannlista" + "Tekin(n) af bannlista" + "Geymslupláss er á þrotum. Einhver gögn kunna að glatast." + "Veldu viðhengi" + "Staðfesta val" + "%d valin" + "Fjarlægðu eitt eða fleiri viðhengi og reyndu aftur." + "Þú getur prófað að senda skilaboðin en þau komast ef til vill ekki til skila nema þú fjarlægir eitt eða fleiri viðhengi." + "Þú getur aðeins sent eitt myndskeið með hverjum skilaboðum. Fjarlægðu myndskeiðin sem eru umfram og reyndu aftur." + "Skilaboð gátu ekki hlaðið viðhengið." + "Senda samt" + "Ekki tókst að hefja samtal" + "%1$s (%2$s)" + "%s valið" + diff --git a/res/values-it/arrays.xml b/res/values-it/arrays.xml new file mode 100644 index 0000000..16f7792 --- /dev/null +++ b/res/values-it/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "nessun oggetto" + "nosubject" + + + "Sì" + "No" + "OK" + "He he" + "Grazie" + "Accetto" + "Bene" + "Sto arrivando" + "OK, ti ricontatto più tardi" + ":)" + ":(" + + diff --git a/res/values-it/strings.xml b/res/values-it/strings.xml new file mode 100644 index 0000000..7261f25 --- /dev/null +++ b/res/values-it/strings.xml @@ -0,0 +1,519 @@ + + + + + "Messaggi" + "Messaggi" + "Seleziona conversazione" + "Impostazioni" + "Invia messaggio" + "Aggiungi un allegato" + "Guida" + "Benvenuto" + "Ignora" + "Avanti >" + "Avanti" + "Esci" + "Impostazioni >" + "Impostazioni" + "Messaggi richiede l\'autorizzazione per SMS, Telefono e Contatti." + "Puoi modificare le autorizzazioni in Impostazioni > Applicazioni > Messaggi > Autorizzazioni." + "Puoi modificare le autorizzazioni in Impostazioni > Applicazioni > Messaggi > Autorizzazioni." + "Frequenti" + "Tutti i contatti" + "Invia a %s" + "Scatta foto o registra video" + "Scegli immagini su questo dispositivo" + "Registra audio" + "Scegli foto" + "Il contenuto multimediale è selezionato." + "Il contenuto multimediale è deselezionato." + "%d selezionati" + "immagine: %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "immagine" + "Registrazione audio" + "Condividi" + "Adesso" + "Ora" + + %d min + %d min + + + %d ore + %d ora + + + %d giorni + %d giorno + + + %d settimane + una settimana + + + %d mesi + un mese + + + %d anni + un anno + + "Messaggio classe 0" + "Salva" + "Spazio insufficiente sul dispositivo. Messaggi eliminerà automaticamente i messaggi meno recenti per liberare spazio." + "Spazio di archiviazione in esaurimento" + "Messaggi potrebbe non inviare o ricevere messaggi finché non si libererà spazio sul dispositivo." + "Memoria per gli SMS scarsa. Potresti dover eliminare alcuni messaggi." + "Conferma il tuo numero di telefono" + "Con questo passaggio da eseguire una sola volta, puoi assicurarti che Messaggi invii correttamente i messaggi di gruppo." + "Numero di telefono" + "Elimina tutti i messaggi con contenuti multimediali" + "Elimina messaggi più vecchi di %s" + "Elimina automaticamente messaggi più vecchi di %s" + "Ignora" + "Eliminare tutti i messaggi con contenuti multimediali?" + "Eliminare i messaggi più vecchi di %s?" + "Eliminare i messaggi più vecchi di %s e attivare l\'eliminazione automatica?" + "Messaggio di %s" + "Il tuo messaggio" + "Messaggio di %s" + "Hai inviato un messaggio" + "Invio…" + "Non inviato. Tocca per riprovare." + "Non inviato. Nuovo tentativo…" + "Invia di nuovo o elimina" + "Effettua una chiamata vocale al numero di emergenza. Al momento non è possibile recapitare l\'SMS." + "Operazione non riuscita" + "Nuovo messaggio MMS da scaricare" + "Nuovo messaggio MMS" + "Download non riuscito" + "Tocca per riprovare" + "Tocca per scaricare" + "Scarica o elimina" + "Download..." + "Messaggio scaduto o non disponibile" + "dimensioni: %1$s, scadenza: %2$s" + "Impossibile inviare. Destinatario non valido." + "Servizio non attivato sulla rete" + "Invio non riuscito a causa di problemi di rete" + "Messaggio scaduto o non disponibile" + "(Nessun oggetto)" + "Mittente sconosciuto" + "Recapitato" + "Download del messaggio %1$s inviato da %2$s non riuscito." + "Completamento dell\'operazione nel database non riuscito a causa di memoria insufficiente" + "Messaggio non inviato" + "Alcuni messaggi non inviati in Messaggi" + + %d messaggi in %d conversazioni + %d messaggi in una conversazione + + "Messaggio non scaricato" + "Alcuni messaggi non scaricati in Messaggi" + + %d messaggi in %d conversazioni + %d messaggi in una conversazione + + "Messaggio al numero %1$s non inviato" + "Effettua una chiamata vocale al numero di emergenza. Al momento non è possibile recapitare l\'SMS al numero %1$s." + + %d nuovi messaggi + Nuovo messaggio + + "Avvia" + "Fotocamera non disponibile" + "Fotocamera non disponibile" + "Acquisizione video non disponibile" + "Impossibile salvare i contenuti multimediali" + "Impossibile scattare una foto" + "Indietro" + "Archiviate" + "Elimina" + "Archivio" + "Annulla archiviazione" + "Disattiva notifiche" + "Attiva notifiche" + "Aggiungi contatto" + "Scarica" + "Invia" + "Elimina" + "Eliminare questo messaggio?" + "L\'operazione non può essere annullata." + "Elimina" + + Eliminare queste conversazioni? + Eliminare questa conversazione? + + "Elimina" + "Annulla" + "A" + "Seleziona diverse immagini" + "Conferma selezione" + "e altri %d" + "Impossibile registrare l\'audio. Riprova." + "Impossibile riprodurre l\'audio. Riprova." + "Impossibile salvare l\'audio. Riprova." + "Tocca e tieni premuto" + ", " + " " + ": " + " " + "Immagine" + "Clip audio" + "Video" + "Scheda contatto" + "Scarica" + "Rispondi tramite SMS" + "Rispondi con MMS" + "Rispondi" + + %d partecipanti + %d participante + + "Io" + "Contatto bloccato e archiviato" + "Contatto sbloccato e non archiviato" + "%d archiviate" + "%d rimosse dall\'archivio" + "Notifiche disattivate" + "Notifiche attivate" + "Fatto. Tocca di nuovo Invia." + "Messaggi impostato correttamente come app predefinita per gli SMS." + + Elimina allegati + Elimina allegato + + "Allegato audio" + "Riproduci allegato audio" + "Metti in pausa" + "Messaggio da %s: %s." + "Messaggio di %s non recapitato: %s. Data/ora: %s." + "Messaggio di %s: %s. Data/ora: %s." + "Messaggio per %s non inviato: %s. Data/ora: %s." + "Invio del messaggio a %s: %s. Data/ora: %s." + "Messaggio per %s non recapitato: %s. Data/ora: %s." + "Messaggio per %s: %s. Data/ora: %s." + "Messaggio di %s non recapitato: %s. Data/ora: %s. %s." + "Messaggio di %s: %s. Data/ora: %s. %s." + "Messaggio per %s non inviato: %s. Data/ora: %s." + "Invio del messaggio a %s: %s. Data/ora: %s." + "Messaggio per %s non recapitato: %s. Data/ora: %s." + "Messaggio per %s: %s. Data/ora: %s." + "Messaggio non recapitato. Tocca per riprovare." + "Conversazione con %s" + "Elimina oggetto" + "Registra video" + "Scatta una foto in posa" + "Scatta una foto" + "Inizia a registrare il video" + "Passa alla fotocamera a schermo intero" + "Passa dalla fotocamera frontale a quella posteriore e viceversa" + "Interrompi registrazione e aggiungi video" + "Interrompi registrazione video" + "Foto in Messaggi" + + %d foto salvate nell\'album \"%s\" + %d foto salvata nell\'album \"%s\" + + + %d video salvati nell\'album \"%s\" + %d video salvato nell\'album \"%s\" + + + %d allegati salvati nell\'album \"%s\" + %d allegato salvato nell\'album \"%s\" + + + %d allegati salvati in \"Download\" + %d allegato salvato in \"Download\" + + + %d allegati salvati + %d allegato salvato + + + Impossibile salvare %d allegati + Impossibile salvare %d allegato + + "Allegato MMS salvato" + "Impostazioni" + "Archiviate" + "Chiudi" + "MMS" + "Avanzate" + "Debug" + "Notifiche" + "Suono" + "Silenzioso" + "Vibrazione" + "Bloccato" + "Rapporti di consegna SMS" + "Richiedi conferma di recapito per ogni SMS inviato" + "Recupero automatico" + "Recupera messaggi MMS automaticamente" + "Recupero automatico in roaming" + "Recupero automatico di MMS in roaming" + "Messaggi di gruppo" + "Utilizza gli MMS per inviare un unico messaggio a più destinatari" + "App SMS predefinita" + "App SMS predefinita" + + + "Il tuo numero di telefono" + "Sconosciuto" + "Riproduzione audio per messaggi in uscita" + "Scarica SMS" + "Scarica dati non elaborati SMS ricevuti in file di archiviazione esterna" + "Scarica MMS" + "Scarica dati non elaborati MMS ricevuti in file di archiviazione esterna" + "Avvisi wireless" + "Opzioni messaggio" + "Copia testo" + "Visualizza dettagli" + "Elimina" + "Inoltra" + "Dettagli messaggio" + "Tipo: " + "SMS" + "Messaggio multimediale" + "Da: " + "A: " + "Inviato: " + "Ricevuto in data: " + "Oggetto: " + "Dimensioni: " + "Priorità: " + "SIM: " + "Alta" + "Normale" + "Bassa" + "SIM %s" + "Indirizzo mittente nascosto" + "Impossibile inviare il messaggio durante il caricamento degli allegati." + "Impossibile caricare l\'allegato. Riprova." + "La rete non è pronta. Riprova." + "Elimina testo" + "Passa tra immissione testo e numeri" + "Aggiungi altri partecipanti" + "Conferma partecipanti" + "Inizia nuova conversazione" + "Seleziona questo elemento" + "Riproduci video" + "Persone e opzioni" + "Debug" + "Persone e opzioni" + "Generali" + "Persone presenti nella conversazione" + "Fai una chiamata" + "Invia messaggio" + "Invia messaggio<br/><small>da %s</small>" + + Invia foto + Invia foto + + + Invia audio + Invia audio + + + Invia video + Invia video + + + Invia schede contatti + Invia scheda contatto + + + Invia allegati + Invia allegato + + + %d allegati pronti da inviare + Un allegato pronto da inviare + + "Invia feedback" + "Visualizza nel Google Play Store" + "Info versione" + "Versione %1$s" + "Licenze open source" + "Notifiche" + "Limite di allegati raggiunto" + "Impossibile caricare l\'allegato." + "Aggiungere a Contatti?" + "Aggiungi contatto" + "Oggetto" + "Oggetto: " + "%s%s" + "Caricamento scheda contatto in corso" + "Impossibile caricare la scheda del contatto" + "Visualizza scheda contatto" + + %d contatti + %d contatto + + "Schede contatti" + "Compleanno" + "Note" + "Inoltra messaggio" + "Rispondi" + "+1" + "+%d" + "SMS disattivati" + "Per inviare messaggi, imposta Messaggi come app predefinita per gli SMS" + "Imposta Messaggi come app predefinita per gli SMS" + "Modifica" + "Per ricevere messaggi, imposta Messaggi come app predefinita per gli SMS" + "Nessuna SIM preferita selezionata per l\'invio di messaggi SMS" + "Questa app non è consentita dal proprietario del dispositivo." + "OK" + "Troppi partecipanti alla conversazione" + + Contatti non validi + Contatto non valido + + "Impossibile caricare l\'immagine della fotocamera" + "Tu: " + "%s: " + "Bozza" + "Le nuove conversazioni che inizi verranno elencate qui" + "Le conversazioni archiviate vengono visualizzate qui" + "Caricamento conversazioni in corso..." + "Immagine" + "Clip audio" + "Video" + "Scheda contatto" + "MMS" + "Annulla" + "Riprova" + "Inserisci il nome o il numero di telefono di un contatto per iniziare un nuovo messaggio" + "Blocca" + "Blocca %s" + "Sblocca %s" + "Bloccare %s?" + "Continuerai a ricevere messaggi da questo numero ma non riceverai più notifiche. Questa conversazione verrà archiviata." + "Contatti bloccati" + "SBLOCCA" + "Contatti bloccati" + "Scegli immagine da raccolta documenti" + "Invio messaggio" + "Messaggio inviato" + "Dati mobili non attivi. Controlla le impostazioni." + "Impossibile inviare messaggi in modalità Aereo" + "Messaggio non inviato" + "Messaggio scaricato" + "Dati mobili non attivi. Controlla le impostazioni." + "Impossibile scaricare messaggi in modalità aereo" + "Impossibile scaricare il messaggio" + "Zero" + "Uno" + "Due" + "Tre" + "Quattro" + "Cinque" + "Sei" + "Sette" + "Otto" + "Nove" + "Impossibile inviare il messaggio tramite %1$s. Errore %2$d" + "Impossibile inviare il messaggio tramite un operatore sconosciuto. Errore %1$d" + "Fwd: %s" + "Messaggio non inviato: servizio non attivato nella rete" + "Messaggio non inviato: indirizzo di destinazione non valido" + "Messaggio non inviato: messaggio non valido" + "Messaggio non inviato: contenuti non supportati" + "Messaggio non inviato: messaggio non supportato" + "Messaggio non inviato: troppo grande" + "Nuovo messaggio" + "Visualizza" + "Immagine" + "Impossibile trovare un\'applicazione adeguata" + "Rimuovi destinatario" + "Nuovo messaggio" + "Annulla" + "Modifica punto di accesso" + "Non impostato" + "Nome" + "APN" + "MMSC" + "Proxy MMS" + "Porta MMS" + "MCC" + "MNC" + "Tipo APN" + "Elimina APN" + "Nuovo APN" + "Salva" + "Elimina" + "Il campo Nome non può essere vuoto." + "Il campo APN non può essere vuoto." + "Il campo MCC deve contenere 3 cifre." + "Il campo MNC deve contenere 2 o 3 cifre." + "Ripristino delle impostazioni APN predefinite." + "Ripristina impostazioni predefinite" + "Ripristino delle impostazioni APN predefinite completato." + "Senza titolo" + "Nomi punti di accesso" + "APN" + "Nuovo APN" + "Le impostazioni del nome punto di accesso non sono disponibili per questo utente" + "Copiare negli appunti?" + "Copia" + "a %s" + "Generali" + "Avanzate" + "Impostazioni generali" + "Impostazioni avanzate" + "SIM \"%s\"" + "Invia SMS singoli a tutti i destinatari. Soltanto tu riceverai le risposte" + "Invia un singolo MMS a tutti i destinatari" + "Numero sconosciuto" + "Nuovo messaggio" + "Nuovo messaggio." + "Selettore SIM" + "Scheda %1$s selezionata, selettore SIM" + "Modifica oggetto" + "Seleziona la SIM o modifica l\'oggetto" + "Tocca e tieni premuto per registrare l\'audio" + "Inizia nuova conversazione" + "Messaggi" + "Elenco messaggi" + "Messaggi" + "Nuovo messaggio" + "Elenco conversazioni" + "Caricamento conversazioni..." + "Caricamento dei messaggi in corso" + "Visualizza altre conversazioni" + "Mostra altri messaggi" + "Conversazione eliminata" + "Conversazione eliminata. Tocca per vedere un\'altra conversazione di Messaggi" + "Bloccato" + "Sbloccato" + "Lo spazio di archiviazione è quasi esaurito. Alcuni dati potrebbero andare perduti." + "Seleziona allegati" + "Conferma selezione" + "%d selezionati" + "Rimuovi uno o più allegati e riprova." + "Puoi provare a inviare il messaggio, che però potrebbe non essere recapitato se non rimuovi uno o più allegati." + "Puoi inviare un solo video per messaggio. Rimuovi i video aggiuntivi e riprova." + "Messaggi non ha potuto caricare l\'allegato." + "Invia comunque" + "Impossibile avviare la conversazione" + "%1$s (%2$s)" + "SIM selezionata: %s" + diff --git a/res/values-iw/arrays.xml b/res/values-iw/arrays.xml new file mode 100644 index 0000000..91bd7db --- /dev/null +++ b/res/values-iw/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "ללא נושא" + "ללא נושא" + + + "כן" + "לא" + "אישור" + "חחח" + "תודה" + "אני מסכים" + "נחמד" + "אני בדרך" + "אוקיי, נדבר מאוחר יותר" + ":)" + ":(" + + diff --git a/res/values-iw/strings.xml b/res/values-iw/strings.xml new file mode 100644 index 0000000..74e46bf --- /dev/null +++ b/res/values-iw/strings.xml @@ -0,0 +1,571 @@ + + + + + "העברת הודעות" + "העברת הודעות" + "בחר שיחה" + "הגדרות" + "שלח הודעה" + "הוסף קובץ מצורף" + "עזרה" + "ברוכים הבאים!" + "דלג" + "‏הבא >" + "הבא" + "צא" + "‏הגדרות >" + "הגדרות" + "‏\'העברת הודעות\' זקוק להרשאות SMS, טלפון ואנשי קשר." + "‏תוכל לשנות את ההרשאות ב\'הגדרות\' > \'אפליקציות\' >‏ \'העברת הודעות\' ‏> \'הרשאות\'." + "תוכל לשנות את ההרשאות ב\'הגדרות\', \'אפליקציות\', \'העברת הודעות\', \'הרשאות\'." + "בתדירות גבוהה" + "כל אנשי הקשר" + "שלח אל %s" + "צלם תמונות או סרטון" + "בחר תמונות ממכשיר זה" + "הקלט אודיו" + "בחר תמונה" + "בחרת במדיה." + "ביטלת את הבחירה במדיה." + "%d נבחרו" + "תמונה %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "תמונה" + "הקלט אודיו" + "שיתוף" + "ממש עכשיו" + "עכשיו" + + %d דקות + %d דקות + %d דקות + דקה %d + + + %d שעות + %d שעות + %d שעות + שעה %d + + + %d ימים + %d ימים + %d ימים + יום %d + + + שבועות + שבועות%d + שבועות + שבוע + + + %d חודשים + %d חודשים + %d חודשים + חודש + + + שנים + שנים%d + שנים + שנה + + "הודעה ברמה 0" + "שמור" + "נותר מעט שטח פנוי במכשיר. \'העברת הודעות\' ימחק הודעות ישנות באופן אוטומטי כדי לפנות שטח." + "שטח האחסון אוזל" + "לא תוכל לשלוח או לקבל הודעות ב\'העברת הודעות\' עד שתפנה שטח במכשיר." + "‏אין מספיק שטח אחסון להודעות SMS. ייתכן שתצטרך למחוק הודעות." + "אשר את מספר הטלפון שלך" + "שלב חד-פעמי זה יבטיח שהאפליקציה \'העברת הודעות\' תעביר כראוי את ההודעות הקבוצתיות שלך." + "מספר טלפון" + "מחק את כל ההודעות עם מדיה" + "מחק הודעות בנות יותר מ-%s" + "מחק אוטומטית הודעות שגילן עולה על %s" + "התעלם" + "למחוק את כל ההודעות עם המדיה?" + "למחוק הודעות שגילן עולה על %s?" + "למחוק הודעות שגילן עולה על %s ולהפעיל מחיקה אוטומטית?" + "%s אמר" + "אמרת" + "הודעה מאת %s" + "שלחת הודעה" + "שולח..." + "ההודעה לא נשלחה. גע כדי לנסות שוב." + "ההודעה לא נשלחה. מנסה שוב…" + "שלח שוב או מחק" + "בצע שיחה קולית לשירותי חירום. לא ניתן היה לשלוח את הודעת הטקסט." + "נכשל" + "‏הודעת MMS חדשה להורדה" + "‏הודעת MMS חדשה" + "לא ניתן לבצע את ההורדה" + "גע כדי לנסות שוב" + "גע כדי להוריד" + "הורד או מחק" + "מוריד…" + "תוקף ההודעה פג או שהיא לא זמינה" + "גודל: %1$s, תפוגה: %2$s" + "לא ניתן לשלוח. הנמען אינו חוקי." + "השירות לא הופעל ברשת" + "לא ניתן לשלוח בגלל בעיה ברשת" + "התוקף של ההודעה פג או שהיא לא זמינה" + "(ללא נושא)" + "שולח לא ידוע" + "נמסר" + "לא ניתן היה להוריד את ההודעה %1$s מ-%2$s." + "לא ניתן היה להשלים את הפעולה של מסד הנתונים עקב זיכרון לא מספיק" + "ההודעה לא נשלחה" + "\'העברת הודעות\' לא שלח חלק מההודעות" + + %d הודעות ב-%d שיחות + %d הודעות ב-%d שיחות + %d הודעות ב-%d שיחות + %d הודעות בשיחה אחת + + "ההודעה לא הורדה" + "\'העברת הודעות\' לא הוריד חלק מההודעות" + + %d הודעות ב-%d שיחות + %d הודעות ב-%d שיחות + %d הודעות ב-%d שיחות + %d הודעות בשיחה אחת + + "ההודעה אל %1$s לא נשלחה" + "התקשר לשירותי חירום. לא ניתן היה לשלוח את הודעת הטקסט אל %1$s." + + %d הודעות חדשות + %d הודעות חדשות + %d הודעות חדשות + הודעה חדשה + + "התחל" + "המצלמה לא זמינה" + "המצלמה לא זמינה" + "לכידת וידאו אינה זמינה" + "לא ניתן לשמור מדיה" + "לא ניתן לצלם את התמונה" + "הקודם" + "הועברו לארכיון" + "מחק" + "העבר לארכיון" + "הוצאה מארכיון" + "כבה התראות" + "הפעל התראות" + "הוסף איש קשר" + "הורד" + "שלח" + "מחק" + "למחוק הודעה זו?" + "פעולה זו אינה הפיכה." + "מחק" + + האם למחוק את השיחות האלו? + האם למחוק את השיחות האלו? + האם למחוק את השיחות האלו? + האם למחוק את השיחה הזו? + + "מחק" + "בטל" + "אל" + "בחר תמונות מרובות" + "אשר את הבחירה" + "+%d" + "לא ניתן להקליט אודיו. נסה שוב." + "לא ניתן להפעיל אודיו. נסה שוב." + "לא ניתן לשמור אודיו. נסה שוב." + "גע והחזק" + ", " + " " + ": " + " " + "תמונה" + "קטע אודיו" + "סרטון" + "כרטיס איש קשר" + "הורד" + "‏השב באמצעות SMS" + "‏השב באמצעות MMS" + "השב" + + %d משתתפים + %d משתתפים + %d משתתפים + משתתף %d + + "אני" + "איש הקשר נחסם והועבר לארכיון" + "חסימת איש הקשר בוטלה והוא הוצא מהארכיון" + "%d הועברו לארכיון" + "%d הוצאו מהארכיון" + "ההודעות כבויות" + "ההודעות פועלות" + "הכל מוכן. גע באפשרות \'שלח שוב\'." + "‏האפליקציה \'העברת הודעות\' הוגדרה בהצלחה כאפליקציית ברירת המחדל ל-SMS." + + מחק את הקבצים המצורפים + מחק את הקבצים המצורפים + מחק את הקבצים המצורפים + מחק את הקובץ המצורף + + "קובץ אודיו מצורף" + "הפעל את קובץ האודיו המצורף" + "השהה" + "הודעה מאת %s: %s." + "הודעה מאת %s שנכשלה: %s. שעה: %s." + "הודעה מאת %s: %s. שעה: %s." + "הודעה אל %s שלא נשלחה: %s. שעה: %s." + "שולח הודעה אל %s: %s. שעה: %s." + "הודעה אל %s שנכשלה: %s. שעה: %s." + "הודעה אל %s: %s. שעה: %s." + "הודעה שנכשלה מאת %s‏: %s. שעה: %s. %s." + "הודעה מאת %s‏: %s. שעה: %s. %s." + "הודעה אל %s שלא נשלחה: %s. שעה: %s." + "שולח הודעה אל %s: %s. שעה: %s." + "הודעה אל %s שנכשלה: %s. שעה: %s." + "הודעה אל %s: %s. שעה: %s." + "ההודעה נכשלה. גע כדי לנסות שוב." + "שיחה עם %s" + "מחק נושא" + "צלם סרטון" + "צלם תמונת סטילס" + "צלם תמונה" + "התחל הקלטת סרטון" + "עבור למצלמה במסך מלא" + "החלף בין המצלמה הקדמית לאחורית" + "הפסק להקליט וצרף סרטון" + "עצור הקלטת וידאו" + "תמונות ב\'העברת הודעות\'" + + %d תמונות נשמרו באלבום \"%s\" + %d תמונות נשמרו באלבום \"%s\" + %d תמונות נשמרו באלבום \"%s\" + תמונה %d נשמרה באלבום \"%s\" + + + %d סרטונים נשמרו באלבום \"%s\" + %d סרטונים נשמרו באלבום \"%s\" + %d סרטונים נשמרו באלבום \"%s\" + סרטון %d נשמר באלבום \"%s\" + + + %d קבצים מצורפים נשמרו באלבום \"%s\" + %d קבצים מצורפים נשמרו באלבום \"%s\" + %d קבצים מצורפים נשמרו באלבום \"%s\" + קובץ מצורף %d נשמר באלבום \"%s\" + + + %d קבצים מצורפים נשמרו ב\"הורדות\" + %d קבצים מצורפים נשמרו ב\"הורדות\" + %d קבצים מצורפים נשמרו ב\"הורדות\" + קובץ מצורף %d נשמר ב\"הורדות\" + + + %d קבצים מצורפים נשמרו + %d קבצים מצורפים נשמרו + %d קבצים מצורפים נשמרו + קובץ מצורף %d נשמר + + + לא ניתן היה לשמור %d קבצים מצורפים + לא ניתן היה לשמור %d קבצים מצורפים + לא ניתן היה לשמור %d קבצים מצורפים + לא ניתן היה לשמור קובץ מצורף %d + + "‏קובץ ה-MMS המצורף נשמר" + "הגדרות" + "הועברו לארכיון" + "סגור" + "MMS" + "מתקדם" + "ניפוי באגים" + "הודעות" + "צליל" + "מצב שקט" + "רטט" + "חסום" + "‏דוחות מסירת SMS" + "‏בקש דוח מסירה לכל SMS שאתה שולח" + "אחזור אוטומטי" + "‏אחזור אוטומטי של הודעות MMS" + "אחזור אוטומטי בנדידה" + "‏אחזר הודעות MMS באופן אוטומטי בזמן נדידה" + "העברת הודעות לקבוצה" + "‏השתמש ב-MMS לשליחת הודעה אחת לנמענים מרובים" + "‏אפליקציית SMS המוגדרת כברירת מחדל" + "‏אפליקציית SMS המוגדרת כברירת מחדל" + + + "מספר הטלפון שלך" + "לא ידוע" + "צלילים של הודעות יוצאות" + "‏צור Dump של SMS" + "‏צור Dump בקובץ אחסון חיצוני עבור נתוני SMS גולמיים שהתקבלו" + "‏צור Dump של MMS" + "‏צור Dump בקובץ אחסון חיצוני עבור נתוני MMS גולמיים שהתקבלו" + "התראות אלחוטיות" + "אפשרויות הודעה" + "העתק טקסט" + "הצגת פרטים" + "מחיקה" + "העבר" + "פרטי הודעה" + "סוג: " + "הודעת טקסט" + "הודעת מולטימדיה" + "מאת: " + "אל: " + "נשלחה ב: " + "התקבלה ב: " + "נושא: " + "גודל: " + "עדיפות: " + "SIM: " + "גבוהה" + "רגילה" + "נמוכה" + "‏SIM‏ %s" + "כתובת שולח מוסתרת" + "לא ניתן לשלוח הודעה בעת טעינת קבצים מצורפים." + "לא ניתן לטעון קובץ מצורף. נסה שוב." + "הרשת לא מוכנה. נסה שוב." + "מחק טקסט" + "עבור בין הזנה של אותיות וספרות" + "הוסף עוד משתמשים" + "אשר משתתפים" + "התחל שיחה חדשה" + "בחר בפריט הזה" + "הפעל סרטון" + "אנשים ואפשרויות" + "ניפוי באגים" + "אנשים ואפשרויות" + "כללי" + "אנשים בשיחה הזו" + "התקשר" + "שלח הודעה" + "‏שלח הודעה<br/><small>מ-%s</small>" + + שלח את התמונות + שלח את התמונות + שלח את התמונות + שלח את התמונה + + + שלח את הקלטות האודיו + שלח את הקלטות האודיו + שלח את הקלטות האודיו + שלח את הקלטת האודיו + + + שלח את הסרטונים + שלח את הסרטונים + שלח את הסרטונים + שלח את הסרטון + + + שלח את כרטיסי אנשי הקשר + שלח את כרטיסי אנשי הקשר + שלח את כרטיסי אנשי הקשר + שלח את כרטיס איש הקשר + + + שלח את הקבצים המצורפים + שלח את הקבצים המצורפים + שלח את הקבצים המצורפים + שלח את הקובץ המצורף + + + יש %d קבצים מצורפים המוכנים לשליחה + יש %d קבצים מצורפים המוכנים לשליחה + יש %d קבצים מצורפים המוכנים לשליחה + יש קובץ מצורף אחד המוכן לשליחה + + "שלח משוב" + "‏הצג בחנות Google Play" + "פרטי גרסה" + "‏גרסה %1$s" + "רישיונות קוד פתוח" + "הודעות" + "צירפת את מספר הקבצים המקסימלי" + "טעינת הקובץ המצורף נכשלה." + "האם להוסיף לאנשי הקשר?" + "הוסף איש קשר" + "נושא" + "נושא: " + "%s%s" + "טוען את כרטיס איש הקשר" + "לא ניתן לטעון כרטיס איש קשר" + "הצג את כרטיס איש הקשר" + + %d אנשי קשר + %d אנשי קשר + %d אנשי קשר + איש קשר %d + + "כרטיסי אנשי קשר" + "יום הולדת" + "הערות" + "העבר הודעה" + "השב" + "+1" + "+%d" + "‏SMS מושבת" + "‏כדי לשלוח, הגדר את האפליקציה \'העברת הודעות\' כאפליקציית ברירת המחדל ל-SMS" + "‏הגדר את האפליקציה \'העברת הודעות\' כאפליקציית ברירת המחדל ל-SMS" + "שנה" + "‏כדי לקבל הודעות, הגדר את \'העברת הודעות\' כאפליקציית ברירת המחדל ל-SMS" + "‏לא נבחר SIM מועדף עבור שליחה של הודעות SMS" + "אפליקציה זו אינה מורשית על ידי בעל המכשיר." + "אישור" + "משתתפים רבים מדי בשיחה" + + אנשי קשר לא חוקיים + אנשי קשר לא חוקיים + אנשי קשר לא חוקיים + איש קשר לא חוקי + + "לא ניתן לטעון תמונת מצלמה" + "אתה: " + "%s: " + "טיוטה" + "כשתתחיל שיחה חדשה, תראה אותה ברשימה כאן" + "שיחות שהועברו לארכיון מופיעות כאן" + "טוען שיחות..." + "תמונה" + "קטע אודיו" + "סרטון" + "כרטיס איש קשר" + "MMS" + "בטל" + "נסה שוב" + "הזן שם איש קשר או מספר טלפון כדי להתחיל הודעה חדשה" + "חסום" + "חסום את %s" + "בטל חסימה של %s" + "לחסום את %s?" + "תמשיך לקבל הודעות ממספר זה אך לא יוצגו לך עוד דיווחים. שיחה זו תועבר לארכיון." + "אנשי קשר חסומים" + "אפשר" + "אנשי קשר חסומים" + "בחר תמונה מספריית המסמכים" + "שולח את ההודעה" + "ההודעה נשלחה" + "נתונים סלולריים כבויים. בדוק את ההגדרות." + "לא ניתן לשלוח הודעות במצב טיסה" + "לא ניתן היה לשלוח את ההודעה" + "הורדת ההודעה בוצעה" + "נתונים סלולריים כבויים. בדוק את ההגדרות." + "לא ניתן להוריד הודעות במצב טיסה" + "לא ניתן היה להוריד את ההודעה" + "אפס" + "אחת" + "שתיים" + "שלוש" + "ארבע" + "חמש" + "שש" + "שבע" + "שמונה" + "תשע" + "לא ניתן לשלוח הודעה דרך %1$s, שגיאה %2$d" + "לא ניתן לשלוח הודעה דרך ספק לא ידוע, שגיאה %1$d" + "העבר: %s" + "ההודעה לא נשלחה: השירות לא הופעל ברשת." + "ההודעה לא נשלחה: כתובת היעד לא חוקית" + "ההודעה לא נשלחה: הודעה לא חוקית" + "ההודעה לא נשלחה: תוכן לא נתמך" + "ההודעה לא נשלחה: הודעה לא נתמכת" + "ההודעה לא נשלחה מפני שהיא גדולה מדי" + "הודעה חדשה" + "הצג" + "תמונה" + "לא נמצא יישום מתאים" + "הסר נמען" + "הודעה חדשה" + "בטל" + "ערוך נקודת גישה" + "לא מוגדרת" + "שם" + "APN" + "MMSC" + "‏שרת Proxy של MMS" + "‏יציאת MMS" + "MCC" + "MNC" + "‏סוג APN" + "‏מחק APN" + "‏APN חדש" + "שמור" + "מחק" + "השדה \'שם\' לא יכול להיות ריק." + "‏ה-APN לא יכול להיות ריק." + "‏שדה MCC חייב להכיל 3 ספרות." + "‏שדה MNC חייב להכיל 2 או 3 ספרות." + "‏משחזר את הגדרות ברירת המחדל של APN." + "אפס לברירת מחדל" + "‏איפוס ההגדרות עבור ברירת המחדל של APN הושלם." + "ללא שם" + "שמות של נקודות גישה" + "APNs" + "‏APN חדש" + "‏הגדרות עבור שם נקודת גישה (APN) אינן זמינות עבור המשתמש הזה" + "האם להעתיק אל לוח העריכה?" + "העתק" + "אל %s" + "כללי" + "מתקדם" + "הגדרות כלליות" + "הגדרות מתקדמות" + "\"%s\" SIM" + "‏שלח הודעות SMS אישיות לכל הנמענים. רק אתה תקבל תשובות" + "‏שלח הודעת MMS אחת לכל הנמענים" + "מספר לא ידוע" + "הודעה חדשה" + "הודעה חדשה." + "‏בורר כרטיסי SIM" + "‏%1$s נבחר, בוחר כרטיסי SIM" + "ערוך נושא" + "‏בחר כרטיס SIM או ערוך את הנושא" + "גע נגיעה ממושכת כדי להקליט אודיו" + "התחל שיחה חדשה" + "\'העברת הודעות\'" + "רשימת \'העברת הודעות\'" + "\'העברת הודעות\'" + "הודעה חדשה" + "רשימת שיחות" + "טוען שיחות" + "טוען הודעות" + "הצג שיחות נוספות" + "הצג עוד הודעות" + "השיחה נמחקה" + "השיחה נמחקה. גע כדי להציג שיחה אחרת ב\'העברת הודעות\'" + "חסום" + "חסימה בוטלה" + "אין מספיק שטח אחסון. ייתכן שתאבד חלק מהנתונים." + "בחר קבצים מצורפים" + "אשר את הבחירה" + "בחרת %d" + "הסר קובץ מצורף אחד או יותר ונסה שוב." + "תוכל לנסות לשלוח את ההודעה, אך ייתכן שהיא לא תימסר אלא אם תסיר קובץ מצורף אחד או יותר." + "ניתן לשלוח רק סרטון אחד בכל הודעה. הסר סרטונים נוספים ונסה שוב." + "טעינת הקובץ המצורף נכשלה ב\'העברת הודעות\'." + "שלח בכל זאת" + "לא ניתן היה להתחיל את השיחה" + "%1$s (%2$s)" + "%s נבחר" + diff --git a/res/values-ja/arrays.xml b/res/values-ja/arrays.xml new file mode 100644 index 0000000..cf9e1a6 --- /dev/null +++ b/res/values-ja/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "件名なし" + "件名なし" + + + "はい" + "いいえ" + "OK" + "笑" + "ありがとう" + "賛成" + "ナイス" + "すぐに連絡します" + "後で連絡します" + ":)" + ":(" + + diff --git a/res/values-ja/strings.xml b/res/values-ja/strings.xml new file mode 100644 index 0000000..49df2fc --- /dev/null +++ b/res/values-ja/strings.xml @@ -0,0 +1,519 @@ + + + + + "メッセージ" + "メッセージ" + "会話の選択" + "設定" + "メッセージを送信" + "添付ファイルを追加" + "ヘルプ" + "ようこそ" + "スキップ" + "次へ >" + "次へ" + "終了" + "設定 >" + "設定" + "SMSには、SMS、電話、連絡先に対する許可が必要です。" + "許可の変更は、[設定]>[アプリ]>[SMS]>[許可]で行えます。" + "許可の変更は、[設定]、[アプリ]、[SMS]、[許可]で行えます。" + "よく使う連絡先" + "すべての連絡先" + "%sに送信" + "写真や動画を撮影" + "この端末から画像を選択" + "音声の録音" + "写真を選択" + "メディアを選択しました。" + "メディアの選択を解除しました。" + "%d件選択済み" + "画像%1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "画像" + "音声の録音" + "共有" + "たった今" + "今" + + %d + %d + + + %d時間 + %d時間 + + + %d + %d + + + %d週間 + 1週間 + + + %dか月 + 1か月 + + + %d + 1年 + + "クラス0メッセージ" + "保存" + "端末の空き容量が少なくなっています。SMSでは空き容量を確保するため古いメッセージが自動的に削除されます。" + "空き容量が少なくなっています" + "端末上の空き容量が増えるまで、SMSでメッセージの送受信ができない場合があります。" + "SMSの空き容量が少なくなっています。メッセージの削除が必要な場合があります。" + "電話番号の確認" + "この1回の手順で、SMSはグループにメッセージを確実に配信します。" + "電話番号" + "メディア付きのメッセージをすべて削除" + "%sより前のメッセージを削除" + "%sより前のメッセージを自動削除" + "無視" + "メディア付きのメッセージをすべて削除しますか?" + "%sより前のメッセージを削除しますか?" + "%sより前のメッセージを削除し、自動削除をONにしますか?" + "%sさんからのメッセージ" + "メッセージ内容" + "%sさんからのメッセージ" + "メッセージを送信しました" + "送信しています…" + "送信されていません。タップしてもう一度お試しください。" + "送信されていません。もう一度送信しています…" + "再送信または削除" + "緊急通報には音声通話を使用してください。テキストメッセージを配信できませんでした。" + "失敗しました" + "ダウンロードする新しいMMSメッセージ" + "新しいMMSメッセージ" + "ダウンロードできませんでした" + "再試行するにはタップしてください" + "ダウンロードするにはタップしてください" + "ダウンロードまたは削除" + "ダウンロードしています…" + "メッセージが期限切れか参照できなくなっています" + "サイズ: %1$s、期限: %2$s" + "送信できません。受信者が無効です。" + "サービスがネットワークで有効になっていません" + "ネットワークに問題があるため送信できませんでした" + "メッセージが期限切れか参照できなくなっています" + "(無題)" + "送信者不明" + "配信済み" + "件名「%1$s」、送信者「%2$s」のメッセージをダウンロードできませんでした。" + "メモリ不足のためデータベース操作を完了できませんでした" + "メッセージが送信されていません" + "SMSの一部のメッセージが送信されていません" + + %d件のスレッドのメッセージ%d + 1件のスレッドのメッセージ%d + + "メッセージがダウンロードされていません" + "SMSの一部のメッセージがダウンロードされていません" + + %d件のスレッドのメッセージ%d + 1件のスレッドのメッセージ%d + + "%1$sへのメッセージは送信されませんでした" + "緊急通報には音声通話を使用してください。%1$sへのテキストメッセージを配信できませんでした。" + + %d件の新しいメッセージ + 新しいメッセージ + + "開始" + "カメラは利用できません" + "カメラは利用できません" + "動画撮影は利用できません" + "メディアを保存できません" + "写真を撮ることはできません" + "戻る" + "アーカイブ済み" + "削除" + "アーカイブ" + "アーカイブ解除" + "通知を無効化" + "通知を有効化" + "連絡先を追加" + "ダウンロード" + "送信" + "削除" + "このメッセージを削除しますか?" + "この操作は元に戻せません。" + "削除" + + これらのスレッドを削除しますか? + このスレッドを削除しますか? + + "削除" + "キャンセル" + "宛先" + "複数の画像を選択" + "選択の確認" + "+%d" + "音声を録音できません。もう一度お試しください。" + "音声を再生できません。もう一度お試しください。" + "音声を保存できませんでした。もう一度お試しください。" + "長押し" + "、 " + " " + ": " + " " + "写真" + "音声クリップ" + "動画" + "連絡先カード" + "ダウンロード" + "SMSで返信" + "MMSで返信" + "返信" + + 参加者%d + 参加者%d + + "自分" + "連絡先がブロックされ、アーカイブされました" + "連絡先をブロック解除し、アーカイブを解除しました" + "%d件をアーカイブしました" + "%d件のアーカイブを解除しました" + "通知をオフにしました" + "通知をオンにしました" + "すべて設定されました。もう一度[送信]をタップしてください。" + "SMSを既定のSMSアプリとして設定しました。" + + 添付ファイルを破棄 + 添付ファイルを破棄 + + "音声添付ファイル" + "音声添付ファイルの再生" + "一時停止" + "%sさんからのメッセージは「%s」です。" + "%sからのメッセージ「%s」を受信できませんでした(時間: %s)。" + "%sからのメッセージ「%s」を受信しました(時間: %s)。" + "%sへのメッセージ「%s」が送信されていません(時間: %s)。" + "%sへのメッセージ「%s」を送信しています(時間: %s)。" + "%sへのメッセージ「%s」を送信できませんでした(時間: %s)。" + "%sへのメッセージ「%s」を送信しました(時間: %s)。" + "%sからのメッセージ「%s」を受信できませんでした(時間: %s%s)。" + "%sからのメッセージ「%s」を受信しました(時間: %s%s)。" + "%sへのメッセージ「%s」が送信されていません(時間: %s)。" + "%sへのメッセージ「%s」を送信しています(時間: %s)。" + "%sへのメッセージ「%s」を送信できませんでした(時間: %s)。" + "%sへのメッセージ「%s」を送信しました(時間: %s)。" + "失敗したメッセージ。再試行するにはタップしてください。" + "%sとのスレッド" + "件名を削除" + "動画を撮影" + "静止画を撮影" + "画像を撮影" + "動画の録画を開始" + "全画面カメラに切り替える" + "前面カメラと背面カメラを切り替える" + "録画を停止し、動画を添付する" + "動画の録画を停止" + "SMSの写真" + + %d枚の写真を「%s」アルバムに保存しました + %d枚の写真を「%s」アルバムに保存しました + + + %d本の動画を「%s」アルバムに保存しました + %d本の動画を「%s」アルバムに保存しました + + + %d個の添付ファイルを「%s」アルバムに保存しました + %d個の添付ファイルを「%s」アルバムに保存しました + + + %d個の添付ファイルを「ダウンロード」に保存しました + %d個の添付ファイルを「ダウンロード」に保存しました + + + %d個の添付ファイルを保存しました + %d個の添付ファイルを保存しました + + + %d個の添付ファイルを保存できませんでした + %d個の添付ファイルを保存できませんでした + + "MMS添付ファイルを保存しました" + "設定" + "アーカイブ済み" + "閉じる" + "MMS" + "詳細設定" + "デバッグ" + "通知" + "通知音" + "マナーモード" + "バイブレーション" + "ブロック済み" + "SMS受取確認通知" + "送信するSMSの配信レポートを毎回リクエストする" + "自動取得" + "MMSメッセージを自動的に取得する" + "ローミング時に自動取得" + "ローミング時にMMSを自動取得" + "グループメッセージング" + "宛先が複数ある場合にMMSを使用して1通のメッセージとして送信する" + "既定のSMSアプリ" + "既定のSMSアプリ" + + + "電話番号" + "不明" + "メッセージ送信音" + "SMSのダンプ" + "受信したSMS生データを外部ストレージファイルにダンプする" + "MMSのダンプ" + "受信したMMS生データを外部ストレージファイルにダンプする" + "無線警報" + "メッセージオプション" + "テキストをコピー" + "詳細を表示" + "削除" + "転送" + "メッセージの詳細" + "形式: " + "テキストメッセージ" + "マルチメディアメッセージ" + "From: " + "To: " + "送信: " + "受信: " + "件名: " + "サイズ: " + "優先度: " + "SIM: " + "高" + "標準" + "低" + "SIM %s" + "差出人アドレスは非表示" + "添付ファイルの読み込み中はメッセージを送信できません。" + "添付ファイルを読み込めません。もう一度お試しください。" + "ネットワークの準備ができていません。もう一度お試しください。" + "テキストを削除" + "文字と数字の入力を切り替える" + "参加者を追加" + "参加者を確認" + "新しいスレッドを開始" + "この項目を選択する" + "動画を再生" + "ユーザーとオプション" + "デバッグ" + "ユーザーとオプション" + "全般" + "このスレッドのユーザー" + "発信" + "メッセージの送信" + "<small>%sから</small>&lt:br/>メッセージを送信" + + 写真を送信します + 写真を送信します + + + 音声を送信します + 音声を送信します + + + 動画を送信します + 動画を送信します + + + 連絡先カードを送信します + 連絡先カードを送信します + + + 添付ファイルを送信します + 添付ファイルを送信します + + + %d個の添付ファイルを送信する準備ができました + 1個の添付ファイルを送信する準備ができました + + "フィードバックを送信" + "Google Playストアで表示" + "バージョン情報" + "バージョン%1$s" + "オープンソースライセンス" + "通知" + "添付ファイルの上限に達しました" + "添付ファイルを読み込めませんでした。" + "連絡先に追加しますか?" + "連絡先を追加" + "件名" + "件名: " + "%s%s" + "連絡先カードを読み込んでいます" + "連絡先カードを読み込めませんでした" + "連絡先カードを表示" + + %d件の連絡先 + %d件の連絡先 + + "連絡先カード" + "誕生日" + "メモ" + "メールを転送" + "返信" + "、他1人" + "、他%d人" + "SMSは無効になっています" + "送信するには、SMSを既定のSMSアプリとして設定してください" + "SMSを既定のSMSアプリとして設定してください" + "変更" + "メッセージを受信するには、SMSを既定のSMSアプリとして設定してください" + "SMSメッセージ送信用の優先SIMが選択されていません" + "このアプリは端末の所有者によって許可されていません。" + "OK" + "スレッドへの参加者が多すぎます" + + 連絡先が無効です + 連絡先が無効です + + "カメラ画像を読み込めませんでした" + "あなた: " + "%s: " + "下書き" + "新しいスレッドを開始すると、ここにリストが表示されます" + "アーカイブされたスレットがここに表示されます" + "スレッドを読み込んでいます…" + "画像" + "音声クリップ" + "動画" + "連絡先カード" + "MMS" + "元に戻す" + "再試行" + "新しいメッセージを開始するには連絡先の名前か電話番号を入力してください" + "ブロック" + "%sさんをブロック" + "%sさんのブロックを解除" + "%sをブロックしますか?" + "この番号からのメッセージは引き続き受信しますが、通知は表示されなくなります。このスレッドはアーカイブされます。" + "ブロック中の連絡先" + "ブロックを解除" + "ブロック中の連絡先" + "ドキュメントライブラリから画像を選択" + "メールを送信しています" + "メッセージを送信しました" + "モバイルデータがOFFになっています。設定を確認してください。" + "機内モードではメッセージを送信できません" + "メッセージを送信できませんでした" + "メッセージをダウンロードしました" + "モバイルデータがOFFになっています。設定を確認してください。" + "機内モードではメッセージをダウンロードできません" + "メッセージをダウンロードできませんでした" + "0" + "1" + "2" + "3" + "4" + "5" + "6" + "7" + "8" + "9" + "メッセージを送信できません(%1$s)、エラー%2$d" + "メッセージを送信できません(不明な携帯通信会社)、エラー%1$d" + "Fwd: %s" + "メッセージの送信失敗: サービスがネットワークで有効になっていません" + "メッセージの送信失敗: 無効な宛先アドレスです" + "メッセージの送信失敗: 無効なメッセージです" + "メッセージの送信失敗: サポートされていないコンテンツです" + "メッセージの送信失敗: サポートされていないメッセージです" + "メッセージの送信失敗: 大きすぎます" + "新しいメッセージ" + "表示" + "画像" + "適切なアプリが見つかりませんでした" + "宛先を削除" + "新しいメッセージ" + "キャンセル" + "アクセスポイントの編集" + "未設定" + "名前" + "APN" + "MMSC" + "MMSプロキシ" + "MMSポート" + "MCC" + "MNC" + "APNタイプ" + "APNを削除" + "新しいAPN" + "保存" + "破棄" + "[名前]欄は必須です。" + "APNは必須です。" + "MCC欄は3桁で指定してください。" + "MNC欄は2桁か3桁で指定してください。" + "デフォルトのAPN設定を復元しています。" + "デフォルトにリセット" + "デフォルトのAPN設定にリセットしました。" + "無題" + "アクセスポイント名" + "APN" + "新しいAPN" + "このユーザーはアクセスポイント名設定を利用できません" + "クリップボードにコピーしますか?" + "コピー" + "宛先: %s" + "全般" + "詳細設定" + "全般設定" + "詳細設定" + "「%s」SIM" + "SMSメッセージを全員に個別に送信し、返信があなただけに返されるようにします" + "1つのMMSを全員に送信します" + "不明な番号" + "新しいメッセージ" + "新しいメッセージがあります。" + "SIMの切り替え" + "SIMの切り替え(%1$sを選択済み)" + "件名を編集" + "SIMを選択するか件名を編集します" + "録音するには押し続けます" + "新しいスレッドを開始" + "SMS" + "SMSリスト" + "SMS" + "新しいメッセージ" + "スレッドリスト" + "スレッドを読み込んでいます" + "メールを読み込んでいます" + "他のスレッドを見る" + "他のメールを見る" + "スレッドを削除しました" + "スレッドを削除しました。タップしてSMSの別のスレッドを表示します" + "ブロックしました" + "ブロックを解除しました" + "空き容量が少なくなっています。一部のデータが失われる可能性があります。" + "添付ファイルの選択" + "選択の確認" + "%d件選択済み" + "添付ファイルを1つ以上削除してから、もう一度お試しください。" + "メッセージの送信を試すことはできますが、添付ファイルを1つ以上削除しないと配信されない可能性があります。" + "送信できる動画は1件のメッセージにつき1本までです。追加した動画を削除してもう一度お試しください。" + "SMSで添付ファイルを読み込めませんでした。" + "このまま送信" + "スレッドを開始できませんでした" + "%1$s%2$s)" + "%sが選択されています" + diff --git a/res/values-ka-rGE/arrays.xml b/res/values-ka-rGE/arrays.xml new file mode 100644 index 0000000..0f50610 --- /dev/null +++ b/res/values-ka-rGE/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "უსათაურო" + "nosubject" + + + "კი" + "არა" + "კარგი" + "ჰე-ჰე" + "დიდი მადლობა" + "თანახმა ვარ" + "მშვენიერია" + "მოვდივარ" + "კარგი, მოგვიანებით შეგეხმიანები" + ":)" + ":(" + + diff --git a/res/values-ka-rGE/strings.xml b/res/values-ka-rGE/strings.xml new file mode 100644 index 0000000..9605c6b --- /dev/null +++ b/res/values-ka-rGE/strings.xml @@ -0,0 +1,519 @@ + + + + + "შეტყობინებები" + "შეტყობინებები" + "მიმოწერის არჩევა" + "პარამეტრები" + "წერილის გაგზავნა" + "დანართის დამატება" + "დახმარება" + "მოგესალმებით" + "გამოტოვება" + "შემდეგი >" + "შემდეგი" + "გასვლა" + "პარამეტრები >" + "პარამეტრები" + "შეტყობინებები საჭიროებს ნებართვას SMS-ზე, ტელეფონზე და კონტაქტებზე." + "ნებართვების შეცვლა შეგიძლიათ აქ: პარამეტრები > აპები > შეტყობინებები > ნებართვები." + "ნებართვების შეცვლა შეგიძლიათ აქ: პარამეტრები, აპები, შეტყობინებები, ნებართვები." + "ხშირად გამოყენებული კონტაქტები" + "ყველა კონტაქტი" + "%s-ზე გაგზავნა" + "სურათების ან ვიდეოს გადაღება" + "ამ მოწყობილობიდან სურათების არჩევა" + "აუდიოს ჩაწერა" + "ფოტოს არჩევა" + "მედია შერჩეულია." + "მედია არ არის შერჩეული." + "%d შერჩეულია" + "სურათის %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "სურათი" + "აუდიოს ჩაწერა" + "გაზიარება" + "ახლახანს" + "ახლა" + + %d წთ. + %d წთ. + + + %d საათის + %d საათის + + + %d დღის + %d დღის + + + %d კვირის + კვირის + + + %d თვის + თვის + + + %d წლის + წლის + + "Class 0 შეტყობინება" + "შენახვა" + "მოწყობილობის მეხსიერებაში თავისუფალი სივრცე ცოტაა. შეტყობინებები ავტომატურად წაშლის ძველ შეტყობინებებს მეხსიერების გასათავისუფლებლად." + "თავისუფალი ადგილი იწურება" + "შეტყობინებებმა შესაძლოა ვერ მოახერხოს შეტყობინებების გაგზავნა ან მიღება მანამ, სანამ თქვენს მოწყობილობაზე ხელმისაწვდომი არ გახდება მეტი სივრცე." + "SMS სივრცე იწურება. შესაძლოა, მოგიწიოთ ძველი შეტყობინებების წაშლა" + "დაადასტურეთ თქვენი ტელეფონის ნომერი" + "ეს ერთჯერადი ნაბიჯი უზრუნველყოფს, რომ შეტყობინებებმა სწორად მოგაწოდოთ ჯგუფური შეტყობინებები." + "ტელეფონის ნომერი" + "ყველა შეტყობინების წაშლა, რომელსაც მედია აქვს" + "%s-ზე ძველი შეტყობინებების წაშლა" + "%s-ზე ძველი შეტყობინებების ავტომატური წაშლა" + "იგნორირება" + "წაიშალოს ყველა შეტყობინება, რომელსაც მედია აქვს?" + "წაიშალოს %s-ზე ძველი შეტყობინებები?" + "გსურთ %s-ზე უფრო ძველი შეტყობინებების წაშლა და ავტომატური წაშლის ჩართვა?" + "%s თქვა" + "თქვენ სთქვით," + "შეტყობინება %s -საგან" + "თქვენ გააგზავნეთ შეტყობინება" + "იგზავნება..." + "არ გაიგზავნა. შეეხეთ ისევ საცდელად." + "არ გაიგზავნა. მიმდინარეობს ხელახლა ცდა…" + "ხელახლა გაგზავნა ან წაშლა" + "გამოიძახეთ საგანგებო სამსახურები ხმოვანი ზარით. ტექსტური შეტყობინების გაგზავნა ამჟამად შეუძლებელია." + "ვერ განხორციელდა" + "ახალი ჩამოსატვირთი MMS შეტყობინება" + "ახალი MMS შეტ" + "ჩამოტვირთვა ვერ მოხერხდა" + "ხელახლა საცდელად შეეხეთ" + "შეეხეთ ჩამოსატვირთად" + "ჩამოტვირთვა ან წაშლა" + "მიმდინარეობს ჩამოტვირთვა…" + "შეტყობინებას ვადა გაუვიდა ან მიუწვდომელია." + "ზომა: %1$s, ვადა: %2$s" + "გაგზავნა ვერ ხერხდება. მიმღები არასწორია." + "სერვისი არ გააქტიურებულა ქსელში" + "ქსელის პრობლემის გამო ვერ გაიგზავნა" + "შეტყობინებას ვადა გაუვიდა ან მიუწვდომელია." + "(თემის გარეშე)" + "უცნობი გამგზავნი" + "მიწოდებულია" + "%1$s შეტყობინების ჩამოტვირთვა ვერ მოხერხდა %2$s-დან." + "მცირე მეხსიერების გამო მონაცემთა ბაზასთან დაკავშირებული ოპერაციის დასრულება ვერ მოხერხდა" + "შეტყობინება ვერ გაიგზავნა" + "შეტყობინებებში ზოგიერთი შეტყობინება ვერ გაიგზავნა" + + %d შეტყობინება %d მიმოწერაში + %d შეტყობინება ერთ მიმოწერაში + + "შეტყობინება არ ჩამოტვირთულა" + "შეტყობინებებში ზოგიერთი შეტყობინება ვერ ჩამოიტვირთა" + + %d შეტყობინება %d მიმოწერაში + %d შეტყობინება ერთ მიმოწერაში + + "შეტყობინება ნომერზე %1$s ვერ გაიგზავნა" + "გამოიძახეთ საგანგებო სამსახურები ხმოვანი ზარით. ტექსტური შეტყობინების გაგზავნა ნომერზე %1$s ამჟამად შეუძლებელია." + + %d ახალი შეტყობინება + ახალი შეტყობინება + + "დაწყება" + "კამერა მიუწვდომელია" + "კამერა მიუწვდომელია" + "ვიდეო ჩაწერა მიუწვდომელია" + "მედიას შენახვა ვერ ვერხდება" + "ვერ ხორციელდება სურათის გადაღება" + "უკან" + "დაარქივებული" + "წაშლა" + "დაარქივება" + "დეარქივაცია" + "შეტყობინებების გამორთვა" + "შეტყობინებების ჩართვა" + "კონტაქტის დამატება" + "ჩამოტვირთვა" + "გაგზავნა" + "წაშლა" + "წავშალო ეს შეტყობინება?" + "ამ ქმედებას ვერ გააუქმებთ." + "წაშლა" + + წაიშალოს ეს მიმოწერები? + წაიშალოს ეს მიმოწერა? + + "წაშლა" + "გაუქმება" + "მიმღები:" + "მრავალი სურათის არჩევა" + "არჩევანის დადასტურება" + "+%d" + "ხმის ჩაწერა ვერ ხერხდენა. სცადეთ ისევ." + "ხმის დაკვრა ვერ ხერხდება. სცადეთ ისევ." + "ხმის ჩაწერა ვერ მოხერხდა. სცადეთ ისევ." + "შეეხეთ & დააყოვნეთ" + ", " + " " + ": " + " " + "სურათი" + "აუდიო კლიპი" + "ვიდეო" + "კონტაქტის ბარათი" + "ჩამოტვირთვა" + "SMS-ით პასუხი" + "MMS-ით პასუხი" + "პასუხი" + + %d მონაწილე + %d მონაწილე + + "მე" + "კონტაქტი დაბლოკილია & დაარქივებულია" + "კონტაქტი განბლოკილია & amp; დაუარქივებელი" + "%d დაარქივებული" + "%d დაუარქივებელი" + "შეტყობინებები გამორთულია" + "შეტყობინებები ჩართულია" + "მზად არის. შეეხეთ ხელახლა გაგზავნას." + "შეტყობინებები წარმატებით დაყენებულია ნაგულისხმევ SMS აპად." + + დანართების გაუქმება + დანართის გაუქმება + + "აუდიო დანართი" + "აუდიო დანართის ჩართვა" + "პაუზა" + "შეტყობინება %s-საგან: %s." + "%s-საგან შეტყობინების შეცდომა: %s. დრო: %s." + "შეტყობინება %s-საგან: %s. დრო: %s." + "%s-სთვის გაუგზავნელი შეტყობინება: %s. დრო: %s." + "იგზავნება შეტყობინება %s-სთვის: %s. დრო: %s." + "%s-სთვის შეტყობინების შეცდომა: %s. დრო: %s." + "შეტყობინება %s-სთვის: %s. დრო: %s." + "%s-საგან შეტყობინების შეცდომა: %s. დრო: %s. %s." + "შეტყობინება %s-საგან: %s. დრო: %s. %s." + "გაუგზავნელი შეტყობინება %s-სთვის: %s. დრო: %s." + "იგზავნება შეტყობინება %s-სთვის: %s. დრო: %s." + "%s-სთვის შეტყობინების შეცდომა: %s. დრო: %s." + "შეტყობინება %s-სთვის: %s. დრო: %s." + "შეტყობინების შეცდომა. შეეხეთ, რათა ხელახლა სცადოთ." + "საუბარი %s -სთან" + "თემის წაშლა" + "ვიდეოს გადაღება" + "ფოტოსურათის აღბეჭდვა" + "სურათის გადაღება" + "ვიდეოს ჩაწერის დაწყება" + "სრულეკრანიან კამერაზე გადართვა" + "წინა და უკანა კამერებს შორის გადართვა" + "ჩაწერის შეჩერება და ვიდეოს მიბმა" + "ვიდეოს ჩაწერის შეჩერება" + "შეტყობინებების ფოტოები" + + %d ფოტო შენახულია ალბომში „%s + %d ფოტო შენახულია ალბომში „%s + + + %d ვიდეო შენახულია ალბომში „%s + %d ვიდეო შენახულია ალბომში „%s + + + %d მიმაგრებული ფაილი შენახულია ალბომში „%s + %d მიმაგრებული ფაილი შენახულია ალბომში „%s + + + %d მიმაგრებული ფაილი შენახულია „ჩამოტვირთვებში“ + %d მიმაგრებული ფაილი შენახულია „ჩამოტვირთვებში“ + + + შენახულია %d დანართი + შენახულია %d დანართი + + + %d დანართის შენახვა ვერ მოხერხდა + %d დანართის შენახვა ვერ მოხერხდა + + "MMS დანართი შენახულია" + "პარამეტრები" + "დაარქივებული" + "დახურვა" + "MMS" + "გაფართოებული" + "გამართვა" + "შეტყობინებები" + "ხმა" + "დადუმებული" + "ვიბრაცია" + "დაბლოკილი" + "SMS მისვლის შეტყობინებები" + "ყოველ გაგზავნილ SMS-ზე მიწოდების დადასტურების მოთხოვნა" + "ავტო-მოძიება" + "MMS შეტყობინებების ავტომატურად ჩამოტვირთვა" + "ავტო ჩამოტვირთვა როუმინგში" + "როუმინგის დროს MMS-ის ავტომატური ჩამოტვირთვა" + "ჯგუფური შეტყობინება" + "მრავალი მიმღების შემთხვევაში ერთიანი შეტყობინების გასაგზავნად MMS-ის გამოყენება" + "ნაგულისხმევი SMS აპლიკაცია" + "ნაგულისხმევი SMS აპლიკაცია" + + + "თქვენი ტელეფონის ნომერი" + "უცნობი" + "გამავალი შეტყობინების ჟღერადობები" + "SMS-ის ჩაწერა" + "მიღებული SMS-ის დაუმუშავებელი მონაცემების გარე საცავის ფაილში ჩაწერა" + "MMS-ის ჩაწერა" + "მიღებული MMS-ის დაუმუშავებელი მონაცემების გარე საცავის ფაილში ჩაწერა" + "უსადენო გაფრთხილებები" + "შეტყობინების პარამეტრები" + "ტექსტის კოპირება" + "დეტალების ნახვა" + "წაშლა" + "გადაგზავნა" + "შეტყობინების მონაცემები" + "ტიპი: " + "ტექსტური შეტყობინება" + "მულტიმედია შეტყობინება" + "გამგზავნი: " + "მიმღები: " + "გაიგზავნა: " + "მიღებულია: " + "თემა: " + "ზომა: " + "პრიორიტეტი: " + "SIM: " + "მაღალი" + "ჩვეულებრივი" + "დაბალი" + "SIM %s" + "გამგზავნის მისამართი დამალულია" + "დანართების ჩატვირთვისას შეტყობინება ვერ გაიგზავნება." + "დანართი ვერ იტვირთება. სცადეთ ისევ." + "ქსელი მზად არ არის. ისევ სცადეთ." + "შემდეგის წაშლა" + "ტექსტისა და რიცხვების შეყვანას შორის გადართვა" + "მეტი მონაწილის დამატება" + "მონაწილეების დადასტურება" + "ახალი მიმოწერის დაწყება" + "ამ ერთეულის არჩევა" + "ვიდეოს დაკვრა" + "ხალხი & ვარიანტები" + "გამართვა" + "ხალხი & ვარიანტები" + "ზოგადი" + "ამ საუბრის მონაწილეები" + "დარეკვა" + "შეტყობინების გაგზავნა" + "შეტყობინების გაგზავნა<br/><small>%s-დან</small>" + + ფოტოების გაგზავნა + ფოტოს გაგზავნა + + + აუდიო ჩანაწერების გაგზავნა + აუდიო ჩანაწერის გაგზავნა + + + ვიდეოების გაგზავნა + ვიდეოს გაგზავნა + + + კონტაქტის ბარათების გაგზავნა + კონტაქტის ბარათის გაგზავნა + + + დანართების გაგზავნა + დანართის გაგზავნა + + + %d დანართი მზად არის გასაგზავნად + გასაგზავნად მზად არის ერთი დანართი + + "უკუკავშირის გაგზავნა" + "Google Play Store-ში ნახვა" + "ვერსიის ინფორმაცია" + "ვერსია %1$s" + "ღია კოდის ლიცენზიები" + "შეტყობინებები" + "მიღწეულია დანართების ლიმიტი" + "დანართი ვერ ჩაიტვირთა." + "დაემატოს კონტაქტებში?" + "კონტაქტის დამატება" + "თემა" + "თემა: " + "%s%s" + "იტვირთება კონტაქტის ბარათი" + "საკონტაქტო ბარათი ვერ იტვირთება" + "კონტაქტის ბარათის ნახვა" + + %d კონტაქტი + %d კონტაქტი + + "კონტაქტის ბარათები" + "დაბადების თარიღი" + "ჩანიშვნები" + "შეტყობინების გადაგზავნა" + "პასუხი" + "+1" + "+%d" + "SMS გამორთულია" + "გასაგზავნად დააყენეთ შეტყობინებები ნაგულისხმევ SMS აპად" + "დააყენეთ შეტყობინებები ნაგულისხმევ SMS აპად" + "შეცვლა" + "შეტყობინებების მისაღებად დააყენეთ შეტყობინებები ნაგულისხმევ SMS აპად" + "მოკლე ტექსტური შეტყობინებების გაგზავნისათვის, არ არის არჩეული უპირატესი SIM ბარათი" + "ეს აპი მოწყობილობის მფლობელის მიერ ნებადართული არ არის." + "კარგი" + "მიმოწერაში ძალიან ბევრი მონაწილეა" + + კონტაქტები არასწორია + კონტაქტი არასწორია + + "კამერის სურათი ვერ ჩაიტვირთა" + "თქვენ: " + "%s: " + "მონახაზი" + "როდესაც საუბარს წამოიწყებთ, ისინი აქ გამოჩნდება" + "დაარქივებული მიმოწერები აქ გამოჩნდება" + "მიმოწერები იტვირთება…" + "სურათი" + "აუდიო კლიპი" + "ვიდეო" + "კონტაქტის ბარათი" + "MMS" + "დაბრუნება" + "ხელახლა ცდა" + "ახალი შეტყობინების დასაწყებად შეიყვანეთ საკონტაქტო სახელი ან ტელეფონის ნომერი" + "დაბლოკვა" + "%s-ის დაბლოკვა" + "%s-ის განბლოკვა" + "%s-ის დაბლოკვა?" + "ამ ნომრიდან შეტყობინებებს კვლავ მიიღებთ, თუმცა შეხსენებები აღარ გამოგეგზავნებათ. ეს მიმოწერა დაარქივდება." + "დაბლოკილი კონტაქტები" + "განბლოკვა" + "დაბლოკილი კონტაქტები" + "აირჩიეთ სურათი დოკუმენტების ბიბლიოთეკიდან" + "შეტყობინება იგზავნება" + "შეტყობინება გაიგზავნა" + "მობილური ინტერნეტი გამორთულია. გადაამოწმეთ პარამეტრები." + "თვითმფრინავის რეჟიმში შეტყობინებების გაგზავნა ვერ ხერხდება" + "შეტყობინება ვერ გაიგზავნა" + "შეტყობინება გადმოწერილია" + "მობილური ინტერნეტი გამორთულია. გადაამოწმეთ პარამეტრები." + "თვითმფრინავის რეჟიმში შეტყობინების ჩამოტვირთვა ვერ ხორციელდება" + "შეტყობინების ჩამოტვირთვა ვერ ხორციელდება" + "ნული" + "ერთი" + "ორი" + "სამი" + "ოთხი" + "ხუთი" + "ექვსი" + "შვიდი" + "რვა" + "ცხრა" + "შეტყობინება ვერ იგზავნება: %1$s-ის შეცდომა %2$d" + "შეტყობინება ვერ იგზავნება უცნობი ოპერატორით, შეცდომა %1$d" + "Fwd: %s" + "შეტყობინება ვერ გაიგზავნა: სერვისი ქსელში გააქტიურებელი არ არის" + "შეტყობინება ვერ გაიგზავნა: სამიზნე მისამართი არასწორია" + "შეტყობინება ვერ გაიგზავნა: შეტყობინება არასწორია" + "შეტყობინება ვერ გაიგზავნა: კონტენტი მხარდაჭერილი არ არის" + "შეტყობინება ვერ გაიგზავნა: შეტყობინება მხარდაჭერილი არ არის" + "შეტყობინება არ გაიგზავნა: ის ძალიან დიდია" + "ახალი შეტყობინება" + "ნახვა" + "სურათი" + "შესაბამისი აპლიკაცია ვერ მოიძებნა" + "მიმღების ამოღება" + "ახალი შეტყობინება" + "გაუქმება" + "წვდომის წერტილის რედაქტირება" + "არ არის დაყენებული" + "სახელი" + "APN" + "MMSC" + "MMS პროქსი-სერვერი" + "MMS პორტი" + "MCC" + "MNC" + "APN ტიპი" + "APN-ის წაშლა" + "ახალი APN" + "შენახვა" + "გაუქმება" + "სახელის ველი არ შეიძლება იყოს ცარიელი." + "APN არ უნდა იყოს ცარიელი." + "MCC ველი უნდა შეიცავდეს 3 ციფრს." + "MNC ველი უნდა შეიცავდეს ორ ან სამ ციფრს." + "ნაგულისხმევი APN პარამეტრების აღდგენა." + "საწყის პარამეტრებზე ჩამოყრა" + "ნაგულისხმევი APN პარამეტრების აღდგენა დასრულებულია." + "უსათაურო" + "წვდომის წერტილის სახელები (APNs)" + "APN-ები" + "ახალი APN" + "წვდომის წერტილის (APN) პარამეტრები ამ მომხმარებლისათვის მიუწვდომელია" + "გსურთ ბუფერში კოპირება?" + "კოპირება" + "%s-ზე" + "ზოგადი" + "გაფართოებული" + "ძირითადი პარამეტრები" + "დამატებითი პარამეტრები" + "„%s“ SIM" + "ყველა მიმღებთან ინდივიდუალური SMS შეტყობინების გაგზავნა. პასუხებს მხოლოდ თქვენ მიიღებთ" + "ყველა მიმღებთან ერთი MMS-ის გაგზავნა" + "უცნობი ნომერი" + "ახალი შეტყობინება" + "ახალი შეტყობინება." + "SIM სელექტორი" + "არჩეულია %1$s, SIM სელექტორი" + "თემის რედაქტირება" + "აირჩიეთ SIM ან შეცვალეთ სათაური" + "აუდიო ჩაწერისათვის შეეხეთ და დააყოვნეთ" + "ახალი მიმოწერის დაწყება" + "შეტყობინებები" + "შეტყობინებების სია" + "შეტყობინებები" + "ახალი შეტყობინება" + "მიმოწერათა სია" + "მიმოწერები იტვირთება…" + "მიმდინარეობს შეტყობინებების ჩატვირთვა" + "მეტი მიმოწერის ნახვა" + "იხილეთ სხვა შეტყობინებები" + "მიმოწერა წაშლილია" + "მიმოწერა წაშლილია. შეეხეთ შეტყობინებების სხვა მიმოწერის საჩვენებლად" + "დაბლოკილი" + "განბლოკილი" + "შენახვის სივრცეში შემცირებულია. ზოგიერთი მონაცემი შეიძლება დაიკარგოს." + "დანართის არჩევა" + "არჩევანის დადასტურება" + "%d შერჩეულია" + "წაშალეთ ერთი ან რამდენიმე დანართი და ხელახლა სცადეთ." + "შეგიძლიათ სცადოთ შეტყობინების გაგზავნა, მაგრამ შეიძლება მისი მიწოდება ვერ მოხერხდეს, თუ არ წაშლით ერთ ან რამდენიმე დანართს." + "შეტყობინებით თქვენ შეგიძლიათ გააგზავნოთ მხოლოდ ერთი ვიდეო. გთხოვთ ამოშალეთ დამატებითი ვიდეოები და სცადეთ კიდევ ერთხელ." + "შეტყობინებებში დანართის ჩატვირთვა ვერ მოხერხდა." + "მაინც გაგზავნა" + "საუბრის დაწყება ვერ მოხერხდა" + "%1$s (%2$s)" + "შერჩეულია %s" + diff --git a/res/values-kk-rKZ/arrays.xml b/res/values-kk-rKZ/arrays.xml new file mode 100644 index 0000000..b49d0cc --- /dev/null +++ b/res/values-kk-rKZ/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "тақырыпсыз" + "тақырып жоқ" + + + "Иә" + "Жоқ" + "Жарайды" + "Хехе" + "Рақмет" + "Келісемін" + "Жақсы" + "Жолдамын" + "Жарайды, кейінірек хабарласамын" + ":)" + ":(" + + diff --git a/res/values-kk-rKZ/strings.xml b/res/values-kk-rKZ/strings.xml new file mode 100644 index 0000000..5164592 --- /dev/null +++ b/res/values-kk-rKZ/strings.xml @@ -0,0 +1,519 @@ + + + + + "Хабар алмасу" + "Хабар алмасу" + "Сөйлесуді таңдау" + "Параметрлер" + "Хабар жіберу" + "Тіркеме қосу" + "Анықтама" + "Қош келдіңіз" + "Өткізіп жіберу" + "Келесі >" + "Келесі" + "Шығу" + "Параметрлер >" + "Параметрлер" + "Messaging қолданбасына SMS, \"Телефон\" және \"Контактілер\" қолданбаларына рұқсат қажет." + "Рұқсаттарды \"Параметрлер\" > \"Қолданбалар\" > \"Messaging\" > \"Рұқсаттар\" тармағында өзгертуге болады." + "Рұқсаттарды \"Параметрлер\", \"Қолданбалар\", \"Messaging\", \"Рұқсаттар\" тармағында өзгерте аласыз." + "Жиі қолданылатын" + "Барлық контактілер" + "%s орнына жіберу" + "Суреттер немесе бейне түсіру" + "Осы құрылғыдан кескіндерді таңдау" + "Аудио жазу" + "Фотосуретті таңдау" + "Медиа таңдалды." + "Медиадан таңдау алынды." + "%d таңдалды" + "кескін %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "кескін" + "Аудио жазу" + "Бөлісу" + "Қазір ғана" + "Қазір" + + %d мин. + %d мин. + + + %d сағат + %d сағат + + + %d күн + %d күн + + + %d апта + апта + + + %d ай + ай + + + %d жыл + жыл + + "0 сыныпты хабар" + "Сақтау" + "Құрылғыдағы бос орын аз. \"Хабар алмасу\" орын босату үшін ескі хабарларды автоматты түрде жояды." + "Жадта бос орын бітуде" + "\"Хабар алмасу\" қолданбасы құрылғыда көбірек бос орын қол жетімді болғанша хабарлар жібере немесе ала алмауы мүмкін." + "SMS жадында орын аз. Хабарларды жою керек болуы мүмкін." + "Телефон нөміріңізді растау" + "Бұл бір реттік қадам Messaging қолданбасының топтық хабарларды тиісті түрде жеткізуін қамтамасыз етеді." + "Телефон нөмірі" + "Тасушы арқылы барлық хабарларды жою" + "%s уақыттан асқан хабарларды жою" + "%s ескірек хабарларды автоматты түрде жою" + "Елемеу" + "Медиа бар барлық хабарларды жою керек пе?" + "%s ескірек хабарларды жою керек пе?" + "%s ескірек хабарларды жою және автоматты түрде жоюды қосу керек пе?" + "%s айтты" + "Сіз айттыңыз" + "Хабар, кімнен: %s" + "Сіз хабар жібердіңіз" + "Жіберілуде…" + "Жіберілмеді. Әрекетті қайталау үшін түртіңіз." + "Жіберілмеді. Әрекет қайталануда…" + "Қайта жіберу немесе жою" + "Жедел қызметтерге дауыстық қоңырау соғыңыз. Бұл ретте мәтіндік хабарды жеткізу мүмкін болмады." + "Сәтсіз аяқталды" + "Жүктейтін жаңа MMS хабары бар" + "Жаңа MMS хабары" + "Жүктеу мүмкін болмады" + "Әрекетті қайталау үшін түртіңіз" + "Жүктеу үшін түртіңіз" + "Жүктеу немесе жою" + "Жүктелуде…" + "Хабар мерзімі бітті немесе ол қол жетімді емес" + "өлшемі: %1$s, мерзімі бітетін уақыт: %2$s" + "Жіберу мүмкін емес. Алушы жарамсыз." + "Қызмет желіде белсендірілмеген" + "Желілік мәселеге байланысты жіберу мүмкін болмады" + "Хабар мерзімі бітті немесе ол қол жетімді емес" + "(Тақырыпсыз)" + "Белгісіз хабар жіберуші" + "Жеткізілді" + "%1$s хабарын %2$s арқылы жүктеу мүмкін болмады." + "Жад көлемінің аз болуына байланысты дерекқор әрекетін аяқтау мүмкін болмады" + "Хабар жіберілмеді" + "Messaging қолданбасында кейбір хабарлар жіберілмеген" + + %d сөйлесуде %d хабар + бір сөйлесуде %d хабар + + "Хабар жүктелмеген" + "Messaging қолданбасында кейбір хабарлар жүктелмеген" + + %d сөйлесуде %d хабар + бір сөйлесуде %d хабар + + "%1$s нөміріне хабар жіберілмеді" + "Жедел қызметтерге дауыстық қоңырау соғыңыз. Бұл ретте %1$s нөміріне мәтіндік хабарды жеткізу мүмкін болмады." + + %d жаңа хабар + Жаңа хабар + + "Бастау" + "Камера қол жетімді емес" + "Камера қол жетімді емес" + "Бейне түсіру қол жетімді емес" + "Медианы сақтау мүмкін емес" + "Сурет түсіру мүмкін емес" + "Артқа" + "Мұрағатталды" + "Жою" + "Мұрағаттау" + "Мұрағаттан шығару" + "Хабарландыруларды өшіру" + "Хабарландыруларды қосу" + "Контакт қосу" + "Жүктеу" + "Жіберу" + "Жою" + "Бұл хабарды жою қажет пе?" + "Бұл әрекетті болдырмау мүмкін емес." + "Жою" + + Осы сөйлесулерді жою қажет пе? + Осы сөйлесуді жою қажет пе? + + "Жою" + "Бас тарту" + "Кімге" + "Бірнеше кескінді таңдау" + "Таңдауды растау" + "+%d" + "Аудионы жазу мүмкін емес. Әрекетті қайталаңыз." + "Аудионы ойнату мүмкін емес. Әрекетті қайталаңыз." + "Аудионы сақтау мүмкін болмады. Әрекетті қайталаңыз." + "Түрту және ұстап тұру" + ", " + " " + ": " + " " + "Фотосурет" + "Аудиоклип" + "Бейне" + "Контакт карточкасы" + "Жүктеу" + "SMS арқылы жауап беру" + "MMS ар. жау. б." + "Жауап беру" + + %d қатысушы + %d қатысушы + + "Мен" + "Контакт бөгелді және мұрағатталды" + "Контакт құлыптан босатылды және мұрағаттан шығарылды" + "%d мұрағатталды" + "%d мұрағаттан шығарылды" + "Хабарландырулар өшірілген" + "Хабарландырулар қосылған" + "Бәрі орнатылды. «Қайта жіберу» пәрменін түртіңіз." + "\"Хабар алмасу\" қолданбасы әдепкі SMS қолданбасы ретінде сәтті орнатылды." + + Тіркемелерді алып тастау + Тіркемені алып тастау + + "Аудио тіркеме" + "Аудио тіркемені ойнату" + "Кідірту" + "%s хабары: %s." + "%s сәтсіз хабары: %s. Уақыт: %s." + "%s хабары: %s. Уақыт: %s." + "%s тобына жіберілмеген хабар: %s. Уақыт: %s." + "%s тобына хабар жіберілуде: %s. Уақыт: %s." + "%s тобына сәтсіз хабар: %s. Уақыт: %s." + "%s тобына хабар: %s. Уақыт: %s." + "%s сәтсіз хабары: %s. Уақыт: %s. %s." + "%s хабары: %s. Уақыт: %s. %s." + "%s тобына жіберілмеген хабар: %s. Уақыт: %s." + "%s тобына хабар жіберілуде: %s. Уақыт: %s." + "%s тобына сәтсіз хабар: %s. Уақыт: %s." + "%s тобына хабар: %s. Уақыт: %s." + "Сәтсіз хабар. Қайталау үшін түртіңіз." + "%s адаммен сөйлесу" + "Тақырыпты жою" + "Бейне түсіру" + "Фотосурет түсіру" + "Сурет түсіру" + "Бейне жазуды бастау" + "Толық экранды камераға ауысу" + "Алдыңғы және артқы камера арасында ауысу" + "Жазуды тоқтату және бейне тіркеу" + "Бейне жазуды тоқтату" + "Messaging фотосуреттері" + + %d фотосурет «%s» альбомына сақталды + %d фотосурет «%s» альбомына сақталды + + + %d бейне «%s» альбомына сақталды + %d бейне «%s» альбомына сақталды + + + %d тіркеме «%s» альбомына сақталды + %d тіркеме «%s» альбомына сақталды + + + %d тіркеме «Жүктеулер» ішіне сақталды + %d тіркеме «Жүктеулер» ішіне сақталды + + + %d тіркеме сақталды + %d тіркеме сақталды + + + %d тіркемені сақтау мүмкін болмады + %d тіркемені сақтау мүмкін болмады + + "MMS тіркемесі сақталды" + "Параметрлер" + "Мұрағатталды" + "Жабу" + "MMS" + "Кеңейтілген" + "Күйін келтіру" + "Хабарландырулар" + "Дыбыс" + "Үнсіз" + "Дірілдеу" + "Бөгелген" + "SMS жеткізу туралы есептер" + "Әр жіберілген SMS үшін жеткізу туралы есепті сұрау" + "Авто шығарып алу" + "MMS хабарларын автоматты түрде шығарып алу" + "Роуминг кезінде авто-шығарып алу" + "Роумингте MMS автоматты түрде шығарып алу" + "Топтық хабар алмасу" + "Бірнеше алушыға бір хабар жіберу үшін MMS пайдалану" + "Әдепкі SMS қолданбасы" + "Әдепкі SMS қолданбасы" + + + "Телефон нөміріңіз" + "Белгісіз" + "Шығыс хабар дыбыстары" + "SMS дампын сақтау" + "Алынған SMS өңделмеген деректерінің дампын сыртқы қойма файлына сақтау" + "MMS дампын сақтау" + "Алынған MMS өңделмеген деректерінің дампын сыртқы қойма файлына сақтау" + "Сымсыз дабылдар" + "Хабар опциялары" + "Мәтінді көшіру" + "Мәліметтерді көру" + "Жою" + "Қайта жіберу" + "Хабар мәліметтері" + "Tүрі: " + "Мәтіндік-хабар" + "Mультимедиалық хабар" + "Кімнен: " + "Кімге: " + "Жіберілді: " + "Қабылданды: " + "Тақырып: " + "Өлшем: " + "Басымдық: " + "SIM: " + "Жоғары" + "Қалыпты" + "Төмен" + "SIM %s" + "Жіберуші мекенжайы жасырын" + "Тіркемелерді жүктеу кезінде хабар жіберу мүмкін емес." + "Тіркемені жүктеу мүмкін емес. Әрекетті қайталаңыз." + "Желі дайын емес. Әрекетті қайталаңыз." + "Мәтінді жою" + "Мәтінді және сандарды енгізу арасында ауысу" + "Қосымша қатысушыларды қосу" + "Қатысушыларды растау" + "Жаңа сөйлесуді бастау" + "Осы элементті таңдау" + "Бейнені ойнату" + "Адамдар және опциялар" + "Күйін келтіру" + "Адамдар және опциялар" + "Жалпы" + "Осы сөйлесудегі адамдар" + "Қоңырау шалу" + "Хабарды жіберу" + "<br/><small>%s</small> картасынан хабар жіберу" + + Фотосуреттер жіберу + Фотосурет жіберу + + + Аудиолар жіберу + Аудио жіберу + + + Бейнелер жіберу + Бейне жіберу + + + Контакті карточкаларын жіберу + Контакті карточкасын жіберу + + + Тіркемелер жіберу + Тіркеме жіберу + + + %d тіркеме жіберуге дайын + Бір тіркеме жіберуге дайын + + "Пікір жіберу" + "Google Play Store дүкенінде көру" + "Нұсқа туралы ақпарат" + "Нұсқа: %1$s" + "Ашық бастапқы код лицензиялары" + "Хабарландырулар" + "Тіркеме шегіне жеттіңіз" + "Тіркемені жүктеу сәтсіз аяқталды." + "Контактілерге қосу керек пе?" + "Контакт қосу" + "Тақырып" + "Тақырып: " + "%s%s" + "Контакт карточкасы жүктелуде" + "Контакт картасын жүктеу мүмкін болмады" + "Контакт карточкасын көру" + + %d контакт + %d контакт + + "Контакт карталары" + "Туған күн" + "Ескертпелер" + "Хабарды қайта жіберу" + "Жауап беру" + "+1" + "+%d" + "SMS өшірілген" + "Жіберу үшін \"Хабар алмасу\" қолданбасын әдепкі SMS қолданбасы ретінде орнатыңыз" + "\"Хабар алмасу\" қолданбасын әдепкі SMS қолданбасы ретінде орнату" + "Өзгерту" + "Хабарлар алу үшін Messaging қолданбасын әдепкі SMS қолданбасы ретінде орнатыңыз" + "SMS хабарларын жіберу үшін таңдаулы SIM таңдалмаған" + "Құрылғы иесі бұл қолданбаға рұқсат етпеген." + "Жарайды" + "Сөйлесуде қатысушылар тым көп" + + Жарамсыз контактілер + Жарамсыз контакті + + "Камера кескінін жүктеу мүмкін болмады" + "Сіз: " + "%s: " + "Жоба" + "Басталған жаңа сөйлесуді осы жерде тізімнен көресіз" + "Мұнда мұрағатталған сөйлесулер көрсетіледі" + "Сөйлесулер жүктелуде…" + "Сурет" + "Аудиоклип" + "Бейне" + "Контакт карточкасы" + "MMS" + "Болдырмау" + "Қайталау" + "Жаңа хабарды бастау үшін контакт атын немесе телефон нөмірін енгізіңіз" + "Бөгеу" + "%s бөгеу" + "%s бөгеуден шығару" + "%s бөгеу керек пе?" + "Сіз осы нөмірден хабарлар алуды жалғастырасыз, бірақ енді хабардар етілмейсіз. Бұл сөйлесу мұрағатталады." + "Бөгелген контактілер" + "БӨГЕУДЕН ШЫҒАРУ" + "Бөгелген контактілер" + "Құжаттар кітапханасынан кескінді таңдау" + "Хабар жіберілуде" + "Хабар жіберілді" + "Ұялы деректер өшірілген. Параметрлерді тексеріңіз." + "Ұшақ режимінде хабарлар жіберу мүмкін емес" + "Хабарды жіберу мүмкін болмады" + "Хабар жүктелді" + "Ұялы деректер өшірілген. Параметрлерді тексеріңіз." + "Ұшақ режимінде хабарларды жүктеу мүмкін емес" + "Хабарды жүктеу мүмкін болмады" + "Нөл" + "Бір" + "Екі" + "Үш" + "Төрт" + "Бес" + "Алты" + "Жеті" + "Сегіз" + "Тоғыз" + "%1$s арқылы хабар жіберілмейді, %2$d қатесі" + "Белгісіз оператормен хабар жіберілмейді, %1$d қатесі" + "Қайта жіберілген: %s" + "Хабар жіберілмеді, желіде қызмет белсендірілмеген" + "Хабар жіберілмеді: жарамсыз межелі мекенжай" + "Хабар жіберілмеді: жарамсыз хабар" + "Хабар жіберілмеді: қолдау көрсетілмейтін мазмұн" + "Хабар жіберілмеді: қолдау көрсетілмейтін хабар" + "Хабар жіберілмеді: тым үлкен" + "Жаңа хабар" + "Көру" + "Кескін" + "Тиісті қолданбаны табу мүмкін болмады" + "Алушыны жою" + "Жаңа хабар" + "Бас тарту" + "Кіру нүктесін өңдеу" + "Орнатылмаған" + "Аты" + "APN" + "MMSC" + "MMS прокси" + "MMS порты" + "MCC" + "MNC" + "APN түрі" + "APN жою" + "Жаңа APN" + "Сақтау" + "Алып тастау" + "Атау өрісі бос болмауы тиіс." + "APN бос болмауы тиіс." + "MCC өрісі 3 цифрдан тұруы тиіс." + "MNC өрісі 2 немесе 3 цифрдан тұруы тиіс." + "Әдепкі APN параметрлерін қалпына келтіру." + "Әдепкі параметрлерге қалпына келтіру" + "Әдепкі APN параметрлерін қалпына келтіру аяқталды." + "Атаусыз" + "Кіру нүктесі атаулары" + "APNs (Кіру нүктесі атаулары)" + "Жаңа APN" + "Кіру нүктесі атауының параметрлері осы пайдаланушы үшін қол жетімді емес" + "Аралық сақтағышқа көшіру керек пе?" + "Көшіру" + "%s картасына" + "Жалпы" + "Кеңейтілген" + "Жалпы параметрлер" + "Қосымша параметрлер" + %s» SIM" + "Барлық алушыларға SMS хабарларын жіберіңіз. Жауаптарды тек сіз аласыз" + "Барлық алушыларға бір MMS жіберу" + "Белгісіз нөмір" + "Жаңа хабар" + "Жаңа хабар" + "SIM таңдағыш" + "%1$s таңдалды, SIM таңдағыш" + "Тақырыпты өңдеу" + "SIM картасын таңдау немесе тақырыпты өңдеу" + "Аудио жазу үшін түртіп, ұстап тұрыңыз" + "Жаңа сөйлесуді бастау" + "Хабар алмасу" + "Хабар алмасу тізімі" + "Хабар алмасу" + "Жаңа хабар" + "Сөйлесімдер тізімі" + "Сөйлесулер жүктелуде" + "Хабарлар жүктелуде" + "Көбірек сөйлесулерді көру" + "Қосымша хабарларды көру" + "Сөйлесу жойылды" + "Сөйлесу жойылды. Басқа \"Хабар алмасу\" сөйлесуін көрсету үшін түртіңіз" + "Бөгелген" + "Бөгеуден шығарылған" + "Жадтағы бос орын аз. Кейбір деректер жоғалуы мүмкін." + "Тіркемелерді таңдау" + "Таңдауды растау" + "%d таңдалды" + "Бір немесе бірнеше тіркемені жойып, әрекетті қайталаңыз." + "Хабарды жіберіп көре аласыз, бірақ бір немесе бірнеше тіркемені жоймасаңыз, ол жеткізілмеуі мүмкін." + "Әр хабарламада бір ғана бейне жіберуге болады. Қосымша бейнелерді жойып, әрекетті қайталаңыз." + "Messaging тіркемені жүктей алмады." + "Бәрібір жіберу" + "Сөйлесуді бастау мүмкін болмады" + "%1$s (%2$s)" + "%s таңдалған" + diff --git a/res/values-km-rKH/arrays.xml b/res/values-km-rKH/arrays.xml new file mode 100644 index 0000000..69f6e0d --- /dev/null +++ b/res/values-km-rKH/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "គ្មាន​ប្រធានបទ" + "គ្មាន​ប្រធាន​បទ" + + + "បាទ/ចាស" + "ទេ" + "យល់ព្រម" + "ហេហេ" + "អរគុណ" + "ខ្ញុំ​យល់​ព្រម" + "ល្អ" + "នៅ​តាម​ផ្លូវ​" + "យល់ព្រម ខ្ញុំ​នឹង​ទាក់ទង​អ្នក​ពេល​ក្រោយ" + ":)" + ":(" + + diff --git a/res/values-km-rKH/strings.xml b/res/values-km-rKH/strings.xml new file mode 100644 index 0000000..1806cc7 --- /dev/null +++ b/res/values-km-rKH/strings.xml @@ -0,0 +1,519 @@ + + + + + "​ការ​ផ្ញើ​សារ" + "ការ​​ផ្ញើ​សារ" + "ជ្រើសរើសការសន្ទនា" + "ការកំណត់" + "ផ្ញើ​សារ" + "បន្ថែម​ឯកសារ​ភ្ជាប់" + "ជំនួយ" + "សូមស្វាគមន៍" + "រំលង" + "បន្ទាប់ >" + "បន្ទាប់" + "ចេញ" + "ការកំណត់ >" + "ការកំណត់" + "ការផ្ញើសារត្រូវការសិទ្ធិអនុញ្ញាតដើម្បីប្រើសារ SMS, ទូរស័ព្ទ និងទំនាក់ទំនង។" + "អ្នកអាចប្តូរសិទ្ធិអនុញ្ញាតនៅក្នុង ការកំណត់ > កម្មវិធី > ការផ្ញើសារ > សិទិ្ធអនុញ្ញាត។" + "អ្នកអាចប្តូរសិទ្ធិអនុញ្ញាតនៅក្នុង ការកំណត់ , កម្មវិធី, ការផ្ញើសារ, សិទ្ធិអនុញ្ញាត។" + "ញឹកញាប់" + "ទំនាក់ទំនងទាំងអស់" + "ផ្ញើ​ទៅ %s" + "ថត​រូបភាព ឬ​វីដេអូ" + "ជ្រើស​រូបភាព​ពី​ឧបករណ៍​នេះ" + "ថត​សំឡេង" + "ជ្រើស​រូបថត" + "បានជ្រើសឯកសារកំសាន្ត។" + "បានលុបការជ្រើសឯកសារកំសាន្ត។" + "បាន​ជ្រើស %d" + "រូបភាព %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "រូបភាព" + "ថត​សំឡេង" + "ចែករំលែក​" + "ថ្មីៗ​នេះ" + "ឥឡូវ" + + %d នាទី + %d នាទី + + + %d ម៉ោង + %d ម៉ោង + + + %d ថ្ងៃ + %d ថ្ងៃ + + + %d សប្តាហ៍ + មួយសប្តាហ៍ + + + %d ខែ + មួយខែ + + + %d ឆ្នាំ + មួយឆ្នាំ + + "សារ​ថ្នាក់ ០" + "រក្សាទុក" + "ឧបករណ៍នៅសល់ទំហំផ្ទុកតិច។ ការផ្ញើសារនឹងលុបសារចាស់ៗដើម្បីធ្វើឲ្យមានទំហំផ្ទុកទំនេរ។" + "អស់​ទំហំ​ផ្ទុក" + "ការផ្ញើសារអាចនឹងមិនផ្ញើ ឬទទួលសារទេ រហូតដល់មានទំហំផ្ទុកបន្ថែមទៀតនៅលើឧបករណ៍របស់អ្នក។" + "ជិត​អស់​ទំហំ​ផ្ទុក​សារ SMS ។ អ្នក​អាច​​​លុប​សារ​មួយ​ចំនួន។" + "បញ្ជាក់លេខទូរស័ព្ទរបស់អ្នក" + "ជំហានមួយលើកនេះធានាថាការផ្ញើសារនឹងបញ្ជូនសារក្រុមរបស់អ្នកបានត្រឹមត្រូវ។" + "លេខទូរស័ព្ទ" + "លុប​សារ​​​​ដែល​មាន​មេឌៀ​ទាំងអស់" + "លុប​សារ​ចាស់​ជាង %s" + "លុប​សារ​ចាស់​ជាង %s ដោយ​ស្វ័យ​ប្រវត្តិ" + "មិន​អើពើ" + "លុប​សារ​ទាំងអស់​មាន​ក្នុង​មេឌៀ?" + "លុប​សារ​ចាស់​ជាង %s?" + "លុប​សារ​ចាស់​ជាង %s និង​បើក​ការ​លុប​ស្វ័យ​ប្រវត្តិ?" + "%s និយាយថា" + "អ្នកនិយាយថា" + "សារពី %s" + "អ្នកបានផ្ញើសារមួយ" + "កំពុង​ផ្ញើ..." + "មិន​បាន​ផ្ញើ។ ប៉ះ​ដើម្បី​ព្យាយាម​ម្ដងទៀត។" + "មិន​បាន​ផ្ញើ។ កំពុង​ព្យាយាម​ម្ដងទៀត..." + "ផ្ញើ​ម្ដង​ទៀត ឬ​លុប" + "សូមធ្វើការហៅជាសម្លេងទៅកាន់សេវាកម្មសង្គ្រោះបន្ទាន់។ សារជាអក្សររបស់អ្នកមិនអាចបញ្ជូនបានទេនៅពេលនេះ។" + "បាន​បរាជ័យ" + "សារ MMS ថ្មី​ត្រូវ​ទាញ​យក" + "សារ MMS ថ្មី" + "មិន​អាច​ទាញ​យក" + "ប៉ះ​ ដើម្បី​ព្យាយាម​ម្តង​ទៀត" + "ប៉ះ​ ដើម្បី​ទាញ​យក" + "ទាញ​យក ​ឬ​លុប" + "កំពុង​ទាញ​យក..." + "សារ​បាន​​ផុត​កំណត់​ ឬ​មិន​អាច​ប្រើ​បាន" + "ទំហំ៖ %1$s, ផុត​កំណត់៖ %2$s" + "មិន​អាច​ផ្ញើ។ អ្នក​ទទួល​មិន​ត្រឹមត្រូវ។" + "សេវា​មិន​សកម្ម​នៅ​លើ​បណ្ដាញ" + "មិន​អាច​ផ្ញើ​ដោយសារ​បញ្ហា​បណ្ដាញ" + "សារ​បាន​​ផុត​កំណត់​ ឬ​មិន​អាច​ប្រើ​បាន" + "(គ្មាន​ប្រធាន​បទ)" + "មិន​ស្គាល់​អ្នក​ផ្ញើ" + "បាន​បញ្ជូន" + "មិន​អាច​ទាញ​យក​សារ %1$s ពី %2$s បាន​ទេ។" + "មិន​អាច​​បញ្ចប់​ប្រតិបត្តិការ​​មូលដ្ឋាន​ទិន្នន័យ​ដោយសារ​អង្គ​ចងចាំ​មិន​គ្រប់គ្រាន់​​" + "មិន​បាន​ផ្ញើ​សារ" + "សារមួយចំនួនមិនបានផ្ញើនៅក្នុងការផ្ញើសារទេ" + + សារ %d នៅក្នុងការសន្ទនា %d + សារ %d នៅក្នុងការសន្ទនាមួយ + + "មិនបានទាញយកសារ" + "សារមួយចំនួនមិនបានទាញយកនៅក្នុងការផ្ញើសារទេ" + + សារ %d នៅក្នុងការសន្ទនា %d + សារ %d នៅក្នុងការសន្ទនាមួយ + + "សារទៅកាន់ %1$s មិនត្រូវបានផ្ញើទេ" + "សូមធ្វើការហៅជាសម្លេងទៅកាន់សេវាកម្មសង្គ្រោះបន្ទាន់។ សារជាអក្សររបស់អ្នកទៅ %1$s មិនអាចបញ្ជូនបានទេនៅពេលនេះ។" + + សារថ្មី %d + សារថ្មី + + "ចាប់ផ្ដើម" + "មិន​អាច​ប្រើ​ម៉ាស៊ីន​ថត​បាន" + "មិន​អាច​ប្រើ​ម៉ាស៊ីន​ថត​បាន" + "មិន​អាច​ថត​វីដេអូ​បាន" + "មិន​អាច​រក្សាទុក​​មេឌៀ" + "មិនអាចថតរូបភាព" + "ថយក្រោយ" + "ទុក​ក្នុង​ប័ណ្ណសារ" + "លុប" + "ប័ណ្ណសារ" + "មិន​ទុក​ក្នុង​ប័ណ្ណសារ" + "បិទ​ការ​ជូនដំណឹង" + "បើក​ការ​ជូនដំណឹង​" + "បន្ថែម​ទំនាក់ទំនង" + "ទាញ​យក" + "ផ្ញើ" + "លុប" + "លុប​សារ​នេះ?" + "សកម្មភាព​នេះ​មិន​អាច​ធ្វើ​វិញ​បាន​ទេ។" + "លុប" + + លុបការសន្ទនាទាំងនេះ? + លុបការសន្ទនានេះ? + + "លុប" + "បោះបង់" + "ជូន​ចំពោះ" + "ជ្រើស​រូបភាព​ច្រើន" + "បញ្ជាក់​​ការ​ជ្រើស" + "+%d" + "មិន​អាច​ថត​សំឡេង។ ព្យាយាម​ម្ដង​ទៀត។" + "មិន​អាច​ចាក់​សំឡេង។ ព្យាយាម​ម្ដងទៀត។" + "មិន​អាច​រក្សាទុក​សំឡេង។ ព្យាយាម​ម្ដងទៀត។" + "ប៉ះ & សង្កត់" + ", " + " " + ": " + " " + "រូបភាព" + "ឈុត​សំឡេង" + "វីដេអូ" + "កាត​ទំនាក់ទំនង" + "ទាញ​យក" + "ឆ្លើយតប​តាម​សារ SMS" + "ឆ្លើយតបតាម MMS" + "ឆ្លើយតប" + + អ្នកចូលរួម %d នាក់ + អ្នកចូលរួម %d នាក់ + + "ខ្ញុំ" + "ទំនាក់ទំនង​ត្រូវ​បាន​ទប់ស្កាត់ & ទុក​ក្នុង​ប័ណ្ណសារ" + "បានរារាំង និងបានដកទំនាក់ទំនងចេញពីបណ្ណ័សារ" + "បានទុកក្នុងបណ្ណសារចំនួន%d" + "មិនបានទុកក្នុងប័ណ្ណសារចំនួន %d" + "បានបិទការជូនដំណឹង" + "បានបើកការជូនដំណឹង" + "កំណត់​រួចរាល់។ ប៉ះ​ដើម្បី​ផ្ញើ​ម្ដងទៀត។" + "បានកំណត់ការផ្ញើសារជាកម្មវិធីសារ SMS លំនាំដើមដោយជោគជ័យ។" + + បោះបង់ឯកសារភ្ជាប់ + បោះបង់ឯកសារភ្ជាប់ + + "ឯកសារភ្ជាប់សម្លេង" + "ឯកសារភ្ជាប់សម្លេង Play" + "ផ្អាក" + "សារពី %s%s។" + "សារបរាជ័យពី %s%s។ ម៉ោង៖ %s។" + "សារពី %s%s។ ម៉ោង៖ %s។" + "សារមិនបានផ្ញើទៅ %s%s។ ម៉ោង៖ %s។" + "កំពុងផ្ញើសារទៅ %s%s។ ម៉ោង៖ %s។" + "សារបរាជ័យទៅ %s%s។ ម៉ោង៖ %s។" + "សារទៅ %s%s។ ម៉ោង៖ %s។" + "សារបរាជ័យពី %s%s។ ម៉ោង៖ %s%s។" + "សារពី %s%s។ ម៉ោង៖ %s%s។" + "សារមិនបានផ្ញើទៅ %s%s។ ម៉ោង៖ %s។" + "កំពុងផ្ញើសារទៅ %s%s។ ម៉ោង៖ %s។" + "សារបរាជ័យទៅ %s%s។ ម៉ោង៖ %s។" + "សារទៅ %s%s។ ម៉ោង៖ %s។" + "សារបរាជ័យ។ ប៉ះដើម្បីព្យាយាមម្តងទៀត។" + "ការសន្ទនាជាមួយ %s" + "លុប​ប្រធានបទ" + "ថត​វីដេអូ" + "ថត​រូបភាព​ថេរ" + "ថតរូប" + "ចាប់ផ្ដើម​ថត​វីដេអូ" + "ប្ដូរ​ទៅ​ម៉ាស៊ីន​ថត​ពេញ​អេក្រង់" + "ប្ដូរ​រវាង​ម៉ាស៊ីន​ថត​មុន និង​ក្រោយ" + "បញ្ឈប់​ការ​ថត និង​ភ្ជាប់​វីដេអូ" + "បញ្ឈប់​ការ​ថត​វីដេអូ" + "រូបភាពសារ" + + បានរក្សាទុករូបថត %d ទៅក្នុងអាល់ប៊ុម \"%s\" + បានរក្សាទុករូបថត %d ទៅក្នុងអាល់ប៊ុម \"%s\" + + + បានរក្សាទុកវីដេអូ %d ទៅក្នុងអាល់ប៊ុម \"%s\" + បានរក្សាទុកវីដេអូ %d ទៅក្នុងអាល់ប៊ុម \"%s\" + + + បានរក្សាទុកឯកសារភ្ជាប់ %d ទៅក្នុងអាល់ប៊ុម \"%s\" + បានរក្សាទុកឯកសារភ្ជាប់ %d ទៅក្នុងអាល់ប៊ុម \"%s\" + + + បានរក្សាទុកឯកសារភ្ជាប់ %d ទៅក្នុង \"ទាញយក\" + បានរក្សាទុកឯកសារភ្ជាប់ %d ទៅក្នុង \"ទាញយក\" + + + បានរក្សាទុកឯកសារភ្ជាប់ %d + បានរក្សាទុកឯកសារភ្ជាប់ %d + + + មិនអាចរក្សាទុកឯកសារភ្ជាប់ %d + មិនអាចរក្សាទុកឯកសារភ្ជាប់ %d + + "បាន​រក្សាទុក​ឯកសារ​ភ្ជាប់​ជា​សារ MMS" + "ការកំណត់" + "ទុក​ក្នុង​ប័ណ្ណសារ" + "បិទ" + "សារ MMS" + "កម្រិត​ខ្ពស់" + "កែ​កំហុស" + "ការ​ជូនដំណឹង" + "សំឡេង" + "ស្ងាត់" + "ញ័រ" + "បាន​ទប់ស្កាត់" + "របាយការណ៍​បញ្ជូន​សារ SMS" + "ស្នើ​​​របាយ​ការណ៍​​បញ្ជូន​សម្រាប់​សារ SMS ​នីមួយៗ​ដែល​អ្នក​ផ្ញើ" + "​យក​ស្វ័យ​ប្រវត្តិ" + "ទាញ​យក​សារ MMS ដោយ​ស្វ័យ​ប្រវត្តិ​" + "ទាញ​យក​ដោយ​ស្វ័យប្រវត្តិ​ពេល​រ៉ូមីង" + "ទាញសារ MMS ត្រឡប់មកវិញដោយស្វ័យប្រវត្តិនៅពេលរ៉ូមីង" + "ការ​ផ្ញើ​សារ​​ជា​ក្រុម" + "ប្រើ MMS ដើម្បី​ផ្ញើ​សារ នៅ​ពេល​មាន​អ្នក​ទទួល​ច្រើន" + "កម្មវិធី​ផ្ញើ​សារ SMS ​លំនាំ​ដើម" + "កម្មវិធី​ផ្ញើ​សារ SMS ​លំនាំ​ដើម" + + + "លេខ​ទូរសព្ទ​របស់​អ្នក" + "មិន​ស្គាល់" + "សម្លេងសារផ្ញើចេញ" + "បោះបង់​សារ SMS" + "ដាក់​ទិន្នន័យ​ដើម​នៃ​សារ SMS បាន​ទទួល​ក្នុង​ឯកសារ​ឧបករណ៍​ផ្ទុក​ខាង​ក្រៅ" + "បោះបង់​សារ MMS" + "ដាក់​ទិន្នន័យ​ដើម​នៃ​សារ MMS បាន​ទទួល​ក្នុង​ឯកសារ​ឧបករណ៍​ផ្ទុក​ខាង​ក្រៅ" + "ការ​ជូនដំណឹង​​ឥត​​​ខ្សែ" + "ជម្រើស​សារ" + "ចម្លង​អត្ថបទ" + "មើល​​ព័ត៌មាន​លម្អិត" + "លុប" + "បញ្ជូន​បន្ត" + "ព័ត៌មាន​លម្អិត​សារ" + "ប្រភេទ៖ " + "សារ​​អត្ថបទ" + "សារ​ពហុ​​មេឌៀ" + "ពី៖ " + "ជូន​ចំពោះ៖ " + "បាន​ផ្ញើ៖ " + "បាន​ទទួល៖ " + "ប្រធានបទ៖ " + "ទំហំ៖ " + "អាទិភាព៖ " + "ស៊ីម៖ " + "ខ្ពស់" + "ធម្មតា" + "ទាប" + "ស៊ីម %s" + "​អាសយដ្ឋាន​​អ្នក​ផ្ញើ​ដែល​បាន​លាក់" + "មិន​អាច​ផ្ញើ​សារ​ខណៈ​ពេល​ផ្ទុក​ឯកសារ​ភ្ជាប់។" + "មិន​អាច​ផ្ទុក​ឯកសារ​ភ្ជាប់។ ព្យាយាម​ម្ដង​ទៀត។" + "បណ្តាញមិនទាន់រួចរាល់ទេ។ ព្យាយាមម្តងទៀត។" + "លុប​អត្ថបទ" + "ប្ដូរ​​រវាង​លេខ និង​អត្ថបទ​បញ្ចូល" + "បន្ថែម​អ្នក​ចូលរួម​ច្រើន​ទៀត" + "បញ្ជាក់​អ្នក​ចូលរួម" + "ចាប់ផ្ដើមការសន្ទនាថ្មី" + "ជ្រើស​ធាតុ​នេះ" + "ចាក់​វីដេអូ" + "មនុស្ស & ជម្រើស" + "កែ​កំហុស" + "មនុស្ស & ជម្រើស" + "ទូទៅ" + "មនុស្ស​ក្នុង​ការ​សន្ទនា​នេះ" + "ហៅ" + "ផ្ញើ​សារ" + "ផ្ញើ​សារ<br/><small>ពី %s</small>" + + ផ្ញើរូបថត + ផ្ញើរូបថត + + + ផ្ញើសំឡេង + ផ្ញើសំឡេង + + + ផ្ញើវីដេអូ + ផ្ញើវីដេអូ + + + ផ្ញើកាតទំនាក់ទំនង + ផ្ញើកាតទំនាក់ទំនង + + + ផ្ញើឯកសារភ្ជាប់ + ផ្ញើឯកសារភ្ជាប់ + + + ឯកសារភ្ជាប់ %d ត្រៀមសម្រាប់ផ្ញើ + ឯកសារភ្ជាប់មួយត្រៀមសម្រាប់ផ្ញើ + + "ផ្ដល់​មតិ​ស្ថាបនា" + "មើល​នៅ​ក្នុង​​ Google ឃ្លាំង​កម្សាន្ត" + "ព័ត៌មាន​កំណែ" + "កំណែ %1$s" + "អាជ្ញាប័ណ្ណ​កម្មវិធី​ប្រភព​កូដ​ចំហ" + "ការ​ជូនដំណឹង" + "ឯកសារភ្ជាប់ឈានដល់កំណត់" + "ការផ្ទុកឯកសារភ្ជាប់បានបរាជ័យ។" + "បន្ថែម​ទៅ​ទំនាក់ទំនង​?" + "បន្ថែម​ទំនាក់ទំនង" + "ប្រធានបទ" + "ប្រធានបទ៖ " + "%s%s" + "ផ្ទុក​កាត​ទំនាក់ទំនង" + "មិន​អាច​ផ្ទុក​កាត​ទំនាក់ទំនង" + "មើល​កាត​ទំនាក់ទំនង" + + ទំនាក់ទំនង %d + ទំនាក់ទំនង %d + + "កាត​ទំនាក់ទំនង" + "ថ្ងៃ​កំណើត" + "ចំណាំ" + "បញ្ជូន​សារ​បន្ត" + "ឆ្លើយតប" + "+1" + "+%d" + "បាន​បិទ​សារ SMS" + "សូមកំណត់ការផ្ញើសារជាកម្មវិធីសារ SMS លំនាំដើមដើម្បីផ្ញើ" + "កំណត់ការផ្ញើសារជាកម្មវិធីសារ SMS លំនាំដើម" + "ផ្លាស់ប្ដូរ" + "សូមកំណត់ការផ្ញើសារជាកម្មវិធីសារ SMS លំនាំដើមដើម្បីទទួលសារ" + "គ្មានស៊ីមកាតដែលបានប្រើត្រូវបានជ្រើសរើសឲ្យផ្ញើសារ SMS ទេ" + "កម្មវិធី​នេះ​មិន​បាន​អនុញ្ញាត​ដោយ​ម្ចាស់​ឧបករណ៍។" + "យល់ព្រម" + "មាន​អ្នក​ចូលរួម​ច្រើន​ពេក​ក្នុង​ការ​សន្ទនា" + + ទំនាក់ទំនងមិនត្រឹមត្រូវ + ទំនាក់ទំនងមិនត្រឹមត្រូវ + + "មិន​អាច​ផ្ទុក​រូបភាព​ម៉ាស៊ីន​ថត" + "អ្នក​៖ " + "%s: " + "សេចក្ដី​ព្រាង" + "នៅ​ពេល​ដែល​អ្នក​ចាប់ផ្ដើម​​ការ​សន្ទនា​ថ្មី ​អ្នក​នឹង​ឃើញ​វា​​​បង្ហាញ​នៅ​ទីនេះ" + "ការ​សន្ទនា​បាន​ទុក​ក្នុង​ប័ណ្ណសារ​បង្ហាញ​នៅ​ទីនេះ" + "កំពុង​ផ្ទុក​ការ​សន្ទនា…" + "រូបភាព" + "ឃ្លីបសម្លេង" + "វីដេអូ" + "កាត​ទំនាក់ទំនង" + "សារ MMS" + "មិនធ្វើវិញ" + "ព្យាយាមម្ដងទៀត" + "បញ្ចូល​ឈ្មោះ​ទំនាក់ទំនង​ ឬ​លេខ​ទូរសព្ទ​ដើម្បី​ចាប់ផ្ដើម​សារ​ថ្មី" + "ទប់ស្កាត់" + "ទប់ស្កាត់ %s" + "មិន​ទប់ស្កាត់ %s" + "ទប់ស្កាត់ %s?" + "អ្នក​នឹង​បន្ត​ទទួល​សារ​ពី​លេខ​នេះ ប៉ុន្តែ​នឹង​មិន​ត្រូវ​បាន​ជូន​ដំណឹង​ទៀត​ទេ។ ការ​សន្ទនា​នេះ​នឹង​ត្រូវ​បាន​ទុក​ក្នុង​ប័ណ្ណសារ។" + "បាន​ទប់ស្កាត់​ទំនាក់ទំនង" + "មិន​ទប់ស្កាត់" + "បាន​ទប់ស្កាត់​ទំនាក់ទំនង​" + "ជ្រើស​រូបភាព​ពី​បណ្ណាល័យ​ឯកសារ" + "កំពុងផ្ញើសារ" + "បានផ្ញើសារ" + "ទិន្នន័យ​ចល័ត​ត្រូវ​បាន​បិទ។ ពិនិត្យ​ការ​កំណត់​របស់​អ្នក។" + "មិន​អាច​ផ្ញើ​សារ​ក្នុង​របៀប​ពេល​ជិះ​យន្ត​ហោះ" + "មិនអាចផ្ញើសារបានទេ" + "បានទាញយកសារ" + "ទិន្នន័យចល័តត្រូវបានបិទ។ ពិនិត្យការកំណត់របស់អ្នក។" + "មិនអាចទាញយកសារនៅក្នុង របៀបយន្តហោះ ទេ។" + "មិនអាចទាញយកសារបានទេ" + "សូន្យ" + "មួយ" + "ពីរ" + "​បី" + "បួន" + "ប្រាំ" + "ប្រាំមួយ" + "ប្រាំពីរ" + "ប្រាំបី" + "ប្រាំបួន" + "មិនអាចផ្ញើសារដោយប្រើ %1$s នេះបានទេ។ កំហុស %2$d" + "មិនអាចផ្ញើសារដោយប្រើក្រុមហ៊ុនផ្តល់សេវាដែលមិនស្គាល់បានទេ។ កំហុស %1$d" + "បញ្ជូន​បន្ត៖ %s" + "សារ​មិន​បាន​ផ្ញើ៖ សេវាកម្ម​មិន​សកម្ម​លើ​បណ្ដាញ" + "មិន​បាន​ផ្ញើសារ៖ អាសយដ្ឋាន​គោលដៅ​មិន​ត្រឹមត្រូវ" + "មិន​បាន​ផ្ញើ​សារ៖ សារ​មិន​ត្រឹមត្រូវ" + "មិន​បាន​ផ្ញើ​សារ៖ មិន​គាំទ្រ​មាតិកា" + "មិន​បាន​ផ្ញើសារ៖ មិន​គាំទ្រ​សារ" + "មិនបានផ្ញើសារ៖ ធំពេក" + "សារ​ថ្មី" + "មើល" + "រូបភាព" + "មិន​អាច​រក​ឃើញ​កម្មវិធី​ដែល​សមរម្យ" + "លុប​អ្នក​ទទួល" + "សារ​ថ្មី" + "បោះបង់" + "កែ​ចំណុច​ចូល​ដំណើរការ" + "មិនបាន​កំណត់" + "ឈ្មោះ" + "APN" + "MMSC" + "ប្រូកស៊ី MMS" + "ច្រក MMS" + "MCC" + "MNC" + "ប្រភេទ APN" + "លុប APN" + "APN ថ្មី" + "រក្សាទុក" + "បោះបង់" + "វាល​ឈ្មោះ​មិន​អាច​ទទេ។" + "APN មិន​អាច​ទទេ។" + "វាល MCC ត្រូវ​តែ​មាន​ ៣ តួ។" + "វាល MNC ត្រូវ​តែ​មាន​ពីរ ឬ​បី​តួ។" + "ស្ដារ​ការ​កំណត់ APN លំនាំដើម​ឡើងវិញ។" + "កំណត់​ទៅ​លំនាំដើម​ឡើងវិញ" + "បាន​បញ្ចប់​ការ​កំណត់​នៃ​ការកំណត់​ APN លំនាំដើម។" + "គ្មាន​ចំណង​ជើង" + "ឈ្មោះ​ចំណុច​ចូល​ដំណើរការ" + "APNs" + "APN ថ្មី" + "ការ​កំណត់​ឈ្មោះ​ចូល​ដំណើរការ​មិន​អាច​ប្រើ​បាន​សម្រាប់​អ្នក​ប្រើ​​នេះ" + "ចម្លង​ទៅ​ក្ដារ​តម្បៀត​ខ្ទាស់​?" + "ចម្លង" + "ទៅ %s" + "ទូទៅ" + "កម្រិត​ខ្ពស់" + "ការ​កំណត់​ទូទៅ" + "ការ​កំណត់​កម្រិត​ខ្ពស់" + "\"%s\" ស៊ីម" + "ផ្ញើ​សារ SMS នីមួយៗ​ទៅ​អ្នក​ទទួល​ទាំង​អស់។ មាន​តែ​អ្នក​ប៉ុណ្ណោះ​​ទទួល​បាន​ការ​ឆ្លើយតប" + "ផ្ញើ​សារ MMS មួយ​ទៅ​អ្នក​ទទួល" + "មិន​ស្គាល់​លេខ" + "សារថ្មី" + "សារថ្មី។" + "កម្មវិធីជ្រើសស៊ីម" + "បានជ្រើសរើស %1$s កម្មវិធីជ្រើសស៊ីម" + "កែប្រធានបទ" + "ជ្រើសស៊ីម ឬកែប្រធានបទ" + "ប៉ះ និងសង្កត់ដើម្បីថតសម្លេង" + "ចាប់ផ្ដើមការសន្ទនាថ្មី" + "ការផ្ញើសារ" + "បញ្ជីសារ" + "ការផ្ញើសារ" + "សារថ្មី" + "បញ្ជីសន្ទនា" + "កំពុងផ្ទុកការសន្ទនា…" + "សារកំពុងដំណើរការ" + "មើលការសន្ទនាច្រើនទៀត" + "មើលសារច្រើនទៀត" + "បានលុបការសន្ទនា" + "បានលុបការសន្ទនា។ សូមប៉ះដើម្បីបង្ហាញការសន្ទនាផ្សេងទៀតនៅក្នុងការផ្ញើសារ" + "បាន​ទប់ស្កាត់" + "បានឈប់ទប់ស្កាត់" + "ចន្លោះផ្ទុកមានកម្រិតទាប។ ទិន្នន័យមួយចំនួនអាចបាត់បង់។" + "ជ្រើសឯកសារភ្ជាប់" + "បញ្ជាក់​​ការ​ជ្រើស" + "បានជ្រើស %d" + "សូមយកឯកសារភ្ជាប់មួយ ឬច្រើនចេញ ហើយព្យាយាមម្តងទៀត។" + "អ្នកអាចព្យាយាមផ្ញើសារបស់អ្នក ប៉ុន្តែវាប្រហែលជាមិនផ្ញើទៅទេ លុះត្រាតែអ្នកយកឯកសារភ្ជាប់មួយ ឬច្រើនចេញ។" + "អ្នកអាចផ្ញើវីដេអូតែមួយប៉ុណ្ណោះនៅក្នុងសារមួយ។ សូមយកវីដេអូដែលលើសពីនេះចេញ ហើយព្យាយាមម្តងទៀត។" + "ការផ្ញើសារបានបរាជ័យក្នុងដំណើរការផ្ទុកឯកសារភ្ជាប់។" + "អញ្ចឹង​ផ្ញើ​​តែម្ដង" + "មិនអាចចាប់ផ្តើមការសន្ទនាទេ" + "%1$s (%2$s)" + "បានជ្រើស %s" + diff --git a/res/values-kn-rIN/arrays.xml b/res/values-kn-rIN/arrays.xml new file mode 100644 index 0000000..932fec1 --- /dev/null +++ b/res/values-kn-rIN/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "ಯಾವುದೇ ವಿಷಯವಿಲ್ಲ" + "ಯಾವುದೇ ವಿಷಯವಿಲ್ಲ" + + + "ಹೌದು" + "ಬೇಡ" + "ಸರಿ" + "ಹೆಹೆ" + "ಧನ್ಯವಾದಗಳು" + "ಒಪ್ಪುತ್ತೇನೆ" + "ಉತ್ತಮ" + "ನನ್ನ ಹಾದಿಯಲ್ಲಿದ್ದೇನೆ" + "ಸರಿ, ನಾನು ಮತ್ತೆ ನಿಮಗೆ ತಿಳಿಸುತ್ತೇನೆ" + ":)" + ":(" + + diff --git a/res/values-kn-rIN/strings.xml b/res/values-kn-rIN/strings.xml new file mode 100644 index 0000000..eb9539b --- /dev/null +++ b/res/values-kn-rIN/strings.xml @@ -0,0 +1,519 @@ + + + + + "ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆ" + "ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆ" + "ಸಂಭಾಷಣೆಗಳನ್ನು ಆಯ್ಕೆಮಾಡಿ" + "ಸೆಟ್ಟಿಂಗ್‌ಗಳು" + "ಸಂದೇಶ ಕಳುಹಿಸು" + "ಲಗತ್ತನ್ನು ಸೇರಿಸಿ" + "ಸಹಾಯ" + "ಸುಸ್ವಾಗತ" + "ಸ್ಕಿಪ್‌" + "ಮುಂದೆ >" + "ಮುಂದೆ" + "ನಿರ್ಗಮಿಸು" + "ಸೆಟ್ಟಿಂಗ್‌ಗಳು >" + "ಸೆಟ್ಟಿಂಗ್‌ಗಳು" + "ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆಗೆ SMS, ಫೋನ್ ಮತ್ತು ಸಂಪರ್ಕಗಳಿಗೆ ಅನುಮತಿ ಹೊಂದಿರುವ ಅಗತ್ಯವಿದೆ." + "ನೀವು ಅನುಮತಿಗಳನ್ನು ಸೆಟ್ಟಿಂಗ್‌ಗಳು > ಅಪ್ಲಿಕೇಶನ್‌ಗಳು > ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆ > ಅನುಮತಿಗಳಲ್ಲಿ ಬದಲಾಯಿಸಬಹುದು." + "ನೀವು ಅನುಮತಿಗಳನ್ನು ಸೆಟ್ಟಿಂಗ್‌ಗಳು, ಅಪ್ಲಿಕೇಶನ್‌ಗಳು, ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆ, ಅನುಮತಿಗಳಲ್ಲಿ ಬದಲಾಯಿಸಬಹುದು." + "ಪುನರಾವರ್ತನೆಗಳು" + "ಎಲ್ಲ ಸಂಪರ್ಕಗಳು" + "%s ಗೆ ಕಳುಹಿಸಿ" + "ಚಿತ್ರಗಳು ಅಥವಾ ವೀಡಿಯೊವನ್ನು ಸೆರೆಹಿಡಿಯಿರಿ" + "ಈ ಸಾಧನದಿಂದ ಚಿತ್ರಗಳನ್ನು ಆಯ್ಕೆಮಾಡಿ" + "ಆಡಿಯೋ ರೆಕಾರ್ಡ್‌ ಮಾಡಿ" + "ಫೋಟೋ ಆಯ್ಕೆಮಾಡಿ" + "ಮಾಧ್ಯಮ ಆಯ್ಕೆಮಾಡಲಾಗಿದೆ." + "ಮಾಧ್ಯಮ ಆಯ್ಕೆ ರದ್ದುಗೊಳಿಸಲಾಗಿದೆ." + "%d ಆಯ್ಕೆಮಾಡಲಾಗಿದೆ" + "ಚಿತ್ರ %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "ಚಿತ್ರ" + "ಆಡಿಯೋ ರೆಕಾರ್ಡ್‌ ಮಾಡಿ" + "ಹಂಚಿಕೊಳ್ಳಿ" + "ಈಗ ತಾನೇ" + "ಇದೀಗ" + + %d ನಿಮಿಷಗಳು + %d ನಿಮಿಷಗಳು + + + %d ಗಂಟೆಗಳು + %d ಗಂಟೆಗಳು + + + %d ದಿನಗಳು + %d ದಿನಗಳು + + + %d ವಾರಗಳು + %d ವಾರಗಳು + + + %d ತಿಂಗಳು + %d ತಿಂಗಳು + + + %d ವರ್ಷಗಳು + %d ವರ್ಷಗಳು + + "ವರ್ಗ 0 ಸಂದೇಶ" + "ಉಳಿಸು" + "ಸಾಧನದಲ್ಲಿ ಸ್ಥಳಾವಕಾಶವು ಕಡಿಮೆ ಇದೆ. ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆಯು ಸ್ಥಳಾವಕಾಶವನ್ನು ತೆರವುಗೊಳಿಸಲು ಹಳೆಯ ಸಂದೇಶಗಳನ್ನು ಸ್ವಯಂಚಾಲಿತವಾಗಿ ಅಳಿಸುತ್ತದೆ." + "ಸಂಗ್ರಹಣೆ ಸ್ಥಳವು ತುಂಬಿದೆ" + "ನಿಮ್ಮ ಸಾಧನದಲ್ಲಿ ಹೆಚ್ಚು ಸ್ಥಳಾವಕಾಶ ಲಭ್ಯವಿರುವವರೆಗೆ ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆಯು ಸಂದೇಶಗಳನ್ನು ಕಳುಹಿಸದಿರಬಹುದು ಅಥವಾ ಸ್ವೀಕರಿಸದಿರಬಹುದು." + "ಕಡಿಮೆ SMS ಸಂಗ್ರಹಣೆ. ನೀವು ಸಂದೇಶಗಳನ್ನು ಅಳಿಸಬೇಕಾಗಬಹುದು." + "ನಿಮ್ಮ ಫೋನ್ ಸಂಖ್ಯೆಯನ್ನು ಖಚಿತಪಡಿಸಿ" + "ಈ ಒಂದು-ಬಾರಿ ಹಂತವು ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆಯು ನಿಮ್ಮ ಗುಂಪು ಸಂದೇಶಗಳನ್ನು ಸರಿಯಾಗಿ ಕಳುಹಿಸುತ್ತದೆ ಎಂಬುದನ್ನು ಖಚಿತಪಡಿಸುತ್ತದೆ." + "ಫೋನ್ ಸಂಖ್ಯೆ" + "ಮೀಡಿಯಾದ ಎಲ್ಲಾ ಸಂದೇಶಗಳನ್ನು ಅಳಿಸಿ" + "%s ಗಿಂತಲೂ ಹಳೆಯದಾದ ಸಂದೇಶಗಳನ್ನು ಅಳಿಸಿ" + "%s ಗಿಂತಲೂ ಹಳೆಯದಾದ ಸಂದೇಶಗಳನ್ನು ಸ್ವಯಂ-ಅಳಿಸಿ" + "ನಿರ್ಲಕ್ಷಿಸು" + "ಮಾಧ್ಯಮದ ಎಲ್ಲಾ ಸಂದೇಶಗಳನ್ನು ಅಳಿಸುವುದೇ?" + "%s ಗಿಂತಲೂ ಹಳೆಯದಾದ ಸಂದೇಶಗಳನ್ನು ಅಳಿಸುವುದೇ?" + "%s ಗಿಂತಲೂ ಹಳೆಯದಾದ ಸಂದೇಶಗಳನ್ನು ಅಳಿಸಿ ಹಾಗೂ ಸ್ವಯಂ-ಅಳಿಸು ಆನ್‌ ಮಾಡಿ?" + "%s ಹೇಳಿದ್ದಾರೆ" + "ನೀವು ಹೇಳಿದ್ದೀರಿ" + "%s ಅವರಿಂದ ಸಂದೇಶ" + "ನೀವು ಸಂದೇಶವನ್ನು ಕಳುಹಿಸಿರುವಿರಿ" + "ಕಳುಹಿಸಲಾಗುತ್ತಿದೆ..." + "ಕಳುಹಿಸಲಾಗಿಲ್ಲ. ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಲು ಸ್ಪರ್ಶಿಸಿ." + "ಕಳುಹಿಸಲಾಗಿಲ್ಲ. ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಲಾಗುತ್ತಿದೆ…" + "ಮರುಕಳುಹಿಸಿ ಅಥವಾ ಅಳಿಸಿ" + "ತುರ್ತು ಸೇವೆಗಳಿಗೆ ದಯವಿಟ್ಟು ಧ್ವನಿ ಕರೆಗಳನ್ನು ಮಾಡಿ. ಈ ಸಮಯದಲ್ಲಿ ನಿಮ್ಮ ಪಠ್ಯ ಸಂದೇಶವನ್ನು ತಲುಪಿಸಲು ಸಾಧ್ಯವಿಲ್ಲ." + "ವಿಫಲವಾಗಿದೆ" + "ಡೌನ್‌ಲೋಡ್‌ ಮಾಡಲು ಹೊಸ MMS ಸಂದೇಶ" + "ಹೊಸ MMS ಸಂದೇಶ" + "ಡೌನ್‌ಲೋಡ್‌ ಮಾಡಲು ಸಾಧ್ಯವಾಗಿಲ್ಲ" + "ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಲು ಸ್ಪರ್ಶಿಸಿ" + "ಡೌನ್‌ಲೋಡ್‌ ಮಾಡಲು ಸ್ಪರ್ಶಿಸಿ" + "ಡೌನ್‌ಲೋಡ್‌ ಮಾಡಿ ಇಲ್ಲವೇ ಅಳಿಸಿ" + "ಡೌನ್‌ಲೋಡ್‌ ಮಾಡಲಾಗುತ್ತಿದೆ…" + "ಸಂದೇಶ ಅವಧಿ ಮೀರಿದೆ ಅಥವಾ ಲಭ್ಯವಿಲ್ಲ" + "ಗಾತ್ರ: %1$s, ಮುಕ್ತಾಯ: %2$s" + "ಕಳುಹಿಸಲಾಗುವುದಿಲ್ಲ. ಸ್ವೀಕರಿಸುವವರು ಮಾನ್ಯವಾಗಿಲ್ಲ." + "ನೆಟ್‌ವರ್ಕ್‌ನಲ್ಲಿ ಸೇವೆಯು ಸಕ್ರಿಯಗೊಂಡಿಲ್ಲ" + "ನೆಟ್‌ವರ್ಕ್‌ ಸಮಸ್ಯೆಯಿಂದಾಗಿ ಕಳುಹಿಸಲಾಗಲಿಲ್ಲ" + "ಸಂದೇಶ ಅವಧಿ ಮೀರಿದೆ ಅಥವಾ ಲಭ್ಯವಿಲ್ಲ" + "(ವಿಷಯ ಇಲ್ಲ)" + "ಕಳುಹಿಸಿದವರು ತಿಳಿದಿಲ್ಲ" + "ತಲುಪಿಸಲಾಗಿದೆ" + "%2$s ರಿಂದ %1$s ಸಂದೇಶವನ್ನು ಡೌನ್‌ಲೋಡ್ ಮಾಡಲು ಸಾಧ್ಯವಿಲ್ಲ." + "ಕಡಿಮೆ ಮೆಮೊರಿಯ ಕಾರಣ ಡೇಟಾಬೇಸ್ ಕಾರ್ಯಾಚರಣೆಯನ್ನು ಪೂರ್ಣಗೊಳಿಸಲು ಸಾಧ್ಯವಾಗುತ್ತಿಲ್ಲ" + "ಸಂದೇಶ ಕಳುಹಿಸಲಾಗಿಲ್ಲ" + "ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆಯಲ್ಲಿ ಕೆಲವು ಸಂದೇಶಗಳನ್ನು ಕಳುಹಿಸಲಾಗಿಲ್ಲ" + + %d ಸಂವಾದಗಳಲ್ಲಿ %d ಸಂದೇಶಗಳು + %d ಸಂವಾದಗಳಲ್ಲಿ %d ಸಂದೇಶಗಳು + + "ಸಂದೇಶವನ್ನು ಡೌನ್‌ಲೋಡ್‌ ಮಾಡಲಾಗಿಲ್ಲ" + "ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆಯಲ್ಲಿ ಕೆಲವು ಸಂದೇಶಗಳನ್ನು ಡೌನ್‌ಲೋಡ್ ಮಾಡಲಾಗಿಲ್ಲ" + + %d ಸಂವಾದಗಳಲ್ಲಿ %d ಸಂದೇಶಗಳು + %d ಸಂವಾದಗಳಲ್ಲಿ %d ಸಂದೇಶಗಳು + + "%1$s ಸಂಖ್ಯೆಗೆ ಸಂದೇಶವನ್ನು ಕಳುಹಿಸಿಲ್ಲ" + "ತುರ್ತು ಸೇವೆಗಳಿಗೆ ದಯವಿಟ್ಟು ಧ್ವನಿ ಕರೆಗಳನ್ನು ಮಾಡಿ. ಈ ಸಮಯದಲ್ಲಿ ನಿಮ್ಮ ಪಠ್ಯ ಸಂದೇಶವನ್ನು %1$s ಸಂಖ್ಯೆಗೆ ತಲುಪಿಸಲು ಸಾಧ್ಯವಿಲ್ಲ." + + %d ಹೊಸ ಸಂದೇಶಗಳು + %d ಹೊಸ ಸಂದೇಶಗಳು + + "ಪ್ರಾರಂಭಿಸು" + "ಕ್ಯಾಮರಾ ಲಭ್ಯವಿಲ್ಲ" + "ಕ್ಯಾಮರಾ ಲಭ್ಯವಿಲ್ಲ" + "ವಿಡಿಯೋ ಸೆರೆಹಿಡಿಯುವಿಕೆ ಲಭ್ಯವಿಲ್ಲ" + "ಮಾಧ್ಯಮ ಉಳಿಸಲಾಗಲಿಲ್ಲ" + "ಫೋಟೋ ತೆಗೆದುಕೊಳ್ಳಲು ಸಾಧ್ಯವಿಲ್ಲ" + "ಹಿಂದೆ" + "ಆರ್ಕೈವ್ ಮಾಡಲಾಗಿದೆ" + "ಅಳಿಸಿ" + "ಆರ್ಕೈವ್ ಮಾಡಿ" + "ಅನ್‌ಆರ್ಕೈವ್ ಮಾಡು" + "ಅಧಿಸೂಚನೆಗಳನ್ನು ಆಫ್ ಮಾಡಿ" + "ಅಧಿಸೂಚನೆಗಳನ್ನು ಆನ್ ಮಾಡು" + "ಸಂಪರ್ಕ ಸೇರಿಸು" + "ಡೌನ್‌ಲೋಡ್‌" + "ಕಳುಹಿಸು" + "ಅಳಿಸಿ" + "ಈ ಸಂದೇಶವನ್ನು ಅಳಿಸುವುದೇ?" + "ಈ ಕ್ರಿಯೆಯನ್ನು ರದ್ದುಪಡಿಸಲಾಗುವುದಿಲ್ಲ" + "ಅಳಿಸಿ" + + ಈ ಸಂವಾದಗಳನ್ನು ಅಳಿಸುವುದೇ? + ಈ ಸಂವಾದಗಳನ್ನು ಅಳಿಸುವುದೇ? + + "ಅಳಿಸಿ" + "ರದ್ದುಮಾಡು" + "ಇವರಿಗೆ" + "ಬಹು ಚಿತ್ರಗಳನ್ನು ಆಯ್ಕೆಮಾಡಿ" + "ಆಯ್ಕೆ ಖಚಿತಪಡಿಸಿ" + "+%d" + "ಆಡಿಯೋ ರೆಕಾರ್ಡ್ ಮಾಡಲು ಸಾಧ್ಯವಿಲ್ಲ. ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ." + "ಆಡಿಯೋವನ್ನು ಪ್ಲೇ ಮಾಡಲು ಸಾಧ್ಯವಿಲ್ಲ. ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ." + "ಆಡಿಯೋವನ್ನು ಉಳಿಸಲಾಗಲಿಲ್ಲ. ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ." + "ಸ್ಪರ್ಶಿಸಿ & ಹೋಲ್ಡ್‌ ಮಾಡಿ" + ", " + " " + ": " + " " + "ಚಿತ್ರ" + "ಆಡಿಯೊ ಕ್ಲಿಪ್" + "ವೀಡಿಯೊ" + "ಸಂಪರ್ಕದ ಕಾರ್ಡ್" + "ಡೌನ್‌ಲೋಡ್‌" + "SMS ಮೂಲಕ ಪ್ರತ್ಯುತ್ತರಿಸಿ" + "MMS ಮೂಲಕ ಪ್ರತ್ಯುತ್ತರಿಸಿ" + "ಪ್ರತ್ಯುತ್ತರಿಸು" + + %d ಪಾಲ್ಗೊಳ್ಳುವವರು + %d ಪಾಲ್ಗೊಳ್ಳುವವರು + + "ನಾನು" + "ಸಂಪರ್ಕವನ್ನು ನಿರ್ಬಂಧಿಸಲಾಗಿದೆ & ಆರ್ಕೈವ್‌ ಮಾಡಲಾಗಿದೆ" + "ಸಂಪರ್ಕವನ್ನು ಅನಿರ್ಬಂಧಿಸಲಾಗಿದೆ & ಆರ್ಕೈವ್ ರದ್ದುಮಾಡಲಾಗಿದೆ" + "%d ಆರ್ಕೈವ್‌‌ ಮಾಡಲಾಗಿದೆ" + "%d ಅನ್‌ಆರ್ಕೈವ್‌ ಮಾಡಲಾಗಿದೆ" + "ಅಧಿಸೂಚನೆಗಳನ್ನು ಆಫ್ ಮಾಡಲಾಗಿದೆ" + "ಅಧಿಸೂಚನೆಗಳನ್ನು ಆನ್ ಮಾಡಲಾಗಿದೆ" + "ಎಲ್ಲವನ್ನೂ ಹೊಂದಿಸಲಾಗಿದೆ. ಮತ್ತೆ ಕಳುಹಿಸಲು ಸ್ಪರ್ಶಿಸಿ." + "ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆಯನ್ನು ಯಶಸ್ವಿಯಾಗಿ ಡೀಫಾಲ್ಟ್ SMS ಅಪ್ಲಿಕೇಶನ್‌ನಂತೆ ಹೊಂದಿಸಲಾಗಿದೆ." + + ಲಗತ್ತುಗಳನ್ನು ತಿರಸ್ಕರಿಸು + ಲಗತ್ತುಗಳನ್ನು ತಿರಸ್ಕರಿಸು + + "ಆಡಿಯೋ ಲಗತ್ತು" + "ಆಡಿಯೊ ಲಗತ್ತನ್ನು ಪ್ಲೇ ಮಾಡಿ" + "ವಿರಾಮಗೊಳಿಸು" + "%s ಅವರಿಂದ ಸಂದೇಶ: %s." + "%s ಅವರಿಂದ ಸಂದೇಶ ವಿಫಲವಾಗಿದೆ: %s. ಸಮಯ: %s." + "%s ಅವರಿಂದ ಸಂದೇಶ: %s. ಸಮಯ: %s." + "%s ಗೆ ಕಳುಹಿಸದಿರುವ ಸಂದೇಶ: %s. ಸಮಯ: %s." + "%s ಗೆ ಸಂದೇಶವನ್ನು ಕಳುಹಿಸಲಾಗುತ್ತಿದೆ: %s. ಸಮಯ: %s." + "%s ಗೆ ಸಂದೇಶ ವಿಫಲವಾಗಿದೆ : %s. ಸಮಯ: %s." + "%s ಗೆ ಸಂದೇಶ: %s. ಸಮಯ: %s." + "%s ಅವರಿಂದ ಸಂದೇಶ ವಿಫಲವಾಗಿದೆ: %s. ಸಮಯ: %s. %s." + "%s ಅವರಿಂದ ಸಂದೇಶ: %s. ಸಮಯ: %s. %s." + "%s ಗೆ ಕಳುಹಿಸದಿರುವ ಸಂದೇಶ: %s. ಸಮಯ: %s." + "%s ಗೆ ಸಂದೇಶವನ್ನು ಕಳುಹಿಸಲಾಗುತ್ತಿದೆ: %s. ಸಮಯ: %s." + "%s ಗೆ ಸಂದೇಶ ವಿಫಲವಾಗಿದೆ : %s. ಸಮಯ: %s." + "%s ಗೆ ಸಂದೇಶ: %s. ಸಮಯ: %s." + "ಸಂದೇಶ ವಿಫಲವಾಗಿದೆ. ಮರುಪ್ರಯತ್ನಿಸಲು ಸ್ಪರ್ಶಿಸಿ." + "%s ಅವರೊಂದಿಗೆ ಸಂಭಾಷಣೆ" + "ವಿಷಯವನ್ನು ಅಳಿಸಿ" + "ವೀಡಿಯೊ ಸೆರೆಹಿಡಿಯಿರಿ" + "ಸ್ಥಿರ ಚಿತ್ರವನ್ನು ಸೆರೆಹಿಡಿಯಿರಿ" + "ಫೋಟೋ ತೆಗೆಯಿರಿ" + "ವೀಡಿಯೊ ರೆಕಾರ್ಡಿಂಗ್ ಪ್ರಾರಂಭಿಸಿ" + "ಪೂರ್ಣ ಪರದೆ ಕ್ಯಾಮರಾಗೆ ಬದಲಿಸಿ" + "ಮುಂದಿನ ಮತ್ತು ಹಿಂದಿನ ಕ್ಯಾಮರಾ ನಡುವೆ ಬದಲಾಯಿಸಿ" + "ರೆಕಾರ್ಡಿಂಗ್ ನಿಲ್ಲಿಸಿ ಮತ್ತು ವೀಡಿಯೊ ಲಗತ್ತಿಸಿ" + "ವೀಡಿಯೊ ರೆಕಾರ್ಡಿಂಗ್ ನಿಲ್ಲಿಸಿ" + "ಫೋಟೋಗಳ ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆ" + + \"%s\" ಆಲ್ಬಮ್‌ಗೆ %d ಫೋಟೋಗಳನ್ನು ಉಳಿಸಲಾಗಿದೆ + \"%s\" ಆಲ್ಬಮ್‌ಗೆ %d ಫೋಟೋಗಳನ್ನು ಉಳಿಸಲಾಗಿದೆ + + + \"%s\" ಆಲ್ಬಮ್‌ಗೆ %d ವೀಡಿಯೊಗಳನ್ನು ಉಳಿಸಲಾಗಿದೆ + \"%s\" ಆಲ್ಬಮ್‌ಗೆ %d ವೀಡಿಯೊಗಳನ್ನು ಉಳಿಸಲಾಗಿದೆ + + + \"%s\" ಆಲ್ಬಮ್‌ಗೆ %d ಲಗತ್ತುಗಳನ್ನು ಉಳಿಸಲಾಗಿದೆ + \"%s\" ಆಲ್ಬಮ್‌ಗೆ %d ಲಗತ್ತುಗಳನ್ನು ಉಳಿಸಲಾಗಿದೆ + + + \"ಡೌನ್‌ಲೋಡ್‌ಗಳಿಗೆ\" %d ಲಗತ್ತುಗಳನ್ನು ಉಳಿಸಲಾಗಿದೆ + \"ಡೌನ್‌ಲೋಡ್‌ಗಳಿಗೆ\" %d ಲಗತ್ತುಗಳನ್ನು ಉಳಿಸಲಾಗಿದೆ + + + %d ಲಗತ್ತುಗಳನ್ನು ಉಳಿಸಲಾಗಿದೆ + %d ಲಗತ್ತುಗಳನ್ನು ಉಳಿಸಲಾಗಿದೆ + + + %d ಲಗತ್ತುಗಳನ್ನು ಉಳಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ + %d ಲಗತ್ತುಗಳನ್ನು ಉಳಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ + + "MMS ಲಗತ್ತನ್ನು ಉಳಿಸಲಾಗಿದೆ" + "ಸೆಟ್ಟಿಂಗ್‌ಗಳು" + "ಆರ್ಕೈವ್ ಮಾಡಲಾಗಿದೆ" + "ಮುಚ್ಚು" + "MMS" + "ಸುಧಾರಿತ" + "ಡೀಬಗ್ ಮಾಡು" + "ಅಧಿಸೂಚನೆಗಳು" + "ಶಬ್ದ" + "ಶಾಂತ" + "ವೈಬ್ರೇಟ್‌‌" + "ನಿರ್ಬಂಧಿಸಲಾಗಿದೆ" + "SMS ವಿತರಣಾ ವರದಿಗಳು" + "ನೀವು ಕಳುಹಿಸುವ ಪ್ರತಿ SMS ಗೂ ವಿತರಣೆ ವರದಿಯನ್ನು ವಿನಂತಿಸಿ" + "ಸ್ವಯಂ-ಹಿಂಪಡೆಯುವಿಕೆ" + "MMS ಸಂದೇಶಗಳನ್ನು ಸ್ವಯಂಚಾಲಿತವಾಗಿ ಹಿಂಪಡೆದುಕೊಳ್ಳಿ" + "ರೋಮಿಂಗ್‌‌ನಲ್ಲಿರುವಾಗ ಸ್ವಯಂ-ಹಿಂಪಡೆಯುವಿಕೆ" + "ರೋಮಿಂಗ್‌ನಲ್ಲಿರುವಾಗ ಸ್ವಯಂಚಾಲಿತವಾಗಿ MMS ಹಿಂಪಡೆಯಿರಿ" + "ಗುಂಪು ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆ" + "ಅನೇಕ ಸ್ವೀಕೃತದಾರರು ಇರುವಾಗ ಒಂದೇ ಸಂದೇಶವನ್ನು ಕಳುಹಿಸಲು MMS ಬಳಸಿ" + "ಡೀಫಾಲ್ಟ್ SMS ಅಪ್ಲಿಕೇಶನ್" + "ಡೀಫಾಲ್ಟ್ SMS ಅಪ್ಲಿಕೇಶನ್" + + + "ನಿಮ್ಮ ಫೋನ್‌ ಸಂಖ್ಯೆ" + "ಅಪರಿಚಿತ" + "ಹೊರಹೋಗುವ ಸಂದೇಶಕ್ಕೆ ಧ್ವನಿಗಳು" + "ಡಂಪ್ SMS" + "ಬಾಹ್ಯ ಸಂಗ್ರಹಣೆ ಫೈಲ್‌ಗೆ SMS ಕಚ್ಛಾ ಡೇಟಾವನ್ನು ಡಂಪ್ ಸ್ವೀಕರಿಸಿದೆ" + "ಡಂಪ್ MMS" + "ಬಾಹ್ಯ ಸಂಗ್ರಹಣೆ ಫೈಲ್‌ಗೆ MMS ಕಚ್ಛಾ ಡೇಟಾವನ್ನು ಡಂಪ್ ಸ್ವೀಕರಿಸಿದೆ" + "ವೈರ್‌ಲೆಸ್ ಎಚ್ಚರಿಕೆಗಳು" + "ಸಂದೇಶ ಆಯ್ಕೆಗಳು" + "ಪಠ್ಯ ನಕಲಿಸು" + "ವಿವರಗಳನ್ನು ವೀಕ್ಷಿಸಿ" + "ಅಳಿಸಿ" + "ಫಾರ್ವರ್ಡ್ ಮಾಡು" + "ಸಂದೇಶ ವಿವರಗಳು" + "ಪ್ರಕಾರ: " + "ಪಠ್ಯ ಸಂದೇಶ" + "ಮಲ್ಟಿಮೀಡಿಯಾ ಸಂದೇಶ" + "ಇಂದ: " + "ಗೆ: " + "ಕಳುಹಿಸಿದ್ದು: " + "ಸ್ವೀಕರಿಸಿದ್ದು: " + "ವಿಷಯ: " + "ಗಾತ್ರ: " + "ಆದ್ಯತೆ: " + "SIM: " + "ಅಧಿಕ" + "ಸಾಮಾನ್ಯ" + "ಕಡಿಮೆ" + "SIM %s" + "ಮರೆಮಾಡಲಾಗಿರುವ ಕಳುಹಿಸುವವರ ವಿಳಾಸ" + "ಲಗತ್ತುಗಳನ್ನು ಲೋಡ್ ಮಾಡುತ್ತಿರುವಾಗ ಸಂದೇಶವನ್ನು ಕಳುಹಿಸಲು ಸಾಧ್ಯವಿಲ್ಲ." + "ಲಗತ್ತನ್ನು ಲೋಡ್ ಮಾಡಲು ಸಾಧ್ಯವಿಲ್ಲ. ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ." + "ನೆಟ್‌ವರ್ಕ್ ಸಿದ್ಧವಾಗಿಲ್ಲ. ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ." + "ಪಠ್ಯ ಅಳಿಸಿ" + "ಪಠ್ಯ ಮತ್ತು ಸಂಖ್ಯೆಗಳ ನಡುವೆ ನಮೂದಿಸುವುದನ್ನು ಬದಲಾಯಿಸಿ" + "ಇನ್ನಷ್ಟು ಪಾಲ್ಗೊಳ್ಳುವವರನ್ನು ಸೇರಿಸಿ" + "ಭಾಗವಹಿಸುವವರನ್ನು ಖಚಿತಪಡಿಸಿ" + "ಹೊಸ ಸಂವಾದವನ್ನು ಪ್ರಾರಂಭಿಸಿ" + "ಈ ಐಟಂ ಆಯ್ಕೆಮಾಡಿ" + "ವೀಡಿಯೊ ಪ್ಲೇ ಮಾಡಿ" + "ಜನರು & ಆಯ್ಕೆಗಳು" + "ಡೀಬಗ್ ಮಾಡು" + "ಜನರು & ಆಯ್ಕೆಗಳು" + "ಸಾಮಾನ್ಯ" + "ಈ ಸಂವಾದದಲ್ಲಿರುವ ಜನರು" + "ಕರೆ ಮಾಡಿ" + "ಸಂದೇಶ ಕಳುಹಿಸಿ" + "<br/><small>%s</small> ನಿಂದ ಸಂದೇಶ ಕಳುಹಿಸಿ" + + ಫೋಟೋಗಳನ್ನು ಕಳುಹಿಸಿ + ಫೋಟೋಗಳನ್ನು ಕಳುಹಿಸಿ + + + ಆಡಿಯೋಗಳನ್ನು ಕಳುಹಿಸಿ + ಆಡಿಯೋಗಳನ್ನು ಕಳುಹಿಸಿ + + + ವೀಡಿಯೊಗಳನ್ನು ಕಳುಹಿಸಿ + ವೀಡಿಯೊಗಳನ್ನು ಕಳುಹಿಸಿ + + + ಸಂಪರ್ಕ ಕಾರ್ಡ್‍ಗಳನ್ನು ಕಳುಹಿಸಿ + ಸಂಪರ್ಕ ಕಾರ್ಡ್‍ಗಳನ್ನು ಕಳುಹಿಸಿ + + + ಲಗತ್ತುಗಳನ್ನು ಕಳುಹಿಸಿ + ಲಗತ್ತುಗಳನ್ನು ಕಳುಹಿಸಿ + + + %d ಕಳುಹಿಸಲು ಲಗತ್ತುಗಳು ಸಿದ್ಧವಾಗಿವೆ + %d ಕಳುಹಿಸಲು ಲಗತ್ತುಗಳು ಸಿದ್ಧವಾಗಿವೆ + + "ಪ್ರತಿಕ್ರಿಯೆ ಕಳುಹಿಸಿ" + "Google Play Store ನಲ್ಲಿ ವೀಕ್ಷಿಸಿ" + "ಆವೃತ್ತಿ ಮಾಹಿತಿ" + "ಆವೃತ್ತಿ %1$s" + "ಮುಕ್ತ ಮೂಲ ಪರವಾನಗಿಗಳು" + "ಅಧಿಸೂಚನೆಗಳು" + "ಲಗತ್ತು ಮಿತಿಯನ್ನು ತಲುಪಿದೆ" + "ಲಗತ್ತನ್ನು ಲೋಡ್ ಮಾಡಲು ವಿಫಲವಾಗಿದೆ." + "ಸಂಪರ್ಕಗಳಿಗೆ ಸೇರಿಸುವುದೇ?" + "ಸಂಪರ್ಕ ಸೇರಿಸು" + "ವಿಷಯ" + "ವಿಷಯ: " + "%s%s" + "ಸಂಪರ್ಕ ಕಾರ್ಡ್‌ ಲೋಡ್‌ ಮಾಡಲಾಗುತ್ತಿದೆ" + "ಸಂಪರ್ಕ ಕಾರ್ಡ್ ಲೋಡ್ ಮಾಡಲು ಸಾಧ್ಯವಿಲ್ಲ" + "ಸಂಪರ್ಕ ಕಾರ್ಡ್ ವೀಕ್ಷಿಸಿ" + + %d ಸಂಪರ್ಕಗಳು + %d ಸಂಪರ್ಕಗಳು + + "ಸಂಪರ್ಕ ಕಾರ್ಡ್‌ಗಳು" + "ಜನ್ಮದಿನ" + "ಟಿಪ್ಪಣಿಗಳು" + "ಸಂದೇಶವನ್ನು ಫಾರ್ವರ್ಡ್ ಮಾಡು" + "ಪ್ರತ್ಯುತ್ತರ ನೀಡು" + "+1" + "+%d" + "SMS ನಿಷ್ಕ್ರಿಯಗೊಳಿಸಲಾಗಿದೆ" + "ಕಳುಹಿಸಲು, ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆಯನ್ನು ಡೀಫಾಲ್ಟ್ SMS ಅಪ್ಲಿಕೇಶನ್‌ನಂತೆ ಹೊಂದಿಸಿ" + "ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆಯನ್ನು ಡೀಫಾಲ್ಟ್ SMS ಅಪ್ಲಿಕೇಶನ್‌ನಂತೆ ಹೊಂದಿಸಿ" + "ಬದಲಾಯಿಸು" + "ಸಂದೇಶಗಳನ್ನು ಸ್ವೀಕರಿಸಲು, ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆಯನ್ನು ಡೀಫಾಲ್ಟ್ SMS ಅಪ್ಲಿಕೇಶನ್‌ನಂತೆ ಹೊಂದಿಸಿ" + "SMS ಸಂದೇಶಗಳನ್ನು ಕಳುಹಿಸಲು ಯಾವುದೇ ಪ್ರಾಶಸ್ತ್ಯದ ಸಿಮ್ ಆಯ್ಕೆಮಾಡಲಾಗಿಲ್ಲ" + "ಸಾಧನದ ಮಾಲೀಕರಿಂದ ಈ ಅಪ್ಲಿಕೇಶನ್ ಅನ್ನು ಅನುಮತಿಸಲಾಗಿಲ್ಲ" + "ಸರಿ" + "ಸಂವಾದದಲ್ಲಿ ಹಲವಾರು ಸಹಭಾಗಿಗಳಿದ್ದಾರೆ" + + ಅಮಾನ್ಯ ಸಂಪರ್ಕಗಳು + ಅಮಾನ್ಯ ಸಂಪರ್ಕಗಳು + + "ಕ್ಯಾಮರಾ ಚಿತ್ರವನ್ನು ಲೋಡ್ ಮಾಡಲಾಗಲಿಲ್ಲ" + "ನೀವು: " + "%s: " + "ಡ್ರಾಫ್ಟ್‌" + "ನೀವು ಒಮ್ಮೆ ಹೊಸ ಸಂವಾದ ಪ್ರಾರಂಭಿಸಿದರೆ, ಅದು ಇಲ್ಲಿ ಪಟ್ಟಿ ಮಾಡಲಾಗಿರುವುದನ್ನು ನೋಡುತ್ತೀರಿ" + "ಆರ್ಕೈವ್‌ ಮಾಡಲಾದ ಸಂವಾದಗಳು ಇಲ್ಲಿ ಗೋಚರಿಸುತ್ತವೆ" + "ಸಂವಾದಗಳನ್ನು ಲೋಡ್‌ ಮಾಡಲಾಗುತ್ತಿದೆ..." + "ಚಿತ್ರ" + "ಆಡಿಯೊ ಕ್ಲಿಪ್" + "ವೀಡಿಯೊ" + "ಸಂಪರ್ಕದ ಕಾರ್ಡ್" + "MMS" + "ರದ್ದುಮಾಡು" + "ಮರುಪ್ರಯತ್ನಿಸು" + "ಹೊಸ ಸಂದೇಶವನ್ನು ಪ್ರಾರಂಭಿಸಲು ಸಂಪರ್ಕ ಹೆಸರು ಅಥವಾ ಫೊನ್ ಸಂಖ್ಯೆಯನ್ನು ನಮೂದಿಸಿ" + "ನಿರ್ಬಂಧಿಸು" + "%s ನಿರ್ಬಂಧಿಸಲಾಗಿದೆ" + "%s ಅನಿರ್ಬಂಧಿಸು" + "%s ನಿರ್ಬಂಧಿಸುವುದೇ?" + "ಈ ಸಂಖ್ಯೆಯಿಂದ ಸಂದೇಶಗಳನ್ನು ಸ್ವೀಕರಿಸಲು ನೀವು ಮುಂದುವರೆಸುತ್ತೀರಿ ಆದರೆ ಇನ್ನು ಮುಂದೆ ಸೂಚನೆ ನೀಡುವುದಿಲ್ಲ. ಈ ಸಂವಾದವನ್ನು ಆರ್ಕೈವ್‌ ಮಾಡಲಾಗುವುದು." + "ನಿರ್ಬಂಧಿಸಲಾದ ಸಂಪರ್ಕಗಳು" + "ಅನಿರ್ಬಂಧಿಸು" + "ನಿರ್ಬಂಧಿಸಲಾದ ಸಂಪರ್ಕಗಳು" + "ಡಾಕ್ಯುಮೆಂಟ್ ಲೈಬ್ರರಿಯಿಂದ ಚಿತ್ರವನ್ನು ಆಯ್ಕೆಮಾಡಿ" + "ಸಂದೇಶ ಕಳುಹಿಸಲಾಗುತ್ತಿದೆ" + "ಸಂದೇಶ ಕಳುಹಿಸಲಾಗಿದೆ" + "ಸೆಲ್ಯುಲಾರ್ ಡೇಟಾ ಆಫ್ ಮಾಡಲಾಗಿದೆ. ನಿಮ್ಮ ಸೆಟ್ಟಿಂಗ್‌ಗಳನ್ನು ಪರೀಕ್ಷಿಸಿ." + "ಸಂದೇಶಗಳನ್ನು ಏರ್‌ಪ್ಲೇನ್ ಮೋಡ್‌ನಲ್ಲಿ ಕಳುಹಿಸಲು ಸಾಧ್ಯವಿಲ್ಲ" + "ಸಂದೇಶ ಕಳುಸಲು ಆಗುತ್ತಿಲ್ಲ" + "ಸಂದೇಶ ಡೌನ್‌ಲೋಡ್ ಮಾಡಲಾಗಿದೆ" + "ಸೆಲ್ಯುಲಾರ್ ಡೇಟಾ ಆಫ್ ಮಾಡಲಾಗಿದೆ. ನಿಮ್ಮ ಸೆಟ್ಟಿಂಗ್‌ಗಳನ್ನು ಪರೀಕ್ಷಿಸಿ." + "ಏರ್‌ಪ್ಲೇನ್ ಮೋಡ್‌ನಲ್ಲಿ ಸಂದೇಶಗಳನ್ನು ಡೌನ್‌ಲೋಡ್ ಮಾಡಲು ಸಾಧ್ಯವಿಲ್ಲ" + "ಸಂದೇಶವನ್ನು ಡೌನ್‌ಲೋಡ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ" + "ಶೂನ್ಯ" + "ಒಂದು" + "ಎರಡು" + "ಮೂರು" + "ನಾಲ್ಕು" + "ಐದು" + "ಆರು" + "ಏಳು" + "ಎಂಟು" + "ಒಂದತ್ತು" + "%1$s ಮೂಲಕ ಸಂದೇಶವನ್ನು ಕಳುಹಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ, ದೋಷ %2$d" + "ಅಜ್ಞಾತ ವಾಹಕದ ಮೂಲಕ ಸಂದೇಶವನ್ನು ಕಳುಹಿಸಲು ಸಾಧ್ಯವಿಲ್ಲ, ದೋಷ %1$d" + "ಫಾರ್ವರ್ಡ್‌: %s" + "ಸಂದೇಶವನ್ನು ಕಳುಹಿಸಲಾಗಲಿಲ್ಲ: ನೆಟ್‌ವರ್ಕ್‌ನಲ್ಲಿ ಸೇವೆಯು ಸಕ್ರಿಯಗೊಂಡಿಲ್ಲ" + "ಸಂದೇಶವನ್ನು ಕಳುಹಿಸಲಾಗಲಿಲ್ಲ: ಅಮಾನ್ಯವಾದ ಗಮ್ಯಸ್ಥಾನ ವಿಳಾಸ" + "ಸಂದೇಶವನ್ನು ಕಳುಹಿಸಲಾಗಲಿಲ್ಲ: ಅಮಾನ್ಯವಾದ ಸಂದೇಶ" + "ಸಂದೇಶವನ್ನು ಕಳುಹಿಸಲಾಗಲಿಲ್ಲ: ಬೆಂಬಲವಿಲ್ಲದ ವಿಷಯ" + "ಸಂದೇಶವನ್ನು ಕಳುಹಿಸಲಾಗಲಿಲ್ಲ: ಬೆಂಬಲವಿಲ್ಲದ ಸಂದೇಶ" + "ಸಂದೇಶವನ್ನು ಕಳುಹಿಸಲಾಗಿಲ್ಲ: ತುಂಬಾ ದೊಡ್ಡದಾಗಿದೆ" + "ಹೊಸ ಸಂದೇಶ" + "ವೀಕ್ಷಿಸಿ" + "ಚಿತ್ರ" + "ಸೂಕ್ತವಾದ ಅಪ್ಲಿಕೇಶನ್ ಕಂಡುಬಂದಿಲ್ಲ" + "ಸ್ವೀಕೃತಿದಾರರನ್ನು ತೆಗೆದುಹಾಕಿ" + "ಹೊಸ ಸಂದೇಶ" + "ರದ್ದುಮಾಡು" + "ಪ್ರವೇಶ ಬಿಂದು ಸಂಪಾದನೆ" + "ಹೊಂದಿಸಿಲ್ಲ" + "ಹೆಸರು" + "APN" + "MMSC" + "MMS ಪ್ರಾಕ್ಸಿ" + "MMS ಪೋರ್ಟ್" + "MCC" + "MNC" + "APN ಪ್ರಕಾರ" + "APN ಅಳಿಸು" + "ಹೊಸ APN" + "ಉಳಿಸು" + "ತಿರಸ್ಕರಿಸು" + "ಹೆಸರಿನ ಕ್ಷೇತ್ರವು ಖಾಲಿ ಇರುವಂತಿಲ್ಲ." + "APN ಖಾಲಿ ಇರುವಂತಿಲ್ಲ." + "MCC ಕ್ಷೇತ್ರವು 3 ಅಂಕಿಗಳಾಗಿರಬೇಕು." + "MNC ಕ್ಷೇತ್ರವು 2 ಅಥವಾ 3 ಅಂಕಿಗಳಾಗಿರಬೇಕು." + "ಡೀಫಾಲ್ಟ್ APN ಸೆಟ್ಟಿಂಗ್‌ಗಳನ್ನು ಮರುಸ್ಥಾಪಿಸಲಾಗುತ್ತಿದೆ." + "ಡೀಫಾಲ್ಟ್‌ಗೆ ಮರುಹೊಂದಿಸು" + "ಡೀಫಾಲ್ಟ್ APN ಸೆಟ್ಟಿಂಗ್‌ಗಳ ಮರುಹೊಂದಿಕೆಯು ಪೂರ್ಣಗೊಂಡಿದೆ." + "ಶೀರ್ಷಿಕೆರಹಿತ" + "ಪ್ರವೇಶಿಸುವಿಕೆ ಕೇಂದ್ರದ ಹೆಸರುಗಳು" + "APN ಗಳು" + "ಹೊಸ APN" + "ಪ್ರವೇಶ ಬಿಂದು ಹೆಸರಿನ ಸೆಟ್ಟಿಂಗ್‌ಗಳು ಈ ಬಳಕೆದಾರರಿಗೆ ಲಭ್ಯವಿಲ್ಲ" + "ಕ್ಲಿಪ್‌ಬೋರ್ಡ್‌ಗೆ ನಕಲಿಸುವುದೇ?" + "ನಕಲಿಸು" + "%s ಗೆ" + "ಸಾಮಾನ್ಯ" + "ಸುಧಾರಿತ" + "ಸಾಮಾನ್ಯ ಸೆಟ್ಟಿಂಗ್‌ಗಳು" + "ಸುಧಾರಿತ ಸೆಟ್ಟಿಂಗ್‌ಗಳು" + "\"%s\" ಸಿಮ್‌" + "ಎಲ್ಲಾ ಸ್ವೀಕೃತದಾರರಿಗೆ ಪ್ರತ್ಯೇಕ SMS ಸಂದೇಶಗಳನ್ನು ಕಳುಹಿಸಿ. ಯಾವುದೇ ಪ್ರತ್ಯುತ್ತರಗಳು ನಿಮಗೆ ಮಾತ್ರ ಬರುತ್ತವೆ" + "ಎಲ್ಲಾ ಸ್ವೀಕೃತಿದಾರರಿಗೆ ಒಂದೇ MMS ಕಳುಹಿಸಿ" + "ಅಜ್ಞಾತ ಸಂಖ್ಯೆ" + "ಹೊಸ ಸಂದೇಶ" + "ಹೊಸ ಸಂದೇಶ." + "ಸಿಮ್ ಆಯ್ಕೆದಾರ" + "%1$s ಆಯ್ಕೆಮಾಡಲಾಗಿದೆ, ಸಿಮ್ ಆಯ್ಕೆ" + "ವಿಷಯವನ್ನು ಸಂಪಾದಿಸಿ" + "ಸಿಮ್ ಆಯ್ಕೆ ಮಾಡಿ ಅಥವಾ ವಿಷಯ ಸಂಪಾದಿಸಿ" + "ಆಡಿಯೋ ಅನ್ನು ರೆಕಾರ್ಡ್ ಮಾಡಲು ಸ್ಪರ್ಶಿಸಿ ಮತ್ತು ಹಿಡಿದುಕೊಳ್ಳಿ" + "ಹೊಸ ಸಂವಾದವನ್ನು ಪ್ರಾರಂಭಿಸಿ" + "ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆ" + "ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆ ಪಟ್ಟಿ" + "ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆ" + "ಹೊಸ ಸಂದೇಶ" + "ಸಂವಾದ ಪಟ್ಟಿ" + "ಸಂವಾದಗಳನ್ನು ಲೋಡ್‌ ಮಾಡಲಾಗುತ್ತಿದೆ" + "ಸಂದೇಶಗಳನ್ನು ಲೋಡ್ ಮಾಡಲಾಗುತ್ತಿದೆ" + "ಇನ್ನಷ್ಟು ಸಂವಾದಗಳನ್ನು ವೀಕ್ಷಿಸಿ" + "ಹೆಚ್ಚಿನ ಸಂದೇಶಗಳನ್ನು ವೀಕ್ಷಿಸಿ" + "ಸಂವಾದವನ್ನು ಅಳಿಸಲಾಗಿದೆ" + "ಸಂಭಾಷಣೆಯನ್ನು ಅಳಿಸಲಾಗಿದೆ. ಬೇರೆ ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆ ಸಂಭಾಷಣೆಯನ್ನು ತೋರಿಸಲು ಸ್ಪರ್ಶಿಸಿ" + "ನಿರ್ಬಂಧಿಸಲಾಗಿದೆ" + "ಅನಿರ್ಬಂಧಿಸಲಾಗಿದೆ" + "ಸಂಗ್ರಹಣೆ ಸ್ಥಳಾವಕಾಶವು ಕಡಿಮೆ ಇದೆ. ಕೆಲವು ಡೇಟಾ ನಷ್ಟವಾಗಬಹುದು." + "ಲಗತ್ತುಗಳನ್ನು ಆಯ್ಕೆಮಾಡಿ" + "ಆಯ್ಕೆ ಖಚಿತಪಡಿಸಿ" + "%d ಆಯ್ಕೆಮಾಡಲಾಗಿದೆ" + "ದಯವಿಟ್ಟು ಒಂದು ಅಥವಾ ಒಂದಕ್ಕಿಂತ ಹೆಚ್ಚಿನ ಲಗತ್ತುಗಳನ್ನು ತೆಗೆದುಹಾಕಿ ಮತ್ತು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ." + "ನಿಮ್ಮ ಸಂದೇಶವನ್ನು ಕಳುಹಿಸಲು ನೀವು ಪ್ರಯತ್ನಿಸಬಹುದು, ಆದರೆ ನೀವು ಒಂದು ಅಥವಾ ಹೆಚ್ಚಿನ ಲಗತ್ತುಗಳನ್ನು ತೆಗೆದುಹಾಕದ ಹೊರತು ಇದು ತಲುಪದಿರಬಹುದು." + "ನೀವು ಪ್ರತಿ ಸಂದೇಶಕ್ಕೆ ಒಂದು ವೀಡಿಯೊ ಮಾತ್ರ ಕಳುಹಿಸಬಹುದು. ದಯವಿಟ್ಟು ಹೆಚ್ಚುವರಿ ವೀಡಿಯೊಗಳನ್ನು ತೆಗೆದುಹಾಕಿ ಹಾಗೂ ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ." + "ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆಯು ಲಗತ್ತನ್ನು ಲೋಡ್ ಮಾಡಲು ವಿಫಲವಾಗಿದೆ." + "ಹೇಗಾದರೂ ಕಳುಹಿಸು" + "ಸಂವಾದವನ್ನು ಪ್ರಾರಂಭಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ" + "%1$s (%2$s)" + "%s ಆಯ್ಕೆಮಾಡಲಾಗಿದೆ" + diff --git a/res/values-ko/arrays.xml b/res/values-ko/arrays.xml new file mode 100644 index 0000000..876776b --- /dev/null +++ b/res/values-ko/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "제목 없음" + "nosubject" + + + "예" + "아니요" + "알겠습니다" + "하하" + "감사합니다." + "동의합니다" + "좋습니다." + "가는 중입니다" + "나중에 연락드리겠습니다" + ":)" + ":(" + + diff --git a/res/values-ko/strings.xml b/res/values-ko/strings.xml new file mode 100644 index 0000000..79949ea --- /dev/null +++ b/res/values-ko/strings.xml @@ -0,0 +1,519 @@ + + + + + "메시지" + "메시지" + "대화 선택" + "설정" + "메시지 보내기" + "첨부파일 추가" + "도움말" + "환영합니다." + "건너뛰기" + "다음 >" + "다음" + "종료" + "설정 >" + "설정" + "메시지를 사용하려면 SMS, 전화, 주소록에 대한 권한이 필요합니다." + "설정 > 앱 > 메시지 > 권한에서 권한을 변경할 수 있습니다." + "설정, 앱, 메시지, 권한에서 권한을 변경할 수 있습니다." + "자주 연락하는 사람" + "모든 연락처" + "받는사람: %s" + "사진 또는 동영상 캡처" + "이 기기에서 이미지 선택" + "오디오 녹음" + "사진 선택" + "미디어가 선택되었습니다." + "미디어가 선택 취소되었습니다." + "%d개 선택됨" + "이미지 - %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "이미지" + "오디오 녹음" + "공유" + "방금 전" + "지금" + + %d + %d + + + %d시간 + %d시간 + + + %d + %d + + + %d + 1주 + + + %d개월 + 1개월 + + + %d + 1년 + + "클래스 0 메시지" + "저장" + "기기의 저장공간이 부족합니다. 메시지에서 공간을 확보하기 위해 오래된 메시지를 삭제합니다." + "저장 공간이 부족함" + "기기 공간이 더 확보되어야 메시지 앱에서 메시지를 보내고 받을 수 있습니다." + "SMS 저장공간이 부족합니다. 메시지를 삭제해야 합니다." + "전화번호 확인" + "이 단계를 한 번만 실행하면 메시지 앱에서 그룹 메시지를 정확히 전달할 수 있습니다." + "전화번호" + "미디어가 포함된 모든 메시지 삭제" + "%s보다 오래된 메시지 삭제" + "%s 이상 된 메시지 자동 삭제" + "무시" + "미디어가 포함된 모든 메시지를 삭제하시겠습니까?" + "%s 이상 된 메시지를 삭제하시겠습니까?" + "%s 이상 된 메시지를 삭제하고 자동 삭제를 사용하시겠습니까?" + "%s님이 보낸 내용" + "보낸 내용" + "%s님이 보낸 메시지" + "메시지 전송됨" + "보내는 중..." + "전송되지 않았습니다. 다시 시도하려면 터치하세요." + "전송되지 않았습니다. 다시 시도하는 중…" + "다시 보내기 또는 삭제" + "음성 통화로 응급 서비스에 연락하시기 바랍니다. 현재 SMS를 보낼 수 없습니다." + "실패" + "다운로드할 새 MMS 메시지" + "새 MMS 메시지" + "다운로드하지 못했습니다." + "다시 시도하려면 터치" + "터치하여 다운로드" + "다운로드 또는 삭제" + "다운로드 중..." + "메시지가 만료되었거나 사용할 수 없음" + "크기: %1$s, 만료: %2$s" + "보낼 수 없습니다. 수신자가 올바르지 않습니다." + "네트워크에 서비스가 활성화되어 있지 않습니다." + "네트워크 문제로 인해 전송하지 못했습니다." + "메시지가 만료되었거나 사용할 수 없음" + "(제목 없음)" + "알 수 없는 발신자" + "전송 완료" + "%2$s님이 보낸 메시지(%1$s)를 다운로드할 수 없습니다." + "메모리가 부족하여 데이터베이스 작업을 완료할 수 없습니다." + "메시지를 보내지 못함" + "일부 메시지가 전송되지 않았습니다." + + 대화 %d개에 메시지 %d + 대화 1개에 메시지 %d + + "메시지를 다운로드하지 못했습니다." + "일부 메시지가 다운로드되지 않았습니다." + + 대화 %d개에 메시지 %d + 대화 1개에 메시지 %d + + "%1$s에 메시지를 보내지 못했습니다." + "음성 통화로 응급 서비스에 연락하시기 바랍니다. 현재 %1$s번으로 SMS를 보낼 수 없습니다." + + 새 메시지 %d + 새 메시지 1개 + + "시작" + "카메라를 사용할 수 없습니다." + "카메라를 사용할 수 없습니다." + "동영상을 캡처할 수 없습니다." + "미디어를 저장할 수 없습니다." + "사진을 찍을 수 없음" + "뒤로" + "보관처리됨" + "삭제" + "보관처리" + "보관 취소" + "알림 사용 중지" + "알림 사용" + "연락처 추가" + "다운로드" + "전송" + "삭제" + "이 메일을 삭제하시겠습니까?" + "이 작업은 취소할 수 없습니다." + "삭제" + + 대화를 삭제하시겠습니까? + 대화를 삭제하시겠습니까? + + "삭제" + "취소" + "받는사람" + "여러 이미지 선택" + "선택항목 확인" + "+%d" + "오디오를 녹음할 수 없습니다. 다시 시도해 주세요." + "오디오를 재생할 수 없습니다. 다시 시도해 주세요." + "오디오를 저장할 수 없습니다. 다시 시도해 주세요." + "길게 터치" + ", " + " " + ": " + " " + "사진" + "오디오 클립" + "동영상" + "연락처 카드" + "다운로드" + "SMS를 통해 답장" + "MMS로 답장" + "답장" + + 참여자 %d + 참여자 %d + + "나" + "연락처가 차단 및 보관되었습니다." + "연락처 차단 해제 및 보관 취소됨" + "%d개 보관됨" + "%d개 보관 취소됨" + "알림 사용 중지됨" + "알림 사용 설정됨" + "준비가 완료되었습니다. 전송을 다시 터치해 주세요." + "메시지가 기본 SMS 앱으로 설정되었습니다." + + 첨부파일 삭제 + 첨부파일 삭제 + + "오디오 첨부파일" + "오디오 첨부파일 재생" + "일시중지" + "%s님의 메시지입니다. %s." + "받지 못한 %s님의 메시지: %s. 시간: %s." + "%s님의 메시지: %s. 시간: %s." + "%s님에게 전송되지 않은 메시지: %s. 시간: %s." + "%s님에게 전송 중인 메시지: %s. 시간: %s." + "%s님에게 전송하지 못한 메시지: %s. 시간: %s." + "%s님에게 보내는 메시지: %s. 시간: %s." + "받지 못한 %s님의 메시지: %s. 시간: %s. %s." + "%s님의 메시지: %s. 시간: %s. %s." + "%s 그룹에 전송되지 않은 메시지: %s. 시간: %s." + "%s 그룹에 전송 중인 메시지: %s. 시간: %s." + "%s 그룹에 전송하지 못한 메시지: %s. 시간: %s." + "%s 그룹에 보내는 메시지: %s. 시간: %s." + "메시지를 전달하지 못했습니다. 다시 시도하려면 터치하세요." + "%s님과 대화" + "제목 삭제" + "동영상 촬영" + "정지 이미지 캡처" + "사진 촬영" + "동영상 녹화 시작" + "전체화면 카메라로 전환" + "전면 및 후면 카메라 전환" + "녹화 중지 및 비디오 첨부" + "동영상 녹화 중지" + "메시지 사진" + + 사진 %d장이 \"%s\" 앨범에 저장됨 + 사진 %d장이 \"%s\" 앨범에 저장됨 + + + 동영상 %d개가 \"%s\" 앨범에 저장됨 + 동영상 %d개가 \"%s\" 앨범에 저장됨 + + + 첨부파일 %d개가 \"%s\" 앨범에 저장됨 + 첨부파일 %d개가 \"%s\" 앨범에 저장됨 + + + 첨부파일 %d개가 \"다운로드\"에 저장됨 + 첨부파일 %d개가 \"다운로드\"에 저장됨 + + + 첨부파일 %d개가 첨부되었습니다. + 첨부파일 %d개가 저장되었습니다. + + + 첨부파일 %d개를 저장하지 못했습니다. + 첨부파일 %d개를 저장하지 못했습니다. + + "저장된 MMS 첨부파일" + "설정" + "보관처리됨" + "닫기" + "MMS" + "고급" + "디버그" + "알림" + "소리" + "무음" + "진동" + "차단됨" + "SMS 전송 보고" + "모든 SMS에 대해 전송 확인 요청" + "자동 검색" + "MMS 메시지를 자동으로 수신" + "로밍 중 자동 수신" + "로밍 시 MMS 자동 검색" + "그룹 메시지" + "수신자가 여러 명일 때 MMS를 사용하여 한 개의 메시지를 전송합니다." + "기본 SMS 앱" + "기본 SMS 앱" + + + "전화번호" + "알 수 없음" + "발신 메시지 소리" + "SMS 덤프" + "수신된 SMS 원시 데이터를 외부 저장소 파일에 덤프합니다." + "MMS 덤프" + "수신된 MSS 원시 데이터를 외부 저장소 파일에 덤프합니다." + "긴급 무선 경보" + "메시지 옵션" + "텍스트 복사" + "세부정보 보기" + "삭제" + "전달" + "메시지 세부정보" + "유형: " + "문자 메시지" + "멀티미디어 메시지" + "보낸사람: " + "받는사람: " + "보낸 시간: " + "받은 시간: " + "제목: " + "크기: " + "중요도: " + "SIM: " + "높음" + "보통" + "낮음" + "SIM %s" + "숨긴 발신자 주소" + "파일을 로드하는 동안 메시지를 보낼 수 없습니다." + "첨부파일을 로드할 수 없습니다. 다시 시도해 주세요." + "네트워크가 준비되지 않았습니다. 다시 시도해 주세요." + "텍스트 삭제" + "텍스트 및 숫자 입력 간 전환" + "더 많은 참가자 추가" + "참가자 확인" + "새로운 대화 시작" + "이 항목을 선택합니다." + "Play 동영상" + "사용자 및 옵션" + "디버그" + "사용자 및 옵션" + "일반" + "이 대화에 참여하는 사용자" + "전화 걸기" + "메시지 보내기" + "<br/><small>%s</small>에서 메시지 보내기" + + 사진 전송 + 사진 전송 + + + 오디오 전송 + 오디오 전송 + + + 동영상 전송 + 동영상 전송 + + + 연락처 카드 전송 + 연락처 카드 전송 + + + 첨부파일 전송 + 첨부파일 전송 + + + 첨부파일 %d개 전송 준비 완료 + 첨부파일 1개 전송 준비 완료 + + "의견 보내기" + "Google Play 스토어에서 보기" + "버전 정보" + "버전: %1$s" + "오픈소스 라이선스" + "알림" + "첨부파일 한도 도달" + "첨부파일을 로드하지 못했습니다." + "주소록에 추가하시겠습니까?" + "연락처 추가" + "제목" + "제목: " + "%s%s" + "연락처 카드 로드 중" + "연락처 카드를 로드할 수 없습니다." + "연락처 카드 보기" + + 연락처 %d + 연락처 %d + + "연락처 카드" + "생일" + "메모" + "메시지 전달" + "답장" + "외 1명" + "외 %d명" + "SMS 사용 중지됨" + "메시지를 전송하려면 메시지를 기본 SMS 앱으로 설정하세요." + "메시지를 기본 SMS 앱으로 설정하세요." + "변경" + "메시지를 수신하려면 메시지를 기본 SMS 앱으로 설정하세요." + "SMS 메시지 보내기에 사용할 기본 앱이 선택되어 있지 않습니다." + "기기 소유자가 허용하지 않은 앱입니다." + "확인" + "대화 참가자가 너무 많습니다." + + 잘못된 연락처 + 잘못된 연락처 + + "카메라 이미지를 로드할 수 없습니다." + "나: " + "%s: " + "임시보관" + "새 대화를 시작하면 여기에 표시됩니다." + "보관처리된 대화는 여기에 표시됩니다." + "대화 로드 중..." + "사진" + "오디오 클립" + "동영상" + "연락처 카드" + "MMS" + "실행취소" + "다시 시도" + "새로운 메시지를 시작하려면 연락처 이름 또는 전화번호를 입력하세요." + "차단" + "%s 차단" + "%s 차단 해제" + "%s을(를) 차단하시겠습니까?" + "앞으로 이 번호에서 보낸 메시지를 계속 받지만 알림은 더 이상 받지 않습니다. 이 대화는 보관처리됩니다." + "차단된 연락처" + "차단 해제" + "차단된 연락처" + "문서 라이브러리에서 이미지 선택" + "메시지를 전송하는 중입니다." + "메시지가 전송되었습니다." + "이동통신 데이터 사용이 중지되어 있습니다. 설정을 확인하세요." + "비행기 모드에서는 메시지를 보낼 수 없습니다." + "메시지를 보내지 못했습니다." + "메시지가 다운로드되었습니다." + "이동통신 데이터 사용이 중지되어 있습니다. 설정을 확인하세요." + "비행기 모드에서 메시지를 다운로드할 수 없습니다." + "메시지를 다운로드할 수 없습니다." + "0" + "1" + "2" + "3" + "4" + "5" + "6" + "7" + "8" + "9" + "%1$s을(를) 사용하여 메시지를 보낼 수 없습니다. 오류: %2$d" + "알 수 없는 이동통신사를 사용하여 메시지를 보낼 수 없습니다. 오류: %1$d" + "전달: %s" + "서비스가 네트워크에서 활성화되지 않아 메시지가 전송되지 않았습니다." + "메시지가 전송되지 않음: 잘못된 수신 주소" + "메시지가 전송되지 않음: 잘못된 메시지" + "메시지가 전송되지 않음: 지원되지 않는 콘텐츠" + "메시지가 전송되지 않음: 지원되지 않는 메시지" + "메시지가 전송되지 않음: 용량 초과" + "새 메시지" + "보기" + "이미지" + "관련 애플리케이션을 찾을 수 없습니다." + "수신자 삭제" + "새 메시지" + "취소" + "액세스포인트 네임(APN) 수정" + "설정되지 않음" + "이름" + "APN" + "MMSC" + "MMS 프록시" + "MMS 포트" + "MCC" + "MNC" + "APN 유형" + "APN 삭제" + "새 APN" + "저장" + "취소" + "이름 입력란은 비워둘 수 없습니다." + "APN을 비워둘 수 없습니다." + "MCC 입력란에는 3자리 숫자를 입력해야 합니다." + "MNC 입력란에는 2~3자리 숫자를 입력해야 합니다." + "기본 APN 설정 복원 중" + "기본값으로 재설정" + "기본 APN 설정을 초기화했습니다." + "제목 없음" + "APN" + "APN" + "새 APN" + "이 사용자는 액세스포인트 네임(APN) 설정을 수정할 수 없습니다." + "클립보드에 복사할까요?" + "복사" + "전송 대상: %s" + "일반" + "고급" + "일반 설정" + "고급 설정" + "\'%s\' SIM" + "모든 수신자에게 개별 SMS 보내기(나만 답장을 받을 수 있음)" + "모든 수신자에게 하나의 MMS 보내기" + "알 수 없는 번호" + "새 메시지" + "새 메시지" + "SIM 선택기" + "%1$s 선택됨. SIM 선택기" + "제목 수정" + "SIM 선택 또는 제목 수정" + "길게 눌러 오디오 녹음" + "새로운 대화 시작" + "메시지" + "메시지 목록" + "메시지" + "새 메시지" + "대화 목록" + "대화 로드 중" + "메시지 로드 중" + "대화 더보기" + "메시지 더보기" + "대화를 삭제함" + "대화가 삭제되었습니다. 다른 메시지 대화를 표시하려면 터치하세요." + "차단됨" + "차단 해제됨" + "저장공간이 충분하지 않아 일부 데이터가 저장되지 않을 수 있습니다." + "첨부파일 선택" + "선택항목 확인" + "%d개 선택됨" + "하나 이상의 첨부파일을 삭제하고 다시 시도하세요." + "메시지 보내기를 시도할 수는 있지만 하나 이상의 첨부파일을 삭제하지 않으면 전송되지 않을 수 있습니다." + "메시지당 하나의 동영상만 보낼 수 있습니다. 추가 동영상을 삭제하고 다시 시도하세요." + "메시지에서 첨부파일이 로드되지 않았습니다." + "무시하고 보내기" + "대화를 시작할 수 없습니다." + "%1$s(%2$s)" + "%s 선택됨" + diff --git a/res/values-ky-rKG/arrays.xml b/res/values-ky-rKG/arrays.xml new file mode 100644 index 0000000..0227144 --- /dev/null +++ b/res/values-ky-rKG/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "темасы жок" + "темасыжок" + + + "Ооба" + "Жок" + "OK" + "Хе-хе" + "Рахмат" + "Туура" + "Сонун" + "Жолдомун" + "Макул, бир аздан кийин кайрылайынчы" + ":)" + ":(" + + diff --git a/res/values-ky-rKG/strings.xml b/res/values-ky-rKG/strings.xml new file mode 100644 index 0000000..b53ca0c --- /dev/null +++ b/res/values-ky-rKG/strings.xml @@ -0,0 +1,519 @@ + + + + + "SMS/MMS" + "SMS/MMS" + "Маек тандаңыз" + "Жөндөөлөр" + "Билдирүү жөнөтүү" + "Тиркеме кошуңуз" + "Жардам" + "Кош келиңиз" + "Өткөрүп жиберүү" + "Кийинки >" + "Кийинки" + "Чыгуу" + "Жөндөөлөр >" + "Жөндөөлөр" + "SMS/MMS\'ке SMS, телефон жана байланыштар уруксаты керек." + "Уруксаттарды өзгөртүү үчүн Жөндөөлөр > Колдонмолор > SMS/MMS > уруксаттарды басыңыз." + "Уруксаттарды өзгөртүү үчүн Жөндөөлөр, Колдонмолор, SMS/MMS, уруксаттарды басыңыз." + "Көп байланышкандар" + "Бардык байланыштар" + "Төмөнкүгө жөнөтүү %s" + "Сүрөттөрдү же видео тартып алуу" + "Бул түзмөктөн сүрөттөрдү тандаңыз" + "Аудио жаздыруу" + "Сүрөт тандаңыз" + "Медиа файл тандалды." + "Медиа файл тандоодон чыгарылды." + "%d тандалды" + "сүрөт %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "сүрөт" + "Аудио жаздыруу" + "Бөлүшүү" + "Жаңы эле" + "Азыр" + + %d мүн + %d мүн + + + %d саат + %d саат + + + %d күн + %d күн + + + %d апта + бир апта + + + %d ай + бир ай + + + %d жыл + бир жыл + + "0 классындагы билдирүү" + "Сактоо" + "Түзмөктө бош орун жетишпейт. Орун бошотуу үчүн, SMS/MMS эски SMS билдирүүлөрүн автоматтык түрдө жок кылат." + "Сактагычта орун калбай баратат" + "Түзмөгүңүздө көбүрөөк бош орун болмоюнча, SMS/MMS билдирүүлөрдү жөнөтүп же ала албашы мүмкүн." + "SMS үчүн орун жетишпейт. Билдирүүлөрдү жок кылышыңыз керек окшойт." + "Телефон номериңизди ырастаңыз" + "Номер топтошкон SMS/MMS билдирүүлөрүн жөнөтүү үчүн талап кылынат. Аны бир гана жолу киргизүү керек." + "Телефон номери" + "Медиа файл камтылган бардык билдирүүлөрдү жок кылуу" + "%s ашкан билдирүүлөрдү жок кылуу" + "%s мурунку билдирүүлөр автоматтык түрдө жок кылынсын" + "Көңүл бурбоо" + "Медиа менен бардык файлдар жок кылынсынбы?" + "%s мурунку файлдар жок кылынсынбы?" + "%s мурунку билдирүүлөр жок кылынып, авто жок кылуу күйгүзүлсүнбү?" + "%s айтты" + "Сиз айттыңыз" + "%s дегенден билдирүү" + "Билдирүү жөнөттүңүз" + "Жөнөтүлүүдө…" + "Жөнөтүлгөн жок. Дагы аракет кылуу үчүн тийип коюңуз." + "Жөнөтүлгөн жок. Кайра аракет жасалууда…" + "Кайра жөнөтүү же жок кылуу" + "Өзгөчө кырдаал кызматтарына үн чалуу жасаңыз. Азыркы учурда текст билдирүүңүз жеткирилбей койду." + "Болбой калды" + "Жүктөлүп алынуучу жаңы MMS билдирүү" + "Жаңы MMS билдирүү" + "Жүктөлүп алынган жок" + "Дагы аракет кылуу үчүн тийип коюңуз" + "Жүктөп алуу үчүн тийип коюңуз" + "Жүктөп алуу же жок кылуу" + "Жүктөлүүдө…" + "Билдирүү мөөнөтү аяктады же жеткиликтүү эмес" + "өлчөмү: %1$s, мөөнөтү бүтөт: %2$s" + "Жөнөтүлбөй жатат. Алуучу жарактуу эмес." + "Тармакта кызмат жандырылган эмес" + "Тармактагы көйгөйдөн улам жөнөтүлгөн жок" + "Билдирүү мөөнөтү аяктады же жеткиликтүү эмес" + "(Темасы жок)" + "Белгисиз жөнөтүүчү" + "Жеткирилди" + "%2$s келген %1$s билдирүүсү жүктөлүп алынган жок." + "Сактагычта орун аз болгондуктан, дайындар базасы аяктабай калды" + "Билдирүү жөнөтүлгөн жок" + "Айрым SMS/MMS билдирүүлөрү жөнөтүлгөн жок" + + %d баарлашууда %d билдирүү + Бир баарлашууда %d билдирүү + + "Билдирүү жүктөлүп алынган жок" + "Айрым SMS/MMS билдирүүлөрү жүктөлүп алынган жок" + + %d баарлашууда %d билдирүү + Бир баарлашууда %d билдирүү + + "%1$s дегенге билдирүү жөнөтүлгөн жок" + "Өзгөчө кырдаал кызматтарына үн чалуу жасаңыз. Азыркы учурда %1$s номерине текст билдирүүңүз жеткирилбей койду." + + %d жаңы билдирүү + Жаңы билдирүү + + "Баштоо" + "Камера жеткиликтүү эмес" + "Камера жеткиликтүү эмес" + "Видеону тартып алуу жеткиликтүү эмес" + "Медиа сакталбай жатат" + "Сүрөткө тарта албайт" + "Артка" + "Архивделди" + "Жок кылуу" + "Архивдөө" + "Архивден чыгаруу" + "Эскертмелерди өчүрүү" + "Эскертмелерди күйгүзүү" + "Байланыш кошуу" + "Жүктөп алуу" + "Жөнөтүү" + "Жок кылуу" + "Бул билдирүү жок кылынсынбы?" + "Бул аракетти артка кайтаруу мүмкүн эмес." + "Жок кылуу" + + Бул баарлашуулөр жок кылынсынбы? + Бул баарлашуу жок кылынсынбы? + + "Жок кылуу" + "Жокко чыгаруу" + "Кимге" + "Бир нече сүрөт тандаңыз" + "Тандалгандарды ырастаңыз" + "+%d" + "Аудио жазылбай жатат. Дагы аракет кылыңыз." + "Аудио ойнотулбай жатат. Дагы аракет кылыңыз." + "Аудио сакталган жок. Дагы аракет кылыңыз." + "Коё бербей amp; тийип туруу" + ", " + " " + ": " + " " + "Сүрөт" + "Аудио клип" + "Видео" + "Байланыш картасы" + "Жүктөп алуу" + "SMS аркылуу жооп берүү" + "MMS аркылуу жооп берүү" + "Жооп берүү" + + %d катышуучу + %d катышуучу + + "Мен" + "Байланыш бөгөттөлдү & архивделди" + "Байланыш бөгөттөн жана архивден чыгарылды" + "%d архивделди" + "%d архивден чыгарылды" + "Эскертмелер өчүрүлгөн" + "Эскертмелер күйгүзүлгөн" + "Баары коюлду. Жөнөтүүнү кайра басыңыз." + "SMS/MMS демейки SMS колдонмосу катары коюлду." + + Тиркемелерди жарактан чыгаруу + Тиркемени жарактан чыгаруу + + "Аудио тиркеме" + "Аудио тиркемени ойнотуу" + "Бир азга токтотуу" + "%s жөнөткөн билдирүү: %s." + "%s: %s билдирүүсү бузулду. Убакыт: %s" + "%s: %s билдирүүсү. Убакыт: %s" + "Төмөнкүгө жиберилбеген билдирү %s: %s. Убакыт: %s" + "Төмөнкүгө билдирүү жөнөтүлүүдө: %s: %s. Убакыт: %s" + "%s: %s билдирүүсү бузулду. Убакыт: %s" + "%s: %s билдирүүсү. Убакыт: %s" + "%s: %s билдирүүсү бузулду. Убакыт: %s. %s." + "%s: %s билдирүүсү. Убакыт: %s. %s." + "Төмөнкүгө жиберилбеген билдирүү: %s: %s. Убакыт: %s" + "Төмөнкүгө билдирүү жөнөтүлүүдө: %s: %s. Убакыт: %s" + "%s: %s билдирүүсү бузулду. Убакыт: %s" + "%s: %s билдирүүсү. Убакыт: %s" + "Билдирүү жөнөтүлбөй койду. Кайра аракет кылуу үчүн тийип коюңуз." + "%s менен сүйлөшүү" + "Темасын жок кылуу" + "Видео жаздыруу" + "Кыймылдабаган сүрөттү тартып алуу" + "Сүрөт тартуу" + "Видео тартып баштоо" + "Камераны толук экранга которуу" + "Алдыңкы жана арткы камераны которуштуруу" + "Тартылып жаткан видеону токтотуп, тиркеңиз" + "Видео жаздырууну токтотуу" + "SMS/MMS сүрөттөрү" + + %d сүрөт \"%s\" альбомуна сакталды + %d сүрөт \"%s\" альбомуна сакталды + + + %d видео \"%s\" альбомуна сакталды + %d видео \"%s\" альбомуна сакталды + + + %d тиркеме \"%s\" альбомуна сакталды + %d тиркеме \"%s\" альбомуна сакталды + + + %d тиркеме \"Жүктөлүп алынгандарга\" сакталды + %d тиркеме \"Жүктөлүп алынгандарга\" сакталды + + + %d тиркеме сакталды + %d тиркеме сакталды + + + %d тиркеме сакталбай калды + %d тиркеме сакталбай калды + + "Сакталган MMS тиркемеси" + "Жөндөөлөр" + "Архивделди" + "Жабуу" + "MMS" + "Өркүндөтүлгөн" + "Мүчүлүштүктөрдү таап оңдоо" + "Эскертмелер" + "Үн" + "Үнсүз" + "Дирилдөө" + "Бөгөттөлгөн" + "SMS жеткирүү кабарлары" + "Ар бир жөнөтүлгөн SMS билдирүүсүнүн алынгандыгын кабарлап коюну өтүнүү" + "Авто-түшүрүү" + "MMS билдирүүлөрүн автоматтык түрдө түшүрүп алуу" + "Роумингде авто-түшүрүп алуу" + "Роуминг учурунда MMS\'ти автоматтык түрдө чыгарып алуу" + "Топтошуп билдирүү жазышуу" + "Бир нече алуучуга бир билдирүү жөнөтүү үчүн MMS\'ти колдонуңуз" + "Демейки SMS колдонмо" + "Демейки SMS колдонмо" + + + "Телефон номериңиз" + "Белгисиз" + "Билдирүү кеткенде добуш чыксын" + "SMS\'ти ыргытуу" + "Алынган SMS\'тин чийки дайындарын тышкы сактагычтагы файлга ыргытыңыз" + "MMS\'ти ыргытуу" + "Алынган MMS\'тин чийки дайындарын тышкы сактагычтагы файлга ыргытыңыз" + "Зымсыз айгай кабарлар" + "Билдирүүнүн параметрлери" + "Текстти көчүрүү" + "Дайындарды карап көрүү" + "Жок кылуу" + "Багыттоо" + "Билдирүүнүн чоо-жайы" + "Түрү: " + "SMS билдирүүсү" + "Мультимедиа билдирүүсү" + "Кимден: " + "Кимге: " + "Жөнөтүлдү: " + "Алынды: " + "Темасы: " + "Өлчөмү: " + "Шашылыштыгы: " + "SIM: " + "Жогору" + "Орточо" + "Төмөн" + "SIM %s" + "Дареги жашырылган жөнөтүүчү" + "Тиркемелер жүктөлүп жатканда билдирүү жөнөтүлбөйт." + "Тиркеме жүктөлбөй жатат. Дагы аракет кылыңыз." + "Тармак даяр эмес. Дагы аракет кылыңыз." + "Текстти жок кылуу" + "Текст жана сандарды киргизүү ыкмаларын которуштуруу" + "Дагы катышуучуларды кошуу" + "Катышуучуларды ырастоо" + "Жаңы сүйлөшүү баштоо" + "Бул нерсени тандоо" + "Видео ойнотуу" + "Адамдар & параметрлер" + "Мүчүлүштүктөрдү таап оңдоо" + "Адамдар & параметрлер" + "Жалпы" + "Бул маектеги адамдар" + "Чалуу" + "Билдирүү жөнөтүү" + "<br/><small> %s</small> аркылуу билдирүү жөнөтүү" + + Сүрөттөрдү жөнөтүү + Сүрөт жөнөтүү + + + Аудиолорду жөнөтүү + Аудио жөнөтүү + + + Видеолорду жөнөтүү + Видео жөнөтүү + + + Байланыш карталарын жөнөтүү + Байланыш картасын жөнөтүү + + + Тиркемелерди жөнөтүү + Тиркемени жөнөтүү + + + %d тиркеме жөнөтүүгө даяр + Бир тиркеме жөнөтүүгө даяр + + "Жооп пикир жөнөтүү" + "Google Play Дүкөнүндө карап көрүү" + "Версия маалыматы" + "%1$s версиясы" + "Ачык булак лицензиялары" + "Эскертмелер" + "Тиркеме чегине жетти" + "Тиркеме жүктөлбөй койду." + "Байланыштарга кошулсунбу?" + "Байланыш кошуу" + "Тема" + "Темасы: " + "%s%s" + "Байланыш картасы жүктөлүүдө" + "Байланыштын картасы жүктөлгөн жок" + "Байланыш картасын карап көрүү" + + %d байланыш + %d байланыш + + "Байланыш карталары" + "Туулган күн" + "Эскертүүлөр" + "Билдирүүнү багыттоо" + "Жооп берүү" + "+1" + "+%d" + "SMS өчүк" + "Билдирүүлөрдү жөнөтүү үчүн, SMS/MMS\'ти демейки SMS колдонмосу катары коюңуз" + "SMS/MMS\'ти демейки SMS колдонмосу катары коюңуз" + "Өзгөртүү" + "Билдирүүлөрдү алуу үчүн, SMS/MMS\'ти демейки SMS колдонмосу катары коюңуз" + "SMS билдирүүлөрдү жөнөтүү үчүн артыкчылыктуу SIM тандалган эмес" + "Бул колдонмого түзмөк ээсинен уруксат жок." + "OK" + "Маекте катышуучулар өтө эле көп" + + Жараксыз байланыштар + Жараксыз байланыш + + "Камера сүрөтү жүктөлгөн жок" + "Сиз: " + "%s: " + "Сомо" + "Жаңы маек башталганда, аны бул жердеги тизмеден көрөсүз" + "Архивделген маектер бул жерден көрүнөт" + "Маектер жүктөлүүдө…" + "Сүрөт" + "Аудио клип" + "Видео" + "Байланыш картасы" + "MMS" + "Кайтаруу" + "Дагы аракет кылуу" + "Жаңы билдирүүнү жазып баштоо үчүн байланыштын атын же телефон номерин киргизиңиз" + "Бөгөттөө" + "%s дегенди бөгөттөө" + "%s дегенди бөгөттөн чыгаруу" + "%s бөгөттөлсүнбү?" + "Бул номерден билдирүүлөрдү ала бересиз, бирок мындан ары эскертмелерди албай каласыз. Бул маек архивделет." + "Бөгөттөлгөн байланыштар" + "БӨГӨТТӨН ЧЫГАРУУ" + "Бөгөттөлгөн байланыштар" + "Документтер китепканасынан сүрөт тандаңыз" + "Билдирүү жөнөтүлүүдө…" + "Билдирүү жөнөтүлдү" + "Уюлдук дайындар өчүрүлгөн. Жөндөөлөрүңүздү текшериңиз." + "Учак режиминде билдирүүлөр жөнөтүлбөйт" + "Билдирүү жөнөтүлбөй жатат" + "Билдирүү жүктөлүп алынды" + "Уюлдук дайындар өчүрүлгөн. Жөндөөлөрүңүздү текшериңиз." + "Учак режиминде билдирүүлөрдү жүктөп ала албайт" + "Билдирүү жүктөлүп алынбай койду" + "Ноль" + "Бир" + "Эки" + "Үч" + "Төрт" + "Беш" + "Алты" + "Жети" + "Сегиз" + "Тогуз" + "Билдирүүнү %1$s оператору аркылуу жөнөтүү мүмкүн эмес, ката %2$d" + "Билдирүүнү белгисиз түзмөк аркылуу жөнөтүү мүмкүн эмес, ката %1$d" + "Багыттоо: %s" + "Билдирүү жөнөтүлгөн жок: кызмат тармакта жандырылган эмес" + "Билдирүү жөнөтүлгөн жок: жеткирүү дареги жараксыз" + "Билдирүү жөнөтүлгөн жок: билдирүү жараксыз" + "Билдирүү жөнөтүлгөн жок: мазмун колдоого алынбайт" + "Билдирүү жөнөтүлгөн жок: билдирүү колдоого алынбайт" + "Билдирүү жөнөтүлгөн жок: өтө чоң" + "Жаңы билдирүү" + "Карап көрүү" + "Сүрөт" + "Ылайыктуу колдонмо табылган жок" + "Алуучуну чыгарып салуу" + "Жаңы билдирүү" + "Жокко чыгаруу" + "Мүмкүнчүлүк алуу түйүнүн түзөтүү" + "Коюлган эмес" + "Ысымы" + "APN" + "MMSC" + "MMS прокси" + "MMS порту" + "MCC" + "MNC" + "APN түрү" + "APN жок кылуу" + "Жаңы APN" + "Сактоо" + "Жарактан чыгаруу" + "Аталыш талаасы бош болбошу керек." + "APN бош болбошу керек." + "MCC талаасы 3 сандан турушу керек." + "MNC талаасы 2 же 3 сандан турушу керек." + "Демейки APN жөндөөлөрү калыбына келтирилүүдө." + "Баштапкы абалга келтирүү" + "Демейки APN жөндөөлөрү баштапкы абалга келтирилди." + "Аталышы жок" + "Мүмкүнчүлүк алуу түйүнүнүн аталыштары" + "APN\'дер" + "Жаңы APN" + "Бул колдонуучу мүмкүнчүлүк алуу түйүнүнүн аталышынын жөндөөлөрүн колдоно албайт" + "Буферге көчүрүлсүнбү?" + "Көчүрүү" + "%s SIMге" + "Жалпы" + "Өркүндөтүлгөн" + "Жалпы жөндөөлөр" + "Өркүндөтүлгөн жөндөөлөр" + "\"%s\" SIM" + "Бардык алуучуларга жеке SMS билдирүүлөрүн жөнөтүү. Бардык жоопторду сиз гана аласыз" + "Бардык алуучуларга бир MMS жөнөтүү" + "Белгисиз номер" + "Жаңы билдирүү" + "Жаңы билдирүү." + "SIM тандагыч" + "%1$s тандалган, SIM тандагыч" + "Теманы түзөтүү" + "SIM тандоо же теманы түзөтүү" + "Аудио жаздырыш үчүн кол тийгизип туруңуз" + "Жаңы сүйлөшүү баштоо" + "SMS/MMS" + "SMS/MMS тизмеси" + "SMS/MMS" + "Жаңы билдирүү" + "Маек тизмеси" + "Сүйлөшүүлөр жүктөлүүдө…" + "Билдирүүлөр жүктөлүүдө" + "Көбүрөөк сүйлөшүүлөрдү көрүү" + "Дагы билдирүүлөрдү көрүү" + "Сүйлөшүү жок кылынды" + "Сүйлөшүү жок кылынды. Башка SMS/MMS сүйлөшүүсүн көрсөтүү үчүн тийиңиз" + "Бөгөттөлдү" + "Бөгөттөн чыгарылды" + "Сактагычта бош орун аз. Айрым дайындар жоголушу мүмкүн." + "Тиркемелерди тандаңыз" + "Тандалгандарды ырастаңыз" + "%d тандалды" + "Тиркемелердин бирин же көбүрөөгүн алып салып, кайра аракет кылыңыз." + "Билдирүүңүздү жөнөтүп көрсөңүз болот, бирок ал бир же көбүрөөк тиркемени алып салмайыңызча жеткирилбеши мүмкүн." + "Бир билдирүү менен бир эле видео жөнөтө аласыз. Кошумча видеолорду алып салып, кайра аракет кылыңыз." + "Тиркелген файлдар жүктөлбөй калды." + "Баары бир жөнөтүлсүн" + "Сүйлөшүүнү баштай алган жок" + "%1$s (%2$s)" + "%s тандалды" + diff --git a/res/values-ldrtl-v21/styles.xml b/res/values-ldrtl-v21/styles.xml new file mode 100644 index 0000000..4775979 --- /dev/null +++ b/res/values-ldrtl-v21/styles.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/res/values-ldrtl/styles.xml b/res/values-ldrtl/styles.xml new file mode 100644 index 0000000..bd270c7 --- /dev/null +++ b/res/values-ldrtl/styles.xml @@ -0,0 +1,273 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/values-lo-rLA/arrays.xml b/res/values-lo-rLA/arrays.xml new file mode 100644 index 0000000..cae3797 --- /dev/null +++ b/res/values-lo-rLA/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "ບໍ່ມີຫົວຂໍ້" + "​ບໍ່​ມີ​ຫົວ​ຂໍ້" + + + "​ແມ່ນ​ແລ້ວ" + "ບໍ່​" + "ຕົກລົງ" + "​ຮິ​ຮິ" + "​ຂອບ​ໃຈ" + "​ຂ້ອຍ​​ຍອມ​ຮັບ" + "​ຄັກຫຼາຍ" + "​ກຳ​ລັງ​ໄປ" + "​ຕົກ​ລົງ, ດຽວ​ຂ້ອຍ​ຈະ​ຕິດ​ຕໍ່​ກັບ​ໄປ" + ":)" + ":(" + + diff --git a/res/values-lo-rLA/strings.xml b/res/values-lo-rLA/strings.xml new file mode 100644 index 0000000..f433031 --- /dev/null +++ b/res/values-lo-rLA/strings.xml @@ -0,0 +1,519 @@ + + + + + "ຂໍ້ຄວາມ" + "ຂໍ້ຄວາມ" + "​ເລືອກ​ການ​ແປງ​ຂໍ້​ມູນ" + "​ການ​ຕັ້ງ​ຄ່າ" + "​ສົ່ງ​ຂໍ້​ຄວາມ" + "ເພີ່ມໄຟລ໌ແນບ" + "​ຊ່ວຍ​ເຫຼືອ" + "ຍິນດີຕ້ອນຮັບ" + "ຂ້າມ" + "ຕໍ່ໄປ >" + "ຕໍ່​ໄປ" + "ອອກ" + "ການ​ຕັ້ງ​ຄ່າ >" + "ການ​ຕັ້ງ​ຄ່າ" + "ການສົ່ງຂໍ້​ຄວາມຕ້ອງ​ການ​ການ​ອະ​ນຸ​ຍາດ​ຫາ SMS, ໂທ​ລະ​ສັບ ແລະ ​ລາຍ​ຊື່." + "ທ່ານ​ສາ​ມາດ​ປ່ຽນ​ແປງ​ການ​ການ​ອະ​ນຸ​ຍາດ​ໄດ້​ຢູ່​ໃນ​ການ​ຕັ້ງ​ຄ່າ > ແອັບ > ຂໍ້​ຄວາມ > ການ​ອະ​ນຸ​ຍາດ." + "ທ່ານ​ສາ​ມາດ​ປ່ຽນ​ແປງ​ການ​ອະ​ນຸ​ຍາດ​ໄດ້​ຢູ່​ໃນ​ການ​ຕັ້ງ​ຄ່າ, ແອັບ, ຂໍ້​ຄວາມ, ການ​ອະ​ນຸ​ຍາດ." + "​ຄວາມ​ຖີ່" + "ລາຍ​ຊື່​ຜູ້ຕິດ​ຕໍ່​ທັງ​ໝົດ" + "ສົ່ງໄປ %s" + "ບັນ​ທຶກ​ຮູບ ຫຼື​ວິ​ດີ​ໂອ" + "ເລືອກ​ຮູບ​ສຳ​ລັບ​ອຸ​ປະ​ກອນ​ນີ້" + "ບັນທຶກສຽງ" + "ເລືອກ​ຮູບ" + "ມີ​ດີ​ນີ້​ຖືກ​ເລືອກ​ແລ້ວ." + "ມີ​ເດຍ​ຖືກ​ຖອນ​ເລືອກ​ແລ້ວ." + "ເລືອກແລ້ວ %d ລາຍການ" + "ຮູບ %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "ຮູບ" + "ບັນທຶກສຽງ" + "ແບ່ງ​ປັນ" + "ຕອນນີ້" + "ຕອນນີ້" + + %d ນ​ທ + %d ນ​ທ + + + %d ຊົ່ວ​ໂມງ + %d ຊົ່ວ​ໂມງ + + + %d ວັນ + %d ວັນ + + + %d ອາ​ທິດ + ໜຶ່ງອາ​ທິດ + + + %d ເດືອນ + ໜຶ່ງ​ເດືອນ + + + %d ປີ + ໜຶ່ງ​ປີ + + "ຂໍ້ຄວາມຄລາສ 0" + "ບັນທຶກ" + "ອຸ​ປະ​ກອນ​ມີ​ພື້ນ​ທີ່​​ເກັບ​ຂໍ້​ມູນ​ໜ້ອຍ. ກາ​ນສົ່ງຂໍ້​ຄວາມ​ຈະ​ລຶບ​ຂໍ້​ຄວາມ​ເກົ່າ​ໂດຍ​ອັດ​ຕະ​ໂນ​ມັດ ເພື່ອ​ເຮັດ​ໃຫ້​ມີ​ພື້ນ​ທີ່​ຫວ່າງ​ເພີ່ມ​ຂຶ້ນ." + "ພື້ນທີ່​ຈັດ​ເກັບ​ຂໍ້ມູນ​ກຳລັງ​ຈະ​ເຕັມ" + "ການສົ່ງຂໍ້​ຄວາມ​ອາດ​ຈະ​ບໍ່​ສົ່ງ ຫຼື ຮັບ​ຂໍ້​ຄວາມ​ຈົນ​ກ່​ວາ​ມີ​ພື້ນ​ທີ່​ເພີ່ມ​ເຕີມ​ຢູ່​ໃນ​ອຸ​ປະ​ກອນ​ຂອງ​ທ່ານ." + "ພື້ນ​ທີ່​ເກັບ​ຂໍ້ຄວາມ​ໜ້ອຍ​ລົງ. ທ່ານ​ອາດ​ຈະ​ຕ້ອງ​ລຶບ​ຂໍ້​ຄວາມ​ເກົ່າ​ອອກ​ກ່ອນ." + "ຢືນຢັນເບີໂທລະສັບຂອງທ່ານ" + "ຂັ້ນ​ຕອນ​ໃຊ້​ເທື່ອ​ດຽວ​ນີ້​ຈະ​ຮັບ​ປະ​ກັນ​ໃຫ້ຂໍ້​ຄວາມສົ່ງ​ຂໍ້​ຄວາມ​ກຸ່ມ​ຂອງ​ທ່ານ​ໄດ້​ຢ່າງ​ຖືກ​ຕ້ອງ." + "ເບີໂທລະສັບ" + "ລຶບ​ຂໍ້ຄວາມ​ທັງໝົດ​ພ້ອມ​ທັງ​ມີເດຍ​ຕ່າງໆ" + "ລຶບ​ຂໍ້​ຄວາມ​ທີ່​ເກົ່າ​ກວ່າ %s" + "ລຶບ​ຂໍ້​ຄວາມ​ເກົ່າ​ກວ່າ %s ໂດຍ​ອັດ​ຕະ​ໂນ​ມັດ" + "ບໍ່ສົນໃຈ" + "ລຶບ​ຂໍ້ຄວາມ​ທັງ​ໝົດ​ພ້ອມ​ທັງ​ມີເດຍ​ຕ່າງໆຫຼື​ບໍ່?" + "ລຶບ​ຂໍ້ຄວາມ​ທີ່ເກົ່າກວ່າ %s ຫຼື​ບໍ່?" + "​ລຶບ​ຂໍ້​ຄວາມ​ເກົ່າ​ກວ່າ %s ​ແລະ ເປີດ​ການ​ລຶບ​ໂດຍ​ອັດ​ຕະ​ໂນ​ມັດຫຼື​ບໍ່?" + "%s ໄດ້ເວົ້າ" + "ທ່ານໄດ້​ເວົ້າ" + "ຂໍ້ຄວາມຈາກ %s" + "ທ່ານ​ໄດ້ສົ່ງ​ຂໍ້​ຄວາມ" + "ກຳລັງສົ່ງ..." + "ສົ່ງ​ບໍ່​ໄດ້. ສຳ​ຜັດ​ເພື່ອ​ລອງ​ໃໝ່​ອີກ​ຄັ້ງ." + "ສົ່ງ​ບໍ່​ໄດ້. ກຳ​ລັງ​ລອງ​ໃໝ່​ອີກ​ຄັ້ງ…" + "Resend or delete" + "ກະ​ລຸ​ນາ​ໂທ​ດ້ວຍ​ສຽງ​ໄປ​ຫາ​ບໍ​ລິ​ການ​ສຸກ​ເສີນ. ຂໍ້​ຄວາມ​ຂອງ​ທ່ານ​ບໍ່​ສາ​ມາດ​ນຳ​ສົ່ງ​ໄດ້​ໃນ​ເວ​ລາ​ນີ້." + "​ລົ້ມ​ເຫລວ" + "ຂໍ້ຄວາມ MMS ​ໃຫມ່​ທີ່ຈະ​ດາວ​ໂຫລດ" + "ຂໍ້ຄວາມ MMS ​ໃຫມ່" + "ບໍ່​ສາ​ມາດ​ດາວ​ໂຫລດ​ໄດ້" + "ສຳ​ຜັດ​ເພື່ອ​ລອງ​ອີກ​ຄັ້ງ" + "ແຕະເພື່ອ​ດາວ​ໂຫຼດ" + "ດາວ​ໂຫລດ​ ຫລື​ລຶບ" + "​ກຳ​ລັງ​ດາວ​ໂຫລດ..." + "ຂໍ້​ຄວາມ​ໝົດ​ອາຍຸ ຫຼື ໃຊ້​ບໍ່​ໄດ້​ແລ້ວ" + "size: %1$s, expiration: %2$s" + "​ບໍ່​ສາ​ມາດ​ສົ່ງ​ໄດ້. ຂໍ້​ມູນ​ຜູ່​ຮັບ​ບໍ່​ຖືກ​ຕ້ອງ." + "ບໍ່​ໄດ້​ເປີດ​ໃຊ້​ບໍລິການ​ໃນ​ເຄືອຂ່າຍ" + "ບໍ່ສາມາດ​ສົ່ງໄດ້​ເນື່ອງຈາກ​ມີບັນຫາ​ກັບເຄືອຂ່າຍ" + "ຂໍ້​ຄວາມ​ໝົດ​ກຳ​ນົດ ຫຼື ໃຊ້​ບໍ່​ໄດ້​ແລ້ວ" + "(ບໍ່ມີຫົວຂໍ້)" + "ບໍ່ຮູ້ຜູ່ສົ່ງ" + "ສົ່ງຮອດແລ້ວ" + "ບໍ່​ສາມາດ​ດາວໂຫລດ​ຂໍ້​ຄວາມ %1$s ຈາກ %2$s ໄດ້." + "ບໍ່​ສາມາດ​ປະຕິບັດ​ການ​ທາງ​ດ້ານ​ຖານ​ຂໍ້ມູນ​ເນື່ອງ​ຈາກ​ໜ່ວຍ​ຄວາມ​ຈຳ​ຕໍ່າ" + "ຂໍ້ຄວາມ​ຍັງ​ບໍ່ໄດ້​ຖືກສົ່ງ" + "ບາງ​ຂໍ້​ຄວາມ​ບໍ່​ໄດ້​ສົ່ງ​ຢູ່​ໃນການສົ່ງ​ຂໍ້​ຄວາມ" + + %d ຂໍ້​ຄວາມ​ໃນ %d ບົດ​ສົນ​ທະ​ນາ + %d ຂໍ້​ຄວາມ​ໃນ​ຫນຶ່ງ​ບົດ​ສົນ​ທະ​ນາ + + "ບໍ່ສາມາດດາວໂຫລດຂໍ້ຄວາມໄດ້" + "ບາງ​ຂໍ້​ຄວາມ​ບໍ່​ໄດ້​ຮັບ​ການ​ດາວ​ໂຫຼດ​ຢູ່​ໃນການສົ່ງ​ຂໍ້​ຄວາມ" + + %d ຂໍ້​ຄວາມ​ໃນ %d ບົດ​ສົນ​ທະ​ນາ + %d ຂໍ້​ຄວາມ​ໃນ​ຫນຶ່ງ​ບົດ​ສົນ​ທະ​ນາ + + "ຂໍ້​ຄວາມ​ຫາ %1$s ບໍ່​ໄດ້​ສົ່ງ" + "ກະ​ລຸ​ນາ​ໂທ​ດ້ວຍ​ສຽງ​ໄປ​ຫາ​ບໍ​ລິ​ການ​ສຸກ​ເສີນ. ຂໍ້​ຄວາມ​ຂອງ​ທ່ານຫາ %1$s ບໍ່​ສາ​ມາດ​ນຳ​ສົ່ງ​ໄດ້​ໃນ​ເວ​ລາ​ນີ້." + + %d ຂໍ້ຄວາມ​ໃໝ່. + ຂໍ້ຄວາມ​ໃໝ່. + + "ເລີ່ມ" + "ບໍ່​ສາມາດ​ໃຊ້​ກ້ອງໄດ້" + "ບໍ່​ສາມາດ​ໃຊ້​ກ້ອງໄດ້" + "​ບໍ່​ສາ​ມາດ​ໃຊ້​ການບັນ​ທຶກວິ​ດີ​ໂອ​ໄດ້" + "​ບໍ່​ສາ​ມາດ​ບັນ​ທຶກ​ມີ​ເດຍ​ໄດ້" + "ບໍ່​ສາ​ມາດ​ຖ່າຍ​ຮູບ​ໄດ້" + "ກັບຄືນ" + "ຈັດເກັບແລ້ວ" + "​ລຶບ" + "ຈັດເກັບ" + "ຖອດອອກຈາກແຟ້ມ" + "ປິດ​ການ​ແຈ້ງເຕືອນ" + "ເປີດ​ການ​ແຈ້ງເຕືອນ" + "ເພີ່ມ​ລາຍ​ຊື່​ຜູ່​ຕິດ​ຕໍ່" + "ດາວໂຫລດ" + "ສົ່ງ" + "​ລຶບ" + "ລຶບ​ຂໍ້ຄວາມ​ນີ້ຫຼືບໍ່?" + "​ການ​ກະ​ທຳ​ນີ້ຈະ​ບໍ່​ສາ​ມາດ​ກູ້​ຄືນ​ໄດ້." + "​ລຶບ" + + ລຶບການສົນທະນານີ້ບໍ? + ລຶບການສົນທະນານີ້ບໍ? + + "​ລຶບ" + "​ຍົກ​ເລີກ" + "ໄປ" + "​ເລືອກ​ຫຼາຍ​ຮູບ" + "​ຢືນ​ຢັນ​ການ​ເລືອກ" + "+%d" + "ບໍ່​ສາ​ມາດ​ອັດ​ສຽງ​ໄດ້. ລອງ​ໃໝ່​ອີກ​ຄັ້ງ." + "ບໍ່​ສາ​ມາດຫຼິ້ນ​ສຽງ​ໄດ້. ລອງ​ໃໝ່​ອີກ​ຄັ້ງ." + "​ບໍ່​ສາ​ມາດ​ບັນ​ທຶກ​ສຽງ​ໄດ້. ລອງ​ໃໝ່​ອີກ​ຄັ້ງ." + "ແຕະຄ້າງ​ໄວ້" + ", " + " " + ": " + " " + "ຮູບພາບ" + "ຄລິບສຽງ" + "ວິດີໂອ" + "ບັດ​ລາຍຊື່​ຜູ່ຕິດຕໍ່" + "ດາວໂຫຼດ" + "ຕອບ​ກັບ​ຜ່ານ SMS" + "ຕອບ​ກັບ​ຜ່ານ MMS" + "ຕອບກັບ" + + %d ຜູ້ເຂົ້າ​ຮ່ວມ + %d ຜູ້ເຂົ້າ​ຮ່ວມ + + "ຂ້ອຍ" + "ບລັອກ​ & ຈັດ​ເກັບ​ລາຍ​ຊື່​ຜູ່​ຕິດ​ຕໍ່​ເຂົ້າ​ແຟ້ມ​ແລ້ວ" + "ປົດ​ບ​ລັອກ​ລາຍ​ຊື່​ແລ້ວ & ບໍ່​ໄດ້​ເກັບ​ໄວ້" + "%d ຖືກຈັດເກັບແລ້ວ" + "%d ຖືກ​ຖອດ​ອອກ​ຈາກ​ບ່ອນ​ຈັດ​ເກັບ​ແລ້ວ" + "ການ​ແຈ້ງ​ເຕືອນ​ປິດ​ແລ້ວ" + "ການ​ແຈ້ງ​ເຕືອນ​ເປີດ​ແລ້ວ" + "​ຕັ້ງ​ຮຽບ​ຮ້ອຍ​ແລ້ວ. ສຳ​ຜັດ​ເພື່ອ​ສົ່ງ​ໃໝ່​ອີກ​ຄັ້ງ." + "ຕັ້ງ​ຂໍ້​ຄວາມ​ເປັນ​ແອັບ SMS ເລີ່ມຕົ້ນ​ສຳ​ເລັດ​ແລ້ວ." + + ເອົາ​ໄຟ​ລ໌​ຄັດ​ຕິດ​ຖິ້ມ + ເອົາ​ໄຟ​ລ໌​ຄັດ​ຕິດ​ຖິ້ມ + + "ໄຟ​ລ໌​ຄັດ​ຕິດ​ສຽງ" + "ຫຼິ້ນ​ສິ່ງຕິດຄັດທີ່ເປັນ​ສຽງ" + "ຢຸດຊົ່ວຄາວ" + "ຂໍ້​ຄວາມ​ຈາກ%s: %s." + "ຂໍ້​ຄວາມ​ບໍ່​ສຳ​ເລັດ​ຈາກ %s: %s. ເວ​ລາ: %s." + "ຂໍ້​ຄວາມ​ຈາກ %s: %s. ເວ​ລາ: %s." + "ຂໍ້​ຄວາມ​ບໍ່​ໄດ້​ສົ່ງ​ຫາ %s: %s. ເວ​ລາ: %s." + "ຂໍ້​ຄວາມ​ກຳ​ລັງ​ສົ່ງ​ຫາ %s: %s. ເວ​ລາ: %s." + "ຂໍ້​ຄວາມ​ບໍ່​ສຳ​ເລັດ​ຫາ %s: %s. ເວ​ລາ: %s." + "ຂໍ້​ຄວາມ​ຫາ %s: %s. ເວ​ລາ: %s." + "ຂໍ້​ຄວາມ​ບໍ່​ສຳ​ເລັດຈາກ %s: %s. ເວ​ລາ: %s. %s." + "ຂໍ້​ຄວາມ​ຈາກ %s: %s. ເວ​ລາ: %s. %s." + "ຂໍ້​ຄວາມ​ບໍ່​ໄດ້​ສົ່ງ​ຫາ %s: %s. ເວ​ລາ: %s." + "ຂໍ້​ຄວາມກຳ​ລັງ​ສົ່ງ​​ຫາ %s: %s. ເວ​ລາ: %s." + "ຂໍ້​ຄວາມ​ບໍ່​ສຳເລັດ​ຫາ %s: %s. Time: %s." + "ຂໍ້​ຄວາມ​ຫາ %s: %s. ເວ​ລາ: %s." + "ຂໍ້​ຄວາມ​ບໍ່​ສຳ​ເລັດ. ສຳ​ຜັດ​ເພື່ອ​ລອງ​ໃໝ່​ອີກ." + "ການ​ສົນ​ທະ​ນາ​ກັບ %s" + "​ລຶບ​ຫົວ​ຂໍ້" + "ບັນ​ທຶກ​ວິດີໂອ" + "ບັນ​ທຶກ​ຮູບ" + "ຖ່າຍຮູບ" + "ເລີ່ມ​ການ​ບັນທຶກ​ວິດີໂອ" + "ສະລັບ​ໄປ​ໃຊ້​ກ້ອງ​ແບບ​ເຕັມຈໍ" + "​ສະ​ລັບ​ລະ​ຫວ່າງ​ກ້ອງ​ໜ້າ ແລະ ຫຼັງ" + "​ຢຸດ​ການ​ບັນ​ທຶກ ແລະ ແນບ​ວິ​ດີ​ໂອ" + "​ຢຸດ​ການ​ບັນ​ທຶກວິ​ດີ​ໂອ" + "ຮູບຖ່າຍານສົ່ງ​ຂໍ້​ຄວາມ" + + %d ຮູບ​ຖືກ​ບັນ​ທຶກ​ໄປ​ໃສ່​ອາ​ລະ​ບັ້ມ \"%s\" + %d ຮູບ​ຖືກ​ບັນ​ທຶກ​ໄປ​ໃສ່​ອາ​ລະ​ບັ້ມ \"%s\" + + + %d ວິ​ດີ​ໂອ​ຖືກ​ບັນ​ທຶກ​ໄປ​ໃສ່​ອາ​ລະ​ບັ້ມ \"%s\" + %d ວິ​ດີ​ໂອ​ຖືກ​ບັນ​ທຶກ​ໄປ​ໃສ່​ອາ​ລະ​ບັ້ມ \"%s\" + + + %d ໄຟ​ລ໌​ຄັດ​ຕິດ​ຖືກ​ບັນ​ທຶ​ກ​ໄປ​ໃສ່ອາ​ລະບັ້ມ \"%s\" + %d ໄຟ​ລ໌​ຄັດ​ຕິດ​ຖືກ​ບັນ​ທຶ​ກ​ໄປ​ໃສ່ອາ​ລະ​ບັ້ມ \"%s\" + + + %dໄຟ​ລ໌​ຄັດ​ຕິດ​ຖືກ​ບັນ​ທຶກ​ໄປ​ໃສ່ \"ດາວ​ໂຫຼດ\" + %d ໄຟ​ລ໌​ຄັດ​ຕິດ​ຖືກ​ບັນ​ທຶກ​ໄປ​ໃສ່ \"ດາວ​ໂຫຼດ\" + + + %d ໄຟ​ລ໌​ຄັດ​ຕິດ​ຖືກ​ບັນ​ທຶກ​ແລ້ວ + %d ໄຟ​ລ໌​ຄັດ​ຕິດ​ຖືກ​ບັນ​ທຶກ​ແລ້ວ + + + ບໍ່​ສາ​ມາດ​ບັນ​ທຶກ %d ໄຟ​ລ໌​ຄັດ​ຕິດ​ໄດ້ + ບໍ່​ສາ​ມາດ​ບັນ​ທຶກ %d ໄຟ​ລ໌​ຄັດ​ຕິດ​ໄດ້ + + "ບັນ​ທຶກ​ໄຟລ໌​ແນບ MMS ​ແລ້ວ" + "ການ​ຕັ້ງ​ຄ່າ" + "ຈັດເກັບແລ້ວ" + "ປິດ" + "MMS" + "​ຂັ້ນ​ສູງ" + "ດີບັ໊ກ" + "ການແຈ້ງເຕືອນ" + "ສຽງ" + "ປິດສຽງ" + "ສັ່ນເຕືອນ" + "ບລັອກ​ແລ້ວ" + "​ລາຍ​ງານ​ຜົນ​ການສົ່ງ SMS" + "​ຂໍ​ລາຍ​ງານຜົນ​ການ​ສົ່ງ​ສຳ​ລັບ​ແຕ່​ລະ​ SMS ທີ່​ທ່ານ​ສົ່ງ" + "ດຶງ​ຂໍ້ມູນ​ອັດຕະໂນມັດ" + "ດຶງຂໍ້ຄວາມ MMS ອັດຕະໂນມັດ" + "ດຶງ​ຂໍ້ຄວາມ​ອັດຕະໂນມັດ​ເມື່ອ​ໂຣມມິງ" + "ກູ້​ຄືນ MMS ອັດ​ຕະ​ໂນ​ມັດ​ເມື່ອ​ໂຣມ​ມິງ" + "ການສົ່ງ​ຂໍ້ຄວາມກຸ່ມ" + "ໃຊ້ MMS ເພື່ອສົ່ງ​ຂໍ້ຄວາມ​ໃດນຶ່ງ​ເມື່ອມີຜູ່ຮັບ​ຫຼາຍຄົນ" + "ແອັບຯ SMS ມາດ​ຕະ​ຖານ" + "ແອັບຯ SMS ມາດ​ຕະ​ຖານ" + + + "​ເບີ​ໂທ​ລະ​ສັບ​ຂອງ​ທ່ານ" + "​ບໍ່​ຮູ້​ຈັກ" + "ສຽງ​ຂໍ້​ຄວາມ​ຂາ​ອອກ" + "ດຶງ SMS" + "ດຶງ​ຂໍ້​ມູນ SMS ​ດິບທີ່​ໄດ້​ຮັບ​ໃນ​ຮູບ​ແບບ​ໄຟ​ລ໌​ເກັບ​ຂໍ້​ມູນ​ພາຍນອກ" + "ດຶງ MMS" + "ດຶງ​ຂໍ້​ມູນ MMS ​ດິບທີ່​ໄດ້​ຮັບ​ໃນ​ຮູບ​ແບບ​ໄຟ​ລ໌​ເກັບ​ຂໍ້​ມູນ​ພາຍນອກ" + "ແຈ້ງ​ເຕືອນ​ລະບົບ​ໄຮ້ສາຍ" + "​ໂຕ​ເລືອກ​ຂໍ້​ຄວາມ" + "​ສຳ​ເນົາ​ຂໍ້​ຄວາມ" + "​ເບິ່ງ​ລາຍ​ລະ​ອຽດ" + "​ລຶບ" + "ສົ່ງຕໍ່" + "​ລາຍ​ລະ​ອຽດ​ຂໍ້​ຄວາມ" + "ປະ​ເພດ: " + "​ຂໍ້​ຄວາມ" + "ຂໍ້​ຄວາມ​ມັນ​ຕິ​ມີ​ເດຍ" + "ຈາກ: " + "ເຖິງ: " + "ສົ່ງ​ເມື່ອ: " + "ໄດ້​ຮັບເມື່ອ: " + "ຫົວ​ຂໍ້: " + "​ຂະ​ໜາດ: " + "ຄວາມ​ສຳ​ຄັນ: " + "​ຊິມ: " + "​ສູງ" + "​ປານ​ກາງ" + "​ຕ່ຳ" + "​ຊິມ %s" + "​​ເຊື່ອງ​ທີ່​ຢູ່​ຜູ່​ສົ່ງ" + "​ບໍ່​ສາ​ມາດ​ສົ່ງ​ຂໍ້​ຄວາມ​ໄດ້​ໃນ​ຂະ​ນະ​ທີ່​ໂຫຼດ​ໄຟລ໌​ແນບ." + "ບໍ່​ສາ​ມາດ​ໂຫລດ​ໄຟລ໌​ແນບ​ໄດ້. ​ລອງ​ໃໝ່​ອີກ​ຄັ້ງ." + "ເຄືອ​ຂ່າຍ​ບໍ່​ພ້ອມ. ລອງ​ໃໝ່​ອີກ." + "ລຶບ​ຂໍ້​ຄວາມ" + "ສະ​ລັບ​ລະ​ຫວ່າງ​ການ​ພິມ​ໂຕ​ອັກ​ສອນ ແລະ​ໂຕ​ເລກ" + "ເພີ່ມ​ຜູ່​ເຂົ້າ​ຮ່ວມ​ອື່ນ" + "ຢືນ​ຢັນ​ຜູ່​ເຂົ້າ​ຮ່ວມ" + "​ເລີ່ມ​ບົດ​ສົນ​ທະ​ນາ​ໃໝ່" + "​ເລືອກ​ລາຍ​ການ​ນີ້" + "ຫຼິ້ນວິດີໂອ" + "​ລາຍ​ຊື່​ຜູ່​ຕິດ​ຕໍ່ & ໂຕ​ເລືອກ" + "ດີບັ໊ກ" + "​ລາຍ​ຊື່​ຜູ່​ຕິດ​ຕໍ່ & ໂຕ​ເລືອກ" + "ທົ່ວໄປ" + "ຄ​ົນ​ໃນ​ບົດ​ສົນ​ທະ​ນາ​ນີ້" + "​ໂທ​ອອກ" + "ສົ່ງຂໍ້ຄວາມ" + "​ສົ່ງ​ຂໍ້​ຄວາມ<br/><small>​ຈາກ %s</small>" + + ​ສົ່ງ​ຮູບ​ພາບ + ​ສົ່ງ​ຮູບ​ພາບ + + + ​ສົ່ງ​ສຽງ + ​ສົ່ງ​ສຽງ + + + ສົ່ງ​ວິດີໂອ + ສົ່ງ​ວິດີໂອ + + + ​ສົ່ງ​ບັດ​ລາຍ​ຊື່​ຜູ້ຕິດ​ຕໍ່ + ​ສົ່ງ​ບັດ​ລາຍ​ຊື່​ຜູ້ຕິດ​ຕໍ່ + + + ​ສົ່ງ​ໄຟລ໌​ຄັດ​ຕິດ + ​ສົ່ງ​ໄຟລ໌​ຄັດ​ຕິດ + + + %d ​ໄຟ​ລ໌​ຄັດ​ຕິດ​ພ້ອມ​ສົ່ງ​ໄດ້​ແລ້ວ + ໜຶ່ງ​ໄຟ​ລ໌​ຄັດ​ຕິດ​ພ້ອມ​ສົ່ງ​ໄດ້​ແລ້ວ + + "​ສົ່ງ​ຄຳ​ຕິ​ຊົມ" + "ເບິ່ງໃນ Google Play Store" + "​​ຂໍ​້​ມູນ​ເວີ​ຊັນ" + "ເວີ​ຊັນ %1$s" + "​ລິ​ຂະ​ສິດ​ໂອ​ເພນ​ຊອດ" + "ການແຈ້ງເຕືອນ" + "ຮອດ​ຂີດ​ຈຳ​ກັດ​ໄຟ​ລ໌​ຄັດ​ຕິດ​ແລ້ວ" + "ໂຫຼດ​ໄຟ​ລ໌​ຄັດ​ຕິດ​ບໍ່​ສຳ​ເລັດ." + "ເພີ່ມ​ໃສ່​ລາຍ​ຊື່​ຜູ່​ຕິດ​ຕໍ່​ບໍ່?" + "ເພີ່ມ​​ໃສ່​ລາ​ຍ​ຊື່​ຜູ່​ຕິດ​ຕໍ່" + "ຫົວຂໍ້" + "ຫົວຂໍ້: " + "%s%s" + "​ກຳ​ລັງ​ໂຫລດ​ບັດ​ລາຍ​ຊື່​ຜ​ູ່​ຕິດ​ຕໍ່" + "​ບໍ່​ສາ​ມາດ​ໂຫລດ​ບັດ​ລາຍ​ຊື່​ຜູ່​ຕິດ​ຕໍ່ໄດ້" + "​ເບິ່ງ​ບັດ​ລາຍ​ຊື່​ຜູ່​ຕິດ​ຕໍ່" + + %d ລາຍ​ຊື່ + %d ລາຍ​ຊື່ + + "ບັດ​ລາຍ​ຊື່​ຜູ່​ຕິດ​ຕໍ່" + "ວັນເດືອນປີເກີດ" + "ບັນທຶກ" + "​ສົ່ງ​ຕໍ່​ຂໍ້​ຄວາມ" + "ຕອບຄືນ" + "+1" + "+%d" + "SMS ​ຖືກ​ປິດ​ແລ້ວ" + "ເພື່ອ​ສົ່ງ, ຕັ້ງ​ການສົ່ງຂໍ້​ຄວາມ​ເປັນ​ແອັບ SMS ເລີ່ມຕົ້ນ" + "ຕັ້ງການສົ່ງ​ຂໍ້​ຄວາມ​ເປັນ​ແອັບ SMS ເລີ່ມຕົ້ນ" + "ປ່ຽນແປງ" + "​ເພື່ອ​ຮັບ​ຂໍ້​ຄວາມ, ຕັ້ງການສົ່ງຂໍ້​ຄວາມໃຫ້​ເປັນ​ແອັບ SMS ​ເລີ່ມ​ຕົ້ນ​ຂອງ​ທ່ານ" + "ບໍ່​ມີ SIM ທີ່​ມັກ​ຖືກ​ເລືອກ​ໄວ້​ ເພື່ອ​ສົ່ງ​ຂໍ້​ຄວາມ SMS" + "​​ເຈົ້າ​ຂອງ​ອຸ​ປະ​ກອນບໍ່​ອະ​ນຸ​ຍາດ​ໃຫ້​ໃຊ້​ແອັບຯ​ນີ້." + "ຕົກລົງ" + "​ມີ​ຜູ່​ເຂົ້າ​ຮ່ວມ​ໃນ​ການ​ສົນ​ທະ​ນາຫຼາຍ​ເກີນ​ໄປ" + + ລາຍ​ຊື່​ຕິດ​ຕໍ່​ບໍ່​ຖືກ​ຕ້ອງ + ລາຍ​ຊື່​ຕິດ​ຕໍ່​ບໍ່​ຖືກ​ຕ້ອງ + + "​ບໍ່​ສາ​ມາດ​ໂຫລດ​ຮູບ​ຈາກ​ກ້ອງ​ໄດ້" + "ທ່ານ: " + "%s: " + "ສະບັບຮ່າງ" + "ໃນເວລາ​ທີ່ທານ​ເລີ່ມການ​ສົນທະນາ​ໃຫມ່, ທ່ານຈະເຫັນ​ມັນສະແດງ​ຢູ່ບ່ອນ​ນີ້" + "​ບົດ​ສົນ​ທະ​ນາ​ທີ່​ຖືກ​ຈັດ​ເກັບ​ຈະ​ສະ​ແດງ​ຢູ່​ນີ້" + "ກຳລັງ​ໂຫລດ​ບົດ​ສົນທະນາ..." + "ຮູບພາບ" + "ຄລິບສຽງ" + "ວິດີໂອ" + "ບັດ​ລາຍຊື່​ຕິດຕໍ່" + "MMS" + "ຍົກເລີກ" + "ລອງໃໝ່" + "​ປ້ອນ​ຊື່​ລາຍ​ຊື່​ຜູ່​ຕິດ​ຕໍ່ ຫຼື ເບີ​ໂທ​ລະ​ສັບ​ເພື່ອ​ເລີ່ມ​ຂໍ້​ຄວາມ​ໃໝ່" + "ບລັອກ" + "ບລັອກ %s" + "​ປົດ​ບລັອກ %s" + "ບລັອກ %s ບໍ?" + "​ທ່ານ​ຈະ​ຍັງ​ສືບ​ຕໍ່​ໄດ້​ຮັບ​ຂໍ້​ຄວາມ​ຈາກ​ເບີ​ນີ້ ແຕ່​ຈະ​ບໍ່​ຖືກ​ແຈ້ງ​ເຕືອນ​ອີກ​ຕໍ່​ໄປ. ບົດ​ສົນ​ທະ​ນາ​ນີ້​ຈະ​ຖືກ​ຈັດ​ເກັບ." + "​ລາຍ​ຊື່​ຜູ່​ຕິດ​ຕໍ່​ທີ່​ຖືກບລັອກ" + "​ປົດ​ບລັອກ" + "ບລັອກ​ລາຍ​ຊື່​ຜູ່​ຕິດ​ຕໍ່​ແລ້ວ" + "​ເລືອກ​ຮູບ​ຈາກ​ຫ້ອງ​ສະ​ໝຸດ​ເອ​ກະ​ສານ" + "ກຳລັງສົ່ງຂໍ້ຄວາມ" + "ສົ່ງຂໍ້ຄວາມແລ້ວ" + "ຂໍ້​ມູນ​ເຄືອ​ຂ່າຍ​ໂທ​ລະ​ສັບ​ຖືກ​ປິດ​ໄວ້. ກະ​ລຸ​ນາ​ກວດ​ສອບ​ການ​ຕັ້ງ​ຄ່າ​ຂອງ​ທ່ານ." + "​ບໍ່​ສາ​ມາດ​ສົ່ງ​ຂໍ້​ຄວາມ​ໃນ​ໂໝດ​ໃນ​ຍົນ​ໄດ້" + "ບໍ່ສາມາດສົ່ງຂໍ້ຄວາມໄປໄດ້" + "ດາວ​ໂຫຼດ​ຂໍ້​ຄວາມ​ແລ້ວ" + "ຂໍ້​ມູນເຊວ​ລູ​ລາ​ຖືກ​ປິດ​ໄວ້. ສອບ​ການ​ຕັ້ງ​ຄ່າ​ຂອງ​ທ່ານ." + "ບໍ່​ສາ​ມາດ​ກາວ​ໂຫຼດ​ຂໍ້​ຄວາມ​ຢູ່​ໃນ​ໂໝດ​ເຮືອ​ບິນ​ໄດ້" + "ບໍ່​ສາ​ມາດ​ດາວ​ໂຫຼດ​ໄດ້" + "​ສູນ" + "​ນຶ່ງ" + "​ສອງ" + "​ສາມ" + "​ສີ່" + "​ຫ້າ" + "​ຫົກ" + "​ເຈັດ" + "ແປດ" + "​ເກົ້າ" + "ບໍ່​ສາ​ມາດ​ສົ່ງ​ຂໍ້​ຄວາມ​ດ້ວຍ %1$s ໄດ້, ຂໍ້​ຜິດ​ພາດ %2$d" + "ບໍ່​ສາ​ມາດ​ສົ່ງ​ຂໍ້​ຄວາມ​ດ້ວຍບໍ​ລິ​ສັດ​ເຄືອ​ຂ່າຍ​ມື​ຖື​ບໍ່​ຮູ້​ຈັກ​ໄດ້, ຂໍ້​ຜິດ​ພາດ %1$d" + "Fwd: %s" + "ຂໍ້​ຄວາມ​ຍັງ​ບໍ່​ໄດ້​ສົ່ງ: ບໍ​ລິ​ການ​ຍັງ​ບໍ່​ໄດ້​ເປີດ​ນຳ​ໃຊ້​ໃນ​ເຄືອ​ຂ່າຍ​ເທື່ອ" + "ຂໍ້​ຄວາມ​ຍັງ​ບໍ່​ໄດ້​ສົ່ງ: ທີ່​ຢູ່​ປາຍ​ທາງບໍ່​ຖືກ​ຕ້ອງ" + "ຂໍ້​ຄວາມ​ຍັງ​ບໍ່​ໄດ້​ສົ່ງ: ຂໍ້​ຄວາມ​ບໍ່​ຖືກ​ຕ້ອງ" + "ຂໍ້​ຄວາມ​ຍັງ​ບໍ່​ໄດ້​ສົ່ງ: ​ບໍ່​ຮອງ​ຮັບ​ເນື້ອ​ຫາ" + "ຂໍ້​ຄວາມ​ຍັງ​ບໍ່​ໄດ້​ສົ່ງ: ບໍ່​ຮອງ​ຮັບ​ຂໍ້​ຄວາມ" + "ຂໍ້ຄວາມ​ຍັງ​ບໍ່ໄດ້​ຖືກສົ່ງ: ໃຫຍ່​ເກີນ​ໄປ" + "ຂໍ້ຄວາມໃໝ່" + "​ເບິ່ງ" + "ຮູບພາບ" + "ບໍ່​ສາ​ມາດ​ຊອກ​ຫາ​ແອັບ​ພລິ​ເຄ​ຊັນ​ທີ່​ເໝາະ​ສົມ​ໄດ້" + "ລຶບ​ຜູ່​ຮັບ" + "ຂໍ້ຄວາມໃໝ່" + "​ຍົກ​ເລີກ" + "ແກ້ໄຂ​ຈຸດ​ການ​ເຊື່ອມຕໍ່" + "ຍັງບໍ່ໄດ້ຕັ້ງ" + "ຊື່" + "APN" + "MMSC" + "MMS proxy" + "ພອດ MMS" + "MCC" + "MNC" + "ປະເພດ APN" + "ລຶບ APN" + "APN ໃໝ່" + "ບັນທຶກ" + "ຍົກເລີກ" + "ຊ່ອງຂໍ້ມູນ​ຊື່​ບໍ່ສາມາດ​ປ່ອຍ​ຫວ່າງໄດ້." + "APN ບໍ່​ສາມາດ​ປ່ອຍ​ໃຫ້​ຫວ່າງ​ໄດ້." + "ຊ່ອງຂໍ້ມູນ MMC ຕ້ອງມີ 3 ໂຕເລກ." + "ຊ່ອງຂໍ້ມູນ MNC ຕ້ອງມີ 2 ຫຼື 3 ໂຕເລກ." + "ກຳລັງ​ກັບ​ໄປ​ໃຊ້ການຕັ້ງຄ່າ APN ເລີ່ມຕົ້ນ." + "ກັບຄືນໄປໃຊ້ຄ່າເລີ່ມຕົ້ນ" + "​ສຳ​ເລັດ​ການ​ກັບ​ໄປໃຊ້ການ​ຕັ້ງ​ຄ່າ APN ເລີ່ມຕົ້ນແລ້ວ." + "ບໍ່ໄດ້ຕັ້ງຊື່" + "ຊື່ຂອງ​ຈຸດການ​ເຂົ້າເຖິງ (APN)" + "APNs" + "APN ໃໝ່" + "ຜູ່​ໃຊ້​ນີ້ບໍ່​ສາ​ມາດ​ຕັ້ງ​ຄ່າ​ຊື່​ຈຸດ​ການ​ເຂົ້າ​ເຖິງ​ໄດ້" + "​ສຳ​ເນົາ​ໄວ້​ໃນຄລິບບອດຫຼື​ບໍ່?" + "ສຳເນົາ" + "ເຖິງ %s" + "ທົ່ວໄປ" + "​ຂັ້ນ​ສູງ" + "ການຕັ້ງຄ່າ​ທົ່ວໄປ" + "ການຕັ້ງຄ່າ​ຂັ້ນສູງ" + "SIM \"%s\"" + "​ສົ່ງ​ຂໍ້​ຄວາມ SMS ​ສະ​ເພາະ​ບຸກ​ຄົນໄປ​ຫາ​ທຸກໆ​ຜູ່​ຮັບ. ​ສະ​ເພາະ​ທ່ານ​ເທົ່າ​ນັ້ນ​ທີ່​ຈະ​ໄດ້​ຮັບ​ແຕ່​ລະ​ການ​ຕອບ​ກັບ" + "​ສົ່ງ MMS ​ສະ​ບັບ​ນຶ່ງ​ໄປ​ຫາ​ທຸກໆ​ຜູ່​ຮັບ" + "​ເບີ​ໂທບໍ່​ຮູ້​ຈັກ" + "ຂໍ້ຄວາມ​ໃໝ່" + "ຂໍ້ຄວາມ​ໃໝ່." + "ຕົວ​ເລືອກ SIM" + "%1$s ຖືກ​ເລືອກ​ແລ້ວ, ຕົວ​ເລືອກ SIM" + "ແກ້ໄຂຫົວຂໍ້" + "ເລືອກ SIM ຫຼື​ແກ້​ໄຂ​ຫົວ​ຂໍ້" + "ສຳ​ພັດ​ຄ້າງ​ໄວ້ ເພື່ອ​ບັນ​ທຶກ​ສຽງ" + "​ເລີ່ມ​ບົດ​ສົນ​ທະ​ນາ​ໃໝ່" + "ຂໍ້ຄວາມ" + "ລາຍ​ການການສົ່ງ​ຂໍ້​ຄວາມ" + "ຂໍ້ຄວາມ" + "ຂໍ້ຄວາມໃໝ່" + "ລາຍການການສົນທະນາ" + "ກຳລັງ​ໂຫລດ​ບົດ​ສົນທະນາ..." + "ກຳລັງໂຫລດຂໍ້ຄວາມ" + "ເບິ່ງບົດສົນທະນາເພີ່ມເຕີມ" + "ເບິ່ງ​ຂໍ້​ຄວາມ​ເພີ່ມ​ເຕີມ" + "ການ​ສົນທະນາ​ຖືກລຶບແລ້ວ" + "ການ​ສົນ​ທະ​ນາ​ຖືກ​ລຶບ​ແລ້ວ. ສຳ​ຜັດ​ເພື່ອ​ສະ​ແດງ, ການ​ສົນ​ທະ​ນາ​ຂໍ້​ຄວາມ​ທີ່​ແຕກ​ຕ່າງ" + "ບລັອກ​ແລ້ວ" + "ປົດ​ບ​ລັອກ​ແລ້ວ" + "ພື້ນ​ທີ່​ຈັດ​ເກັບ​ຕ່ຳ. ບາງ​ຂໍ້​ມູນ​ອາດ​ຈະ​ເສຍ​ໄປ." + "ເລືອກ​ໄຟ​ລ໌​ຄັດ​ຕິດ" + "​ຢືນ​ຢັນ​ການ​ເລືອກ" + "%d ເລືອກ​ແລ້ວ" + "ກະ​ລຸ​ນາ​ເອົາ​ໜຶ່ງ ຫຼື​ຫຼາຍ​ໄຟ​ລ໌​ຄັດ​ຕິດ​ອອກ​ໄປ ແລະ​ລອງ​ໃໝ່​ອີກ." + "ທ່ານ​ສາ​ມາດ​ລອງ​ສົ່ງ​ຂໍ້​ຄວາມ​ຂອງ​ທ່ານ​ໄດ້, ແຕ່​ມັນ​ອາດ​ຈະ​ບໍ່​ຖືກ​ສົ່ງ​ໄປ ນອກ​ຈາກວ່າ​ທ່ານ​ເອົາ​ໜຶ່ງ ຫຼື​ຫຼາຍ​ໄຟ​ລ໌​ຄັດ​ຕິດ." + "ທ່ານ​ສາ​ມາດ​ສົ່ງ​ໄດ້​ແຕ່​ໜຶ່ງວິ​ດີ​ໂອ​ຕໍ່​ຂໍ້​ຄວາມ​ເທົ່າ​ນັ້ນ. ກະ​ລຸ​ນາ​ເອົາ​ວິ​ດີ​ໂອ​ເພີ່ມ​ເຕີມ​ອອກ ແລະ​ລອງ​ໃໝ່​ອີກ." + "ການສົ່ງຂໍ້​ຄວາມ​ໂຫຼດ​ໄຟ​ລ໌​ຄັດ​ຕິດ​ບໍ່ສຳເລັດ." + "ແນວ​ໃດ​ກໍ່​ສົ່ງ​" + "ບໍ່​ສາ​ມາດ​ເລີ່ມ​ຕົ້ນ​ການ​ສົນ​ທະ​ນາ​ໄດ້" + "%1$s (%2$s)" + "%s ເລືອກ​ແລ້ວ" + diff --git a/res/values-lt/arrays.xml b/res/values-lt/arrays.xml new file mode 100644 index 0000000..3c1c116 --- /dev/null +++ b/res/values-lt/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "nėra temos" + "nėra temos" + + + "Taip" + "Ne" + "Gerai" + "Hehe" + "Ačiū" + "Sutinku" + "Puiku" + "Pakeliui" + "Gerai, informuosiu vėliau" + ":)" + ":(" + + diff --git a/res/values-lt/strings.xml b/res/values-lt/strings.xml new file mode 100644 index 0000000..167fe9f --- /dev/null +++ b/res/values-lt/strings.xml @@ -0,0 +1,571 @@ + + + + + "Susirašinėjimas pranešimais" + "Susirašinėjimas pranešimais" + "Pasirinkti pokalbį" + "Nustatymai" + "Siųsti pranešimą" + "Pridėti priedą" + "Pagalba" + "Sveiki!" + "Praleisti" + "Kitas >" + "Kitas" + "Išeiti" + "Nustatymai >" + "Nustatymai" + "Susirašinėjimo programai reikia leidimų pasiekti SMS pranešimus, telefoną ir kontaktus." + "Leidimus galite pakeisti skiltyje „Nustatymai > Programos > Susirašinėjimo programa > Leidimai“." + "Leidimus galite pakeisti skiltyje „Nustatymai, Programos, Susirašinėjimo programa, Leidimai“." + "Dažniausiai naudojami" + "Visi kontaktai" + "Siųsti: %s" + "Fotografuokite arba filmuokite" + "Pasirinkite vaizdus iš šio įrenginio" + "Įrašyti garsą" + "Pasirinkti nuotrauką" + "Medija pasirinkta." + "Medija nepasirinkta." + "Pasirinkta: %d" + "vaizdas: %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "vaizdas" + "Įrašyti garsą" + "Bendrinimas" + "Ką tik" + "Dabar" + + %d min. + %d min. + %d min. + %d min. + + + %d val. + %d val. + %d val. + %d val. + + + %d diena + %d dienos + %d dienos + %d dienų + + + %d savaitė + %d savaitės + %d savaitės + %d savaičių + + + %d mėnesiai + %d mėnesiai + %d mėnesio + %d mėnesių + + + %d metai + %d metai + %d metų + %d metų + + "0 klasės pranešimas" + "Išsaugoti" + "Mažai vietos įrenginyje. Susirašinėjimo programa automatiškai ištrins senesnius pranešimus, kad atlaisvintų vietos." + "Mažėja laisvos saugyklos vietos saugykloje" + "Galbūt Susirašinėjimo programa negalės siųsti ar gauti pranešimų, kol įrenginyje nebus daugiau laisvos vietos." + "Mažai laisvos SMS saugyklos vietos. Gali reikėti ištrinti pranešimus." + "Patvirtinkite telefono numerį" + "Atlikę šį vienkartinį veiksmą užtikrinsite, kad Susirašinėjimo programa tinkamai pateiks grupės pranešimus." + "Telefono numeris" + "Ištrinti visus pranešimus su medija" + "Ištrinti senesnius nei %s pranešimus" + "Automatiškai ištrinti pranešimus, kurie senesni nei %s" + "Nepaisyti" + "Ištrinti visus pranešimus su medija?" + "Ištrinti pranešimus, kurie senesni nei %s?" + "Ištrinti pranešimus, kurie senesni nei %s, ir įjungti automatinį ištrynimą?" + "%s sakė" + "Jūs sakėte" + "Pranešimas nuo %s:" + "Išsiuntėte pranešimą" + "Siunčiama…" + "Neišsiųsta. Palieskite, kad bandytumėte dar kartą." + "Neišsiųsta. Bandoma dar kartą…" + "Persiųsti arba ištrinti" + "Atlikite balso skambutį pagalbos tarnyboms. Šiuo metu negalime pateikti teksto pranešimo." + "Nepavyko" + "Naujas MMS pranešimas, kurį galima atsisiųsti" + "Naujas MMS pranešimas" + "Nepavyko atsisiųsti" + "Palieskite ir bandykite dar kartą" + "Palieskite, kad atsisiųstumėte" + "Atsisiųskite arba ištrinkite" + "Atsisiunčiama..." + "Pranešimo galiojimo laikas pasibaigė arba jis negalimas" + "dydis: %1$s, galiojimo pabaiga: %2$s" + "Nepavyko išsiųsti. Netinkamas gavėjas." + "Paslauga nesuaktyvinta tinkle" + "Nepavyko išsiųsti dėl tinklo problemos" + "Pranešimo galiojimo laikas pasibaigė arba jis negalimas" + "(Nėra temos)" + "Nežinomas siuntėjas" + "Pristatyta" + "Nepavyko atsisiųsti pranešimo „%1$s“ nuo %2$s." + "Nepavyko atlikti duomenų bazės operacijos dėl atminties trūkumo" + "Pranešimas neišsiųstas" + "Kai kurie pranešimai neišsiųsti naudojant Susirašinėjimo programą" + + Pranešimų: %d (%d pokalbis) + Pranešimų: %d (%d pokalbiai) + Pranešimų: %d (%d pokalbio) + Pranešimų: %d (%d pokalbių) + + "Pranešimas neatsisiųstas" + "Kai kurie pranešimai neatsisiųsti naudojant Susirašinėjimo programą" + + Pranešimų: %d (%d pokalbis) + Pranešimų: %d (%d pokalbiai) + Pranešimų: %d (%d pokalbio) + Pranešimų: %d (%d pokalbių) + + "Pranešimas numeriu %1$s nebuvo išsiųstas" + "Atlikite balso skambutį pagalbos tarnyboms. Šiuo metu negalime pateikti teksto pranešimo numeriu %1$s." + + %d naujas pranešimas + %d nauji pranešimai + %d naujo pranešimo + %d naujų pranešimų + + "Pradėti" + "Fotoaparatas negalimas" + "Fotoaparatas negalimas" + "Vaizdo įrašo fiksavimas negalimas" + "Nepavyko išsaugoti medijos" + "Negalima fotografuoti" + "Atgal" + "Suarchyvuota" + "Ištrinti" + "Archyvuoti" + "Išarchyvuoti" + "Išjungti pranešimus" + "Įjungti pranešimus" + "Pridėti kontaktą" + "Atsisiųsti" + "Siųsti" + "Ištrinti" + "Ištrinti šį pranešimą?" + "Šio veiksmo anuliuoti negalima." + "Ištrinti" + + Ištrinti šiuos pokalbius? + Ištrinti šiuos pokalbius? + Ištrinti šiuos pokalbius? + Ištrinti šiuos pokalbius? + + "Ištrinti" + "Atšaukti" + "Kam" + "Pasirinkti kelis vaizdus" + "Patvirtinti pasirinkimą" + "Dar %d" + "Nepavyko įrašyti garso. Bandykite dar kartą." + "Nepavyko paleisti garso įrašo. Bandykite dar kartą." + "Nepavyko išsaugoti garso įrašo. Bandykite dar kartą." + "Palieskite ir palaikykite" + ", " + " " + ": " + " " + "Nuotrauka" + "Garso įrašo klipas" + "Vaizdo įrašas" + "Kontakto kortelė" + "Atsisiųsti" + "Atsakyti SMS žinute" + "Atsakyti MMS" + "Atsakyti" + + %d dalyvis + %d dalyviai + %d dalyvio + %d dalyvių + + "Aš" + "Kontaktas užblokuotas ir suarchyvuotas" + "Kontaktas atblokuotas ir išarchyvuotas" + "Suarchyvuota: %d" + "Išarchyvuota: %d" + "Pranešimai išjungti" + "Pranešimai įjungti" + "Viskas nustatyta. Dar kartą palieskite „Siųsti“." + "Susirašinėjimo programa sėkmingai nustatyta kaip numatytoji SMS programa." + + Išmesti priedus + Išmesti priedus + Išmesti priedus + Išmesti priedus + + "Garso priedas" + "Leisti garso priedą" + "Pristabdyti" + "Pranešimas nuo %s: %s." + "Nepavyko atsiųsti pranešimo nuo %s: „%s“. Laikas: %s." + "Pranešimas nuo %s: „%s“. Laikas: %s." + "Neišsiųstas pranešimas grupei „%s“: „%s“. Laikas: %s." + "Siunčiamas pranešimas grupei „%s“: „%s“. Laikas: %s." + "Nepavyko išsiųsti pranešimo grupei „%s“: „%s“. Laikas: %s." + "Pranešimas grupei „%s“: „%s“. Laikas: %s." + "Nepavyko atsiųsti pranešimo nuo %s: „%s“. Laikas: %s. %s." + "Pranešimas nuo %s: „%s“. Laikas: %s. %s." + "Neišsiųstas pranešimas grupei „%s“: „%s“. Laikas: %s." + "Siunčiamas pranešimas grupei „%s“: „%s“. Laikas: %s." + "Nepavyko išsiųsti pranešimo grupei „%s“: „%s“. Laikas: %s." + "Pranešimas grupei „%s“: „%s“. Laikas: %s." + "Įvyko pranešimo klaida. Palieskite ir bandykite dar kartą." + "Pokalbis su %s" + "Pašalinti temą" + "Filmuoti" + "Fiksuoti nejudantį vaizdą" + "Fotografuoti" + "Pradėti filmuoti" + "Perjungti į viso ekrano režimu veikiantį fotoaparatą" + "Perjungti priekinį ir užpakalinį fotoaparatus" + "Sustabdyti įrašymą ir pridėti vaizdo įrašą" + "Sustabdyti vaizdo įrašymą" + "Susirašinėjimo programos nuotraukos" + + %d nuotrauka išsaugota albume „%s + %d nuotraukos išsaugotos albume „%s + %d nuotraukos išsaugota albume „%s + %d nuotraukų išsaugota albume „%s + + + %d vaizdo įrašas išsaugotas albume „%s + %d vaizdo įrašai išsaugoti albume „%s + %d vaizdo įrašo išsaugota albume „%s + %d vaizdo įrašų išsaugota albume „%s + + + %d priedas išsaugotas albume „%s + %d priedai išsaugoti albume „%s + %d priedo išsaugota albume „%s + %d priedų išsaugota albume „%s + + + %d priedas išsaugotas aplanke „Atsisiuntimai“ + %d priedai išsaugoti aplanke „Atsisiuntimai“ + %d priedo išsaugota aplanke „Atsisiuntimai“ + %d priedų išsaugota aplanke „Atsisiuntimai“ + + + Išsaugotas %d priedas + Išsaugoti %d priedai + Išsaugota %d priedo + Išsaugota %d priedų + + + Nepavyko išsaugoti %d priedo + Nepavyko išsaugoti %d priedų + Nepavyko išsaugoti %d priedo + Nepavyko išsaugoti %d priedų + + "Išsaugotas MMS priedas" + "Nustatymai" + "Suarchyvuota" + "Uždaryti" + "MMS" + "Išplėstiniai" + "Derinti" + "Pranešimai" + "Garsas" + "Tylus" + "Vibravimas" + "Užblokuota" + "SMS pristatymo ataskaitos" + "Pateikti kiekvieno išsiųsto SMS pristatymo ataskaitos užklausą" + "Automatiškai nuskaityti" + "Automatiškai nuskaityti MMS pranešimus" + "Aut. nuskait. naud. tarptinklinį ryšį" + "Automatiškai nuskaityti MMS, kai veikia tarptinklinis ryšys" + "Grupinis susirašinėjimas" + "Naudokite MMS, kad siųstumėte vieną pranešimą, kai yra keli gavėjai" + "Numatytoji SMS programa" + "Numatytoji SMS programa" + + + "Jūsų telefono numeris" + "Nežinomas" + "Siunčiamų pranešimų garsai" + "Pateikti SMS" + "Pateikti gautų SMS neapdorotus duomenis išorinės atminties faile" + "Pateikti MMS" + "Pateikti gautų MMS neapdorotus duomenis išorinės atminties faile" + "Belaidžiu ryšiu siunčiami įspėjimai" + "Pranešimo parinktys" + "Kopijuoti tekstą" + "Peržiūrėti išsamią informaciją" + "Ištrinti" + "Persiųsti" + "Išsami pranešimo informacija" + "Tipas: " + "teksto pranešimas" + "Įvairialypės informacijos pranešimas" + "Nuo: " + "Kam: " + "Išsiųsta: " + "Gauta: " + "Tema: " + "Dydis: " + "Pirmenybė: " + "SIM: " + "Aukšta" + "Įprasta" + "Žema" + "SIM %s" + "Paslėptas siuntėjo adresas" + "Negalima siųsti žinutės, kol įkeliami priedai." + "Nepavyko įkelti priedo. Bandykite dar kartą." + "Tinklas neparuoštas. Bandykite dar kartą." + "Pašalinti tekstą" + "Perjungti iš teksto įvedimo į skaičių įvedimą ir atvirkščiai" + "Pridėti daugiau dalyvių" + "Patvirtinti dalyvius" + "Pradėti naują pokalbį" + "Pasirinkti šį elementą" + "Leisti vaizdo įrašą" + "Žmonės ir parinktys" + "Derinti" + "Žmonės ir parinktys" + "Bendrieji" + "Žmonės šiame pokalbyje" + "Skambinti" + "Siųsti pranešimą" + "Siųsti pranešimą<br/><small>iš %s</small>" + + Siųsti nuotraukas + Siųsti nuotraukas + Siųsti nuotraukas + Siųsti nuotraukas + + + Siųsti garso įrašus + Siųsti garso įrašus + Siųsti garso įrašus + Siųsti garso įrašus + + + Siųsti vaizdo įrašus + Siųsti vaizdo įrašus + Siųsti vaizdo įrašus + Siųsti vaizdo įrašus + + + Siųsti kontaktų korteles + Siųsti kontaktų korteles + Siųsti kontaktų korteles + Siųsti kontaktų korteles + + + Siųsti priedus + Siųsti priedus + Siųsti priedus + Siųsti priedus + + + %d priedas paruoštas siųsti + %d priedai paruošti siųsti + %d priedo paruošta siųsti + %d priedų paruošta siųsti + + "Siųsti atsiliepimą" + "Žr. „Google Play“ parduotuvėje" + "Versijos informacija" + "%1$s versija" + "Atvirojo šaltinio licencijos" + "Pranešimai" + "Pasiektas priedų apribojimas" + "Nepavyko įkelti priedo." + "Pridėti prie kontaktų?" + "Pridėti kontaktą" + "Tema" + "Tema: " + "%s%s" + "Įkeliama kontakto kortelė" + "Nepavyko įkelti kontakto kortelės" + "Peržiūrėti kontakto kortelę" + + %d kontaktas + %d kontaktai + %d kontakto + %d kontaktų + + "Kontaktų kortelės" + "Gimimo data" + "Užrašai" + "Persiųsti pranešimą" + "Atsakyti" + "+1" + "+%d" + "SMS išjungta" + "Kad galėtumėte išsiųsti, nustatykite Susirašinėjimo programą kaip numatytąją SMS programą" + "Nustatykite Susirašinėjimo programą kaip numatytąją SMS programą" + "Pakeisti" + "Kad galėtumėte gauti pranešimus, nustatykite Susirašinėjimo programą kaip numatytąją SMS programą" + "Nepasirinkta jokių pageidaujamų SIM kortelių, kurias būtų galima naudoti SMS pranešimams siųsti" + "Įrenginio savininkas neleidžia naudoti šios programos." + "Gerai" + "Per daug pokalbio dalyvių" + + Netinkami kontaktai + Netinkami kontaktai + Netinkami kontaktai + Netinkami kontaktai + + "Nepavyko įkelti fotoaparato vaizdo" + "Jūs: " + "%s: " + "Juodraštis" + "Kai pradėsite naują pokalbį, jis bus pateiktas čia" + "Archyvuoti pokalbiai rodomi čia" + "Įkeliami pokalbiai..." + "Nuotrauka" + "Garso įrašo klipas" + "Vaizdo įrašas" + "Kontakto kortelė" + "MMS" + "Anuliuoti" + "Bandyti dar kartą" + "Įveskite kontakto vardą arba telefono numerį, kad pradėtumėte naują pranešimą" + "Blokuoti" + "Blokuoti %s" + "Panaikinti %s blokavimą" + "Blokuoti %s?" + "Ir toliau gausite pranešimus iš šio numerio, bet nebūsite apie juos įspėti. Šis pokalbis bus archyvuotas." + "Užblokuoti kontaktai" + "PANAIKINTI BLOKAVIMĄ" + "Užblokuoti kontaktai" + "Pasirinkite vaizdą iš dokumentų bibliotekos" + "Siunčiamas pranešimas" + "Pranešimas išsiųstas" + "Mobiliojo ryšio duomenys išjungti. Patikrinkite nustatymus." + "Negalima siųsti pranešimų lėktuvo režimu" + "Nepavyko išsiųsti pranešimo" + "Pranešimas atsisiųstas" + "Mobiliojo ryšio duomenys išjungti. Patikrinkite nustatymus." + "Nepavyksta atsisiųsti pranešimų lėktuvo režimu" + "Pranešimo nepavyko atsisiųsti" + "Nulis" + "Vienas" + "Du" + "Trys" + "Keturi" + "Penki" + "Šeši" + "Septyni" + "Aštuoni" + "Devyni" + "Nepavyko išsiųsti pranešimo; operatorius „%1$s“, %2$d klaida" + "Nepavyko išsiųsti pranešimo; operatorius nežinomas, %1$d klaida" + "Pers.: %s" + "Pranešimas neišsiųstas: paslauga nesuaktyvinta tinkle" + "Pranešimas neišsiųstas: netinkamas paskirties adresas" + "Pranešimas neišsiųstas: netinkamas pranešimas" + "Pranešimas neišsiųstas: nepalaikomas turinys" + "Pranešimas neišsiųstas: nepalaikomas pranešimas" + "Pranešimas neišsiųstas: per didelis" + "Naujas pranešimas" + "Peržiūrėti" + "Vaizdas" + "Nepavyko rasti tinkamos programos" + "Pašalinti gavėją" + "Naujas pranešimas" + "Atšaukti" + "Redaguoti prieigos tašką" + "Nenustatyta" + "Pavadinimas" + "APN" + "MMSC" + "MMS tarpinis serveris" + "MMS prievadas" + "MCC" + "MNC" + "APN tipas" + "Ištrinti APN" + "Naujas APN" + "Išsaugoti" + "Atmesti" + "Pavadinimo laukas negali būti tuščias." + "APN laukas negali būti tuščias." + "MCC lauke turi būti įvesti 3 skaitmenys." + "MNC lauke turi būti įvesti 2 ar 3 skaitmenys." + "Atkuriami numatytieji APN nustatymai." + "Iš naujo nustatyti numatytuosius nustatymus" + "Numatytųjų APN nustatymų nustatymas iš naujo baigtas." + "Be pavadinimo" + "Prieigos taškų pavadinimai" + "APN" + "Naujas APN" + "Prieigos taško pavadinimo nustatymai šiam naudotojui nepasiekiami" + "Kopijuoti į iškarpinę?" + "Kopijuoti" + %s" + "Bendrieji" + "Išplėstiniai" + "Bendrieji nustatymai" + "Išplėstiniai nustatymai" + "„%s“ SIM" + "Siųsti individualius SMS pranešimus visiems gavėjams. Atsakymus gausite tik jūs" + "Siųsti vieną MMS visiems gavėjams" + "Nežinomas numeris" + "Naujas pranešimas" + "Naujas pranešimas." + "SIM pasirinkimas" + "Pasirinkta: %1$s, SIM pasirinkimas" + "Redaguoti temą" + "Pasirinkite SIM arba redaguokite temą" + "Palaikykite palietę, kad įrašytumėte garso įrašą" + "Pradėti naują pokalbį" + "Susirašinėjimo programa" + "Susirašinėjimo programos sąrašas" + "Susirašinėjimo programa" + "Naujas pranešimas" + "Pokalbių sąrašas" + "Įkeliami pokalbiai" + "Įkeliami pranešimai" + "Peržiūrėti daugiau pokalbių" + "Peržiūrėti daugiau pranešimų" + "Pokalbis ištrintas" + "Pokalbis ištrintas. Palieskite, kad būtų rodomas kitas Susirašinėjimo programos pokalbis." + "Užblokuota" + "Atblokuota" + "Mažai laisvos saugyklos vietos. Kai kurie duomenys gali būti prarasti." + "Pasirinkti priedus" + "Patvirtinti pasirinkimą" + "Pasirinkta: %d" + "Pašalinkite bent vieną priedą ir bandykite dar kartą." + "Galite pabandyti siųsti pranešimą, bet gali nepavykti jo pristatyti, nebent pašalinsite bent vieną priedą." + "Viename pranešime galite siųsti tik vieną vaizdo įrašą. Pašalinkite papildomus vaizdo įrašus ir bandykite dar kartą." + "Susirašinėjimo programai nepavyko įkelti priedo." + "Vis tiek siųsti" + "Nepavyko pradėti pokalbio" + "%1$s (%2$s)" + "Pasirinkta %s" + diff --git a/res/values-lv/arrays.xml b/res/values-lv/arrays.xml new file mode 100644 index 0000000..0e130b7 --- /dev/null +++ b/res/values-lv/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "bez temata" + "bez temata" + + + "Jā" + "Nē" + "Labi" + "He-he" + "Paldies!" + "Piekrītu" + "Jauki" + "Esmu ceļā" + "Labi, vēlāk ar tevi sazināšos." + ":)" + ":(" + + diff --git a/res/values-lv/strings.xml b/res/values-lv/strings.xml new file mode 100644 index 0000000..a3195b3 --- /dev/null +++ b/res/values-lv/strings.xml @@ -0,0 +1,545 @@ + + + + + "Ziņojumapmaiņa" + "Ziņojumapmaiņa" + "Atlasiet sarunu" + "Iestatījumi" + "Nosūtīt ziņojumu" + "Pievienot pielikumu" + "Palīdzība" + "Laipni lūdzam!" + "Izlaist" + "Tālāk >" + "Tālāk" + "Iziet" + "Iestatījumi >" + "Iestatījumi" + "Lietotnei Ziņojumapmaiņa ir nepieciešama piekļuves atļauja īsziņām, kā arī lietotnēm Tālrunis un Kontaktpersonas." + "Atļaujas varat mainīt sadaļā Iestatījumi > Lietotnes > Ziņojumapmaiņa > Atļaujas." + "Atļaujas varat mainīt sadaļā Iestatījumi > Lietotnes > Ziņojumapmaiņa > Atļaujas." + "Biežāk izmantotās" + "Visas kontaktpersonas" + "Nosūtīt uz: %s" + "Uzņemt attēlus vai video" + "Izvēlieties attēlus no šīs ierīces." + "Ierakstīt audio" + "Fotoattēla izvēle" + "Saturs ir atlasīts." + "Satura atlase ir noņemta." + "Atlasīts: %d" + "attēls, %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "attēls" + "Ierakstīt audio" + "Kopīgot" + "Tikko" + "Tikko" + + %d min + %d min + %d min + + + %d stundas + %d stunda + %d stundas + + + %d dienas + %d diena + %d dienas + + + %d nedēļas + %d nedēļa + %d nedēļas + + + %d mēneši + %d mēnesis + %d mēneši + + + %d gadi + %d gads + %d gadi + + "0. klases ziņojums" + "Saglabāt" + "Ierīcē ir maz vietas. Lai atbrīvotu vietu, lietotnē Ziņojumapmaiņa tiks automātiski dzēsti vecāki ziņojumi." + "Paliek maz brīvas vietas" + "Kamēr ierīcē nebūs vairāk vietas, lietotnē Ziņojumapmaiņa, iespējams, nevarēs sūtīt vai saņemt ziņojumus." + "Maz vietas īsziņām. Iespējams, jums būs jādzēš īsziņas." + "Tālruņa numura apstiprināšana" + "Šī vienreizējā darbība ir nepieciešama, lai lietotnē Ziņojumapmaiņa tiktu pareizi piegādāti jūsu grupas ziņojumi." + "Tālruņa numurs" + "Dzēst visus ziņojumus, kas ietver multivides saturu" + "Dzēst ziņojumus, kas vecāki par %s" + "Automātiski dzēst ziņojumus, kas vecāki par: %s" + "Ignorēt" + "Vai dzēst visas ziņas, kurās ietverts multivides saturs?" + "Vai dzēst ziņojumus, kas vecāki par: %s?" + "Vai dzēst ziņojumus, kas vecāki par: %s, un ieslēgt automātisko dzēšanu?" + "%s rakstīja" + "Jūs rakstījāt" + "Ziņojums no %s" + "Jūs nosūtījāt ziņojumu" + "Notiek sūtīšana…" + "Neizdevās nosūtīt. Pieskarieties, lai mēģinātu vēlreiz." + "Neizdevās nosūtīt. Notiek atkārtots mēģinājums…" + "Sūtiet vēlreiz vai dzēsiet vienumu" + "Veiciet balss zvanu uz avārijas dienesta tālruņa numuru. Pašlaik nevar nosūtīt īsziņu." + "Neizdevās" + "Jauna lejupielādējama multiziņa" + "Jauna multiziņa" + "Nevarēja lejupielādēt" + "Pieskarieties, lai mēģinātu vēlreiz" + "Pieskarieties, lai lejupielādētu" + "Lejupielādējiet vai dzēsiet" + "Notiek lejupielāde…" + "Ziņojums nav pieejams, vai ir beidzies tā derīguma termiņš" + "lielums: %1$s; derīgs līdz: %2$s" + "Nevar nosūtīt ziņojumu. Adresāts nav derīgs." + "Pakalpojums tīklā nav aktivizēts." + "Nevarēja nosūtīt tīkla problēmas dēļ." + "Ziņa nav pieejama, vai ir beidzies tās derīguma termiņš." + "(Bez temata)" + "Nezināms sūtītājs" + "Piegādāts" + "Nevarēja lejupielādēt no sūtītāja %2$s saņemto ziņojumu “%1$s”." + "Nevarēja pabeigt datu bāzes darbību, jo nav vietas atmiņā." + "Ziņojums netika nosūtīts." + "Lietotnē Ziņojumapmaiņa netika nosūtīti daži ziņojumi." + + %d ziņojumi %d sarunās + %d ziņojumi %d sarunā + %d ziņojumi %d sarunās + + "Ziņojums nav lejupielādēts." + "Lietotnē Ziņojumapmaiņa nav lejupielādēti daži ziņojumi." + + %d ziņojumi %d sarunās + %d ziņojumi %d sarunā + %d ziņojumi %d sarunās + + "Ziņojums netika nosūtīts uz tālruņa numuru %1$s." + "Veiciet balss zvanu uz avārijas dienesta tālruņa numuru. Pašlaik nevar nosūtīt īsziņu uz tālruņa numuru %1$s." + + %d jauni ziņojumi + %d jauns ziņojums + %d jauni ziņojumi + + "Ieslēgt" + "Kamera nav pieejama." + "Kamera nav pieejama." + "Video tveršana nav pieejama." + "Nevar saglabāt multivides failu." + "Nevar uzņemt attēlu" + "Atpakaļ" + "Arhīvs" + "Dzēst" + "Arhivēt" + "Izņemt no arhīva" + "Izslēgt paziņojumus" + "Ieslēgt paziņojumus" + "Pievienot kontaktpersonu" + "Lejupielādēt" + "Sūtīt" + "Dzēst" + "Vai dzēst šo ziņojumu?" + "Šo darbību nevar atsaukt." + "Dzēst" + + Vai dzēst šīs sarunas? + Vai dzēst šīs sarunas? + Vai dzēst šīs sarunas? + + "Dzēst" + "Atcelt" + "Kam:" + "Atlasīt vairākus attēlus" + "Apstiprināt atlasi" + "Vēl %d" + "Nevar ierakstīt audio. Mēģiniet vēlreiz." + "Nevar atskaņot audio. Mēģiniet vēlreiz." + "Nevarēja saglabāt audio. Mēģiniet vēlreiz." + "Pieskarieties un turiet." + ", " + " " + ": " + " " + "Attēls" + "Audioklips" + "Videoklips" + "Kontaktpersonas kartīte" + "Lejupielādēt" + "Atbildēt ar īsziņu" + "Atbildēt ar MMS" + "Atbildēt" + + %d dalībnieki + %d dalībnieks + %d dalībnieki + + "Es" + "Kontaktpersona ir bloķēta un arhivēta" + "Kontaktpersona ir atbloķēta un atarhivēta." + "Arhivētas: %d" + "Atcelta arhivēšana: %d" + "Paziņojumi ir izslēgti." + "Paziņojumi ir ieslēgti." + "Iestatīšana ir pabeigta. Vēlreiz pieskarieties vienumam Sūtīt." + "Lietotne Ziņojumapmaiņa ir sekmīgi iestatīta kā noklusējuma īsziņu lietotne." + + Atmest pielikumus + Atmest pielikumu + Atmest pielikumus + + "Audio pielikums" + "Atskaņot audio pielikumu" + "Pārtraukt" + "Ziņojums no lietotāja %s: %s." + "Neizdevās saņemt ziņojumu no kontaktpersonas %s: %s. Laiks: %s." + "Ziņojums no kontaktpersonas %s: %s. Laiks: %s." + "Nenosūtīts ziņojums kontaktpersonai %s: %s. Laiks: %s." + "Notiek ziņojuma sūtīšana kontaktpersonai %s: %s. Laiks: %s." + "Neizdevās nosūtīt ziņojumu kontaktpersonai %s: %s. Laiks: %s." + "Ziņojums kontaktpersonai %s: %s. Laiks: %s." + "Neizdevās saņemt ziņojumu no kontaktpersonas %s: %s. Laiks: %s. %s." + "Ziņojums no kontaktpersonas %s: %s. Laiks: %s. %s." + "Nenosūtīts ziņojums grupai %s: %s. Laiks: %s." + "Notiek ziņojuma sūtīšana grupai %s: %s. Laiks: %s." + "Neizdevās nosūtīt ziņojumu grupai %s: %s. Laiks: %s." + "Ziņojums grupai %s: %s. Laiks: %s." + "Radās problēma ar ziņojumu. Pieskarieties, lai mēģinātu vēlreiz." + "Saruna ar grupu: %s" + "Dzēst tēmu" + "Uzņemt video" + "Uzņemt fotoattēlu" + "Uzņemt attēlu" + "Sākt video ierakstīšanu" + "Pārslēgties uz pilnekrāna kameru" + "Pārslēgties no priekšējās kameras uz aizmugurējo kameru un otrādi" + "Pārtraukt ierakstīšanu un pievienot videoklipu" + "Pārtraukt videoklipa ierakstīšanu" + "Fotoattēli lietotnē Ziņojumapmaiņa" + + %d fotoattēli tika saglabāti albumā “%s + %d fotoattēls tika saglabāts albumā “%s + %d fotoattēli tika saglabāti albumā “%s + + + %d videoklipi tika saglabāti albumā “%s + %d videoklips tika saglabāts albumā “%s + %d videoklipi tika saglabāti albumā “%s + + + %d pielikumi tika saglabāti albumā “%s + %d pielikums tika saglabāts albumā “%s + %d pielikumi tika saglabāti albumā “%s + + + %d pielikumi tika saglabāti mapē “Lejupielādes” + %d pielikums tika saglabāts mapē “Lejupielādes” + %d pielikumi tika saglabāti mapē “Lejupielādes” + + + Tika saglabāti %d pielikumi. + Tika saglabāts %d pielikums. + Tika saglabāti %d pielikumi. + + + Nevarēja saglabāt %d pielikumus. + Nevarēja saglabāt %d pielikumu. + Nevarēja saglabāt %d pielikumus. + + "Multiziņas pielikums tika saglabāts." + "Iestatījumi" + "Arhīvs" + "Aizvērt" + "Multiziņa" + "Papildu" + "Atkļūdot" + "Paziņojumi" + "Signāls" + "Klusums" + "Vibrozvans" + "Bloķēts" + "Īsziņu piegādes atskaites" + "Pieprasiet piegādes atskaiti par katru sūtīto īsziņu." + "Automātiski izgūt" + "Automātiski izgūstiet multiziņas." + "Autom. izgūšana viesabon. laikā" + "Automātiska multiziņu izgūšana viesabonēšanas laikā" + "Grupas ziņojumapmaiņa" + "Izmantojiet multiziņu, lai vairākiem adresātiem nosūtītu vienu ziņu." + "Noklusējuma īsziņu lietotne" + "Noklusējuma īsziņu lietotne" + + + "Jūsu tālruņa numurs" + "Nezināms" + "Izejošo ziņojumu signāli" + "Īsziņu izrakstīšana" + "Izrakstiet saņemto īsziņu jēldatus ārējā krātuves failā." + "Multiziņu izrakstīšana" + "Izrakstiet saņemto multiziņu jēldatus ārējā krātuves failā." + "Bezvadu brīdinājumi" + "Ziņojuma opcijas" + "Kopēt tekstu" + "Skatīt detalizētu informāciju" + "Dzēst" + "Pārsūtīt" + "Detalizēta informācija par ziņojumu" + "Veids: " + "Īsziņa" + "Multiziņa" + "No: " + "Kam: " + "Nosūtīts: " + "Saņemts: " + "Temats: " + "Lielums: " + "Prioritāte: " + "SIM: " + "Augsta" + "Parasta" + "Zema" + "SIM %s" + "Sūtītāja adrese ir slēpta" + "Ziņojumu nevar nosūtīt, kamēr tiek ielādēti pielikumi." + "Nevar ielādēt pielikumu. Mēģiniet vēlreiz." + "Tīkls nav gatavs lietošanai. Mēģiniet vēlreiz." + "Dzēst tekstu" + "Pārslēgties starp teksta un ciparu ievadīšanu" + "Pievienot vēl dalībniekus" + "Apstiprināt dalībniekus" + "Sākt jaunu sarunu" + "Atlasīt šo vienumu" + "Atskaņot videoklipu" + "Personas un opcijas" + "Atkļūdot" + "Personas un opcijas" + "Vispārīgi" + "Sarunas dalībnieki" + "Zvanīt" + "Sūtīt ziņojumu" + "Sūtiet īsziņu,<br/><small>izmantojot šo SIM: %s</small>" + + Sūtīt fotoattēlus + Sūtīt fotoattēlus + Sūtīt fotoattēlus + + + Sūtīt audio failus + Sūtīt audio failus + Sūtīt audio failus + + + Sūtīt videoklipus + Sūtīt videoklipus + Sūtīt videoklipus + + + Sūtīt kontaktpersonu kartītes + Sūtīt kontaktpersonu kartītes + Sūtīt kontaktpersonu kartītes + + + Sūtīt pielikumus + Sūtīt pielikumus + Sūtīt pielikumus + + + Nav atlasīts neviens pielikums. + %d pielikums ir gatavs nosūtīšanai. + %d pielikumi ir gatavi nosūtīšanai. + + "Sūtīt atsauksmes" + "Skatīt Google Play veikalā" + "Informācija par versiju" + "Versija %1$s" + "Atklātā pirmkoda licences" + "Paziņojumi" + "Sasniegts pielikumu ierobežojums." + "Neizdevās ielādēt pielikumu." + "Vai pievienot kontaktpersonām?" + "Pievienot kontaktpersonu" + "Temats" + "Temats: " + "%s%s" + "Notiek kontaktpersonas kartītes ielāde…" + "Nevarēja ielādēt kontaktpersonas kartīti." + "Skatiet kontaktpersonas kartīti" + + %d kontaktpersonas + %d kontaktpersona + %d kontaktpersonas + + "Kontaktpersonu kartītes" + "Dzimšanas diena" + "Piezīmes" + "Ziņojuma pārsūtīšana" + "Atbildēt" + "+1" + "+%d" + "Īsziņas ir atspējotas." + "Lai sūtītu īsziņas, iestatiet lietotni Ziņojumapmaiņa kā noklusējuma īsziņu lietotni." + "Iestatiet lietotni Ziņojumapmaiņa kā noklusējuma īsziņu lietotni." + "Mainīt" + "Lai saņemtu īsziņas, iestatiet lietotni Ziņojumapmaiņa kā noklusējuma īsziņu lietotni." + "Īsziņu sūtīšanai nav atlasīta vēlamā SIM karte" + "Ierīces īpašnieks nav atļāvis šīs lietotnes darbību." + "Labi" + "Sarunā ir pārāk daudz dalībnieku." + + Nederīgas kontaktpersonas + Nederīga kontaktpersona + Nederīgas kontaktpersonas + + "Nevarēja ielādēt kameras attēlu." + "Jūs: " + "%s: " + "Melnraksts" + "Jaunas sarunas tiks rādītas šeit." + "Šeit tiek rādītas arhivētās sarunas." + "Notiek sarunu ielāde..." + "Attēls" + "Audioklips" + "Videoklips" + "Kontaktpersonas kartīte" + "Multiziņa" + "Atsaukt" + "Mēģināt vēlreiz" + "Ievadiet kontaktpersonas vārdu vai tālruņa numuru, lai sāktu rakstīt jaunu ziņojumu." + "Bloķēt" + "Bloķēt: %s" + "Atbloķēt: %s" + "Vai bloķēt %s?" + "Jūs turpināsiet saņemt ziņojumus no šī numura, taču vairs nesaņemsiet par tiem paziņojumus. Šī saruna tiks arhivēta." + "Bloķētās kontaktpersonas" + "ATBLOĶĒT" + "Bloķētās kontaktpersonas" + "Izvēlēties attēlu no dokumentu bibliotēkas" + "Notiek ziņojuma sūtīšana" + "Ziņojums nosūtīts" + "Mobilo datu savienojums ir izslēgts. Pārbaudiet iestatījumus." + "Lidojuma režīmā nevar nosūtīt ziņojumus." + "Ziņojumu nevarēja nosūtīt." + "Ziņojums lejupielādēts" + "Mobilo datu savienojums ir izslēgts. Pārbaudiet iestatījumus." + "Ziņojumus nevar lejupielādēt lidojuma režīmā" + "Ziņojumu nevarēja lejupielādēt" + "Nulle" + "Viens" + "Divi" + "Trīs" + "Četri" + "Pieci" + "Seši" + "Septiņi" + "Astoņi" + "Deviņi" + "Nevar nosūtīt ziņojumu: operatora %1$s kļūda %2$d" + "Nevar nosūtīt ziņojumu: nezināma operatora kļūda %1$d" + "Pārs.: %s" + "Ziņojums netika nosūtīts: pakalpojums nav aktivizēts tīklā." + "Ziņojums nav nosūtīts: nederīga galamērķa adrese" + "Ziņojums nav nosūtīts: nederīgs ziņojums" + "Ziņojums nav nosūtīts: neatbalstīts saturs" + "Ziņojums nav nosūtīts: neatbalstīts ziņojums" + "Ziņojums nav nosūtīts: pārāk liels" + "Jauns ziņojums" + "Skatīt" + "Attēls" + "Neizdevās atrast atbilstošu lietojumprogrammu." + "Noņemt adresātu" + "Jauns ziņojums" + "Atcelt" + "Piekļuves punkta rediģēšana" + "Nav iestatīts" + "Nosaukums" + "APN" + "MMSC" + "Multiziņu starpniekserveris" + "Multiziņu ports" + "MCC" + "MNC" + "APN veids" + "Dzēst APN" + "Jauns APN" + "Saglabāt" + "Atmest" + "Nosaukuma lauks nedrīkst būt tukšs." + "APN lauks nedrīkst būt tukšs." + "MCC laukā ir jābūt 3 cipariem." + "MNC laukā ir jābūt 2 vai 3 cipariem." + "Notiek APN noklusējuma iestatījumu atjaunošana." + "Atiestatīt noklusējuma vērtības" + "Noklusējuma APN iestatījumu atiestatīšana ir pabeigta." + "Bez nosaukuma" + "Piekļuves punktu nosaukumi" + "APN" + "Jauns APN" + "Piekļuves punkta nosaukuma iestatījumi šim lietotājam nav pieejami." + "Vai kopēt starpliktuvē?" + "Kopēt" + "šajā SIM: %s" + "Vispārīgi" + "Papildu" + "Vispārīgi iestatījumi" + "Papildu iestatījumi" + "SIM kartes “%s” iestatījumi" + "Sūtīt atsevišķas īsziņas visiem adresātiem (atbildes saņemsiet tikai jūs)" + "Sūtīt vienu multiziņu visiem adresātiem" + "Nezināms numurs" + "Jauns ziņojums" + "Jauns ziņojums" + "SIM kartes atlasītājs" + "Atlasīta %1$s, SIM kartes atlasītājs" + "Rediģēt tematu" + "Atlasīt SIM karti vai rediģēt tematu" + "Pieskarieties un turiet, lai ierakstītu skaņu." + "Sākt jaunu sarunu" + "Ziņojumapmaiņa" + "Sarunu saraksts lietotnē Ziņojumapmaiņa" + "Ziņojumapmaiņa" + "Jauns ziņojums" + "Sarunu saraksts" + "Notiek sarunu ielāde" + "Notiek ziņojumu ielāde." + "Skatīt citas sarunas" + "Skatīt vairāk ziņojumu" + "Saruna ir izdzēsta" + "Saruna dzēsta. Pieskarieties, lai skatītu citu sarunu lietotnē Ziņojumapmaiņa." + "Bloķēts" + "Atbloķēts" + "Krātuvē ir maz vietas. Daļa datu var tikt zaudēta." + "Pielikumu atlasīšana" + "Apstiprināt atlasi" + "Atlasīti: %d" + "Lūdzu, noņemiet vienu vai vairākus pielikumus un mēģiniet vēlreiz." + "Varat mēģināt sūtīt ziņojumu, taču tas, iespējams, netiks piegādāts, ja vien nenoņemsiet vienu vai vairākus pielikumus." + "Ziņojumam var pievienot tikai vienu videoklipu. Lūdzu, noņemiet liekos videoklipus un mēģiniet vēlreiz." + "Lietotnē Ziņojumapmaiņa neizdevās ielādēt pielikumu." + "Tik un tā sūtīt" + "Nevarēja sākt sarunu" + "%1$s (%2$s)" + "Atlasīts: %s" + diff --git a/res/values-mk-rMK/arrays.xml b/res/values-mk-rMK/arrays.xml new file mode 100644 index 0000000..eba8db7 --- /dev/null +++ b/res/values-mk-rMK/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "без наслов" + "без тема" + + + "Да" + "Не" + "Во ред" + "Хехе" + "Фала" + "Се согласувам" + "Убаво" + "На пат сум" + "Во ред, ќе ти јавам подоцна" + ":)" + ":(" + + diff --git a/res/values-mk-rMK/strings.xml b/res/values-mk-rMK/strings.xml new file mode 100644 index 0000000..98f871e --- /dev/null +++ b/res/values-mk-rMK/strings.xml @@ -0,0 +1,519 @@ + + + + + "Пораки" + "Пораки" + "Изберете разговор" + "Поставки" + "Испрати порака" + "Додај прилог" + "Помош" + "Добре дојдовте" + "Прескокни" + "Следно >" + "Следно" + "Излези" + "Поставки >" + "Поставки" + "На Messaging му треба дозвола за СМС, телефон и контакти." + "Дозволите може да ги промените во Поставки > Апликации > Messaging > Дозволи." + "Дозволите може да ги промените во Поставки, Апликации, Messaging, Дозволи." + "Чести" + "Сите контакти" + "Испрати на %s" + "Снимај слики или видео" + "Изберете слики од уредот" + "Снимај аудио" + "Избери фотографија" + "Медиумот е избран." + "Медиумот е неизбран." + "Избрани: %d" + "слика %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "слика" + "Снимај аудио" + "Сподели" + "Пред малку" + "Сега" + + %d мин. + %d мин. + + + %d час + %d часа + + + %d ден + %d дена + + + %d седмица + %d седмици + + + %d месец + %d месеци + + + %d година + %d години + + "Порака 0 класа" + "Зачувај" + "Уредот нема доволно меморија. Messaging автоматски ќе ги брише постарите текстуални пораки за да ослободи простор." + "Меморијата е речиси полна" + "Messaging може да не испраќа или прима пораки сѐ додека не се ослободи повеќе простор на уредот." + "Малку простор за складирање СМС. Можеби ќе треба да избришете пораки." + "Потврдете го својот телефонски број" + "Со овој еднократен чекор ќе се погрижите Messaging правилно да ви ги испорачува групните пораки." + "Телефонски број" + "Избриши ги сите пораки со медиуми" + "Избриши ги пораките постари од %s" + "Автоматско бришење пораки постари од %s" + "Игнорирај" + "Да се избришат сите пораки со медиуми?" + "Да се избришат пораките постари од %s?" + "Да се избришат пораките постари од %s и да се вклучи автоматското бришење?" + "%s рече" + "Рековте" + "Порака од %s" + "Испративте порака" + "Се испраќа..." + "Не се испрати. Допрете за да се обидете повторно." + "Не се испрати. Се обидуваме повторно…" + "Испратете повторно или избришете" + "Направете гласовен повик до службите за итни случаи. Вашата текстуална порака не може да се испорача во ова време." + "Неуспешно" + "Нова ММС-порака за преземање" + "Нова ММС-порака" + "Не можеше да се преземе" + "Допрете да се обидете повторно" + "Допри за преземање" + "Преземете или избришете" + "Се презема..." + "Пораката е истечена или не е достапна" + "големина: %1$s, истекување: %2$s" + "Не може да се испрати. Примачот не е важечки." + "Услугата не е активирана на мрежата" + "Не можеше да се испрати поради проблем со мрежата" + "Пораката е истечена или не е достапна" + "(Без наслов)" + "Непознат испраќач" + "Доставено" + "Пораката %1$s од %2$s не може да се преземе." + "Не може да се заврши дејството во базата со податоци поради мала меморија" + "Пораката не се испрати" + "Некои пораки не се испратени во Messaging" + + %d пораки во %d разговор + %d пораки во %d разговори + + "Пораката не е преземена" + "Некои пораки не се преземени во Messaging" + + %d пораки во %d разговор + %d пораки во %d разговори + + "Пораката до %1$s не се испрати" + "Направете гласовен повик до службите за итни случаи. Вашата текстуална порака до %1$s не може да се испорача во ова време." + + %d нова порака + %d нови пораки + + "Започни" + "Нема фотоапарат на располагање" + "Нема фотоапарат на располагање" + "Снимањето видео не е достапно" + "Медиумите не може да се зачуваат" + "Не може да фотографира" + "Назад" + "Архивирано" + "Бришење" + "Архивирање" + "Извади од архива" + "Исклучи известувања" + "Вклучи известувања" + "Додај контакт" + "Преземи" + "Испрати" + "Избриши" + "Да се избрише пораката?" + "Дејството не може да се врати." + "Избриши" + + Да се избрише разговоров? + Да се избришат разговориве? + + "Избриши" + "Откажи" + "До" + "Избери повеќе слики" + "Потврдете го изборот" + "+%d" + "Аудиото не може да се сними. Обидете се повторно." + "Аудиото не може да се пушти. Обидете се повторно." + "Аудиото не можеше да се зачува. Обидете се повторно." + "Допри и задржи" + ", " + " " + ": " + " " + "Слика" + "Аудиоклип" + "Видео" + "Картичка за контакти" + "Преземи" + "Одговори преку СМС" + "Одговори преку ММС" + "Одговори" + + %d учесник + %d учесници + + "Јас" + "Контактот е блокиран и архивиран" + "Одблокиран и одархивиран контакт" + "%d архивирани" + "%d извадени од архива" + "Известувањата се исклучени" + "Известувањата се вклучени" + "Сè е подготвено. Допрете Испрати повторно." + "Messaging е успешно поставен како стандардна апликација за СМС." + + Отфрли го прилогот + Отфрли ги прилозите + + "Аудио прилог" + "Репродуцирај аудио прилог" + "Пауза" + "Порака од %s: %s." + "Неуспешна порака од %s: %s. Време: %s." + "Порака од %s: %s. Време: %s." + "Неиспратена порака до %s: %s. Време: %s." + "Се испраќа порака до %s: %s. Време: %s." + "Неуспешна порака до %s: %s. Време: %s." + "Порака до %s: %s. Време: %s." + "Неуспешна порака од %s: %s. Време: %s. %s." + "Порака од %s: %s. Време: %s. %s." + "Неиспратена порака до %s: %s. Време: %s." + "Се испраќа порака до %s: %s. Време: %s." + "Неуспешна порака до %s: %s. Време: %s." + "Порака до %s: %s. Време: %s." + "Неуспешна порака. Допрете за да се обидете повторно." + "Разговор со %s" + "Избриши тема" + "Снимај видео" + "Сними фотографија" + "Фотографирај" + "Започни со снимање видео" + "Префрлете на камера на цел екран" + "Префрли меѓу предна и задна камера" + "Престани со снимање и прикачи видео" + "Прекини со снимање видео" + "Фотографии во Messaging" + + %d фотографија се зачува во албумот „%s + %d фотографии се зачуваа во албумот „%s + + + %d видео се зачува во албумот „%s + %d видеа се зачуваа во албумот „%s + + + %d прилог се зачува во албумот „%s + %d прилози се зачуваа во албумот „%s + + + %d прилог се зачува во „Преземања“ + %d прилози се зачуваа во „Преземања“ + + + Зачуван е %d прилог + Зачувани се %d прилози + + + Не можеше да се зачува %d прилог + Не можеше да се зачуваат %d прилози + + "Зачуван ММС-прилог" + "Поставки" + "Архивирано" + "Затвори" + "ММС" + "Напредни" + "Отстрани грешка" + "Известувања" + "Звук" + "На тивко" + "Вибрации" + "Блокирано" + "Извештаи за испорака на СМС" + "Побарајте извештај за испорака за секоја СМС што ја испраќате" + "Автоматско преземање" + "Преземај ММС-пораки автоматски" + "Автоматско преземање во роаминг" + "Автоматски преземај MMS додека си во роаминг" + "Групно испраќање пораки" + "Користи ММС за испраќање една порака кога има повеќе примачи" + "Стандардна СМС апликација" + "Стандардна СМС апликација" + + + "Вашиот телефонски број" + "Непознато" + "Звуци на појдовни пораки" + "Извади СМС" + "Извади ги примените необработени податоци за СМС на надворешна датотека за складирање" + "Извади ММС" + "Извади ги примените необработени податоци за ММС на надворешна датотека за складирање" + "Безжични предупредувања" + "Опции на порака" + "Копирај текст" + "Прикажи детали" + "Избриши" + "Проследи" + "Детали на порака" + "Тип: " + "Текстуална порака" + "Мултимедијална порака" + "Од: " + "До: " + "Испратено: " + "Примени: " + "Предмет: " + "Големина: " + "Приоритет: " + "СИМ: " + "Високо" + "Нормално" + "Ниско" + "СИМ %s" + "Скриена адреса на испраќачот" + "Не може да се испрати порака додека се вчитуваат прилозите." + "Прилогот не може да се вчита. Обидете се повторно." + "Мрежата не е подготвена. Обидете се повторно." + "Избриши текст" + "Префрлајте меѓу внесување текст и броеви" + "Додај повеќе учесници" + "Потврди учесници" + "Започнете нов разговор" + "Изберете ја ставкава" + "Пушти видео" + "Луѓе и опции" + "Отстрани грешка" + "Луѓе и опции" + "Општи" + "Луѓе во овој разговор" + "Повикај" + "Испрати порака" + "Испратете порака <br/><small>од %s</small>" + + Испрати ја фотографијата + Испрати ги фотографиите + + + Испрати го аудиото + Испрати ги аудиата + + + Испрати го видеото + Испрати ги видеата + + + Испрати ја картичката за контакт + Испрати ги картичките за контакт + + + Испрати го прилогот + Испрати ги прилозите + + + %d прилог е подготвен за испраќање + %d прилози се подготвени за испраќање + + "Испрати повратни информации" + "Прикажи во продавницата на Google Play" + "Информации за верзијата" + "Верзија %1$s" + "Лиценци со отворен код" + "Известувања" + "Достигнато е ограничувањето за прилози" + "Не успеа да се вчита прилогот." + "Да додадеме во контакти?" + "Додај контакт" + "Предмет" + "Предмет: " + "%s%s" + "Се вчитува картичката за контакт" + "Картичката за контакт не можеше да се вчита" + "Прикажи картичка за контакти" + + %d контакт + %d контакти + + "Картички за контакт" + "Роденден" + "Белешки" + "Проследи порака" + "Одговори" + "+1" + "+%d" + "СМС-пораките се оневозможени" + "За испраќање, поставете го Messaging како стандардна апликација за СМС" + "Поставете го Messaging како стандардна апликација за СМС" + "Промени" + "За примање пораки, поставете го Messaging како стандардна апликација за СМС" + "Не е избрана претпочитана СИМ за испраќање СМС-пораки" + "Оваа апликација не е дозволена од сопственикот на уредот." + "Во ред" + "Премногу учесници во разговорот" + + Неважечки контакт + Неважечки контакти + + "Слика од камерата не можеше да се вчита" + "Вие: " + "%s: " + "Нацрт" + "Штом започнете нов разговор, ќе го видите наведен тука" + "Архивираните разговори се појавуваат тука" + "Се вчитуваат разговори…" + "Слика" + "Аудиоклип" + "Видео" + "Картичка за контакти" + "ММС" + "Врати" + "Обидете се повторно" + "Внесете име на контактот или телефонски број за да започнете нова порака" + "Блокирај" + "Блокирај %s" + "Одблокирај %s" + "Да се блокира %s?" + "Ќе продолжите да добивате пораки од овој број, но веќе нема да добивате известувања. Разговорот ќе се архивира." + "Блокирани контакти" + "ОДБЛОКИРАЈ" + "Блокирани контакти" + "Изберете слика од библиотеката со документи" + "Пораката се испраќа" + "Пораката е испратена" + "Мобилните податоци се исклучени. Проверете ги поставките." + "Не може да се испраќаат пораки во авионски режим" + "Пораката не може да се испрати." + "Пораката е преземeна" + "Мобилните податоци се исклучени. Проверете ги поставките." + "Не може да се преземаат пораки во авионски режим" + "Пораката не можеше да се преземе" + "Нула" + "Еден" + "Два" + "Три" + "Четири" + "Пет" + "Шест" + "Седум" + "Осум" + "Девет" + "Пораката не може да се испрати преку %1$s, грешка %2$d" + "Пораката не може да се испрати преку непознат давател на услуга, грешка %1$d" + "Препрати: %s" + "Пораката не се испрати: услугата не е активирана на мрежата" + "Пораката не е испратена: неважечка адреса на одредиштето" + "Пораката не е испратена: неважечка порака" + "Пораката не е испратена: неподдржана содржина" + "Пораката не е испратена: неподдржана порака" + "Пораката не е испратена: преголема" + "Нова порака" + "Прикажи" + "Слика" + "Не може да се најде соодветна апликација" + "Отстрани го примачот" + "Нова порака" + "Откажи" + "Уреди пристапна точка" + "Не е поставено" + "Име" + "Име на пристапна точка (APN)" + "ММСЦ" + "Прокси на ММС" + "Порта за ММС" + "MCC" + "MNC" + "Тип APN" + "Избриши APN" + "Ново APN" + "Зачувај" + "Отфрли" + "Полето за име не може да биде празно." + "APN не може да биде празна." + "Полето MNC мора да содржи 3 цифри." + "Полето MNC мора да содржи 2 или 3 цифри." + "Враќање стандардни поставки на APN." + "Ресетирај на стандардни вредности" + "Ресетирањето стандардни поставки на APN е завршено." + "Без наслов" + "Имиња на пристапни точки" + "Поставување APN" + "Ново APN" + "Поставките за името на пристапната точка не се достапни за овој корисник" + "Копирајте во таблата со исечоци?" + "Копирај" + "до %s" + "Општи" + "Напредни" + "Општи поставки" + "Напредни поставки" + "СИМ „%s“" + "Испратете поединечни СМС-пораки до сите примачи. Само вие ќе добивате одговори" + "Испратете единствена ММС-порака до сите примачи" + "Непознат број" + "Нова порака" + "Нова порака." + "Бирач на СИМ" + "%1$s е избран, бирач на СИМ" + "Уреди предмет" + "Избери СИМ или уреди предмет" + "Допри и задржи за снимање аудио" + "Започнете нов разговор" + "Messaging" + "Список на Messaging" + "Messaging" + "Нова порака" + "Список со разговори" + "Разговорите се вчитуваат" + "Се вчитуваат пораки" + "Прикажи повеќе разговори" + "Прикажи повеќе пораки" + "Конверзацијата е избришана" + "Разговорот е избришан. Допрете за да се прикаже друг разговор на Messaging" + "Блокирано" + "Одблокирано" + "Просторот за складирање е премногу мал. Некои податоци може да се изгубат." + "Изберете прилози" + "Потврдете го изборот" + "Избрани %d" + "Отстранете еден или повеќе прилози и обидете се повторно." + "Може да се обидете да ја испратите пораката, но можеби нема да се испорача, освен ако не отстраните еден или повеќе прилози." + "Може да испраќате само едно видео по порака. Отстранете ги дополнителните видеа и обидете се повторно." + "Messaging не успеа да го вчита прилогот." + "Сепак испрати" + "Не може да се започне разговор" + "%1$s (%2$s)" + "Избрана е %s" + diff --git a/res/values-ml-rIN/arrays.xml b/res/values-ml-rIN/arrays.xml new file mode 100644 index 0000000..7454e1f --- /dev/null +++ b/res/values-ml-rIN/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "വിഷയമില്ല" + "വിഷയമൊന്നുമില്ല" + + + "ഉവ്വ്" + "ഇല്ല" + "ശരി" + "ഹിഹി" + "നന്ദി" + "ഞാൻ അംഗീകരിക്കുന്നു" + "കൊള്ളാം" + "മറുപടി നൽകാൻ തയ്യാറാണ്" + "ശരി, പിന്നീട് നിങ്ങളുമായി ബന്ധപ്പെടാൻ എന്നെ അനുവദിക്കുക" + ":)" + ":(" + + diff --git a/res/values-ml-rIN/strings.xml b/res/values-ml-rIN/strings.xml new file mode 100644 index 0000000..554cac3 --- /dev/null +++ b/res/values-ml-rIN/strings.xml @@ -0,0 +1,519 @@ + + + + + "സന്ദേശമയയ്‌ക്കൽ" + "സന്ദേശമയയ്‌ക്കൽ" + "സംഭാഷണം തിരഞ്ഞെടുക്കുക" + "ക്രമീകരണങ്ങൾ" + "സന്ദേശം അയയ്ക്കൂ" + "ഒരു അറ്റാച്ചുമെന്റ് ചേർക്കുക" + "സഹായം" + "സ്വാഗതം" + "ഒഴിവാക്കുക" + "അടുത്തത് >" + "അടുത്തത്" + "പുറത്തുകടക്കുക" + "ക്രമീകരണം >" + "ക്രമീകരണം" + "SMS, ഫോൺ, കോൺടാക്റ്റുകൾ എന്നിവയിലേക്ക് സന്ദേശമയയ്ക്കലിന് അനുമതി ആവശ്യമാണ്." + "ക്രമീകരണം > ആപ്സ് > സന്ദേശമയയ്ക്കൽ > അനുമതികൾ എന്നതിൽ നിങ്ങൾക്ക് അനുമതികൾ മാറ്റാവുന്നതാണ്." + "ക്രമീകരണം, ആപ്സ്, സന്ദേശമയയ്ക്കൽ, അനുമതികൾ എന്നതിൽ നിങ്ങൾക്ക് അനുമതികൾ മാറ്റാവുന്നതാണ്." + "പതിവായി കോൺടാക്‌റ്റുചെയ്യുന്നവർ" + "എല്ലാ കോൺടാക്റ്റുകളും" + "%s എന്നതിലേയ്ക്ക് അയയ്ക്കുക" + "ചിത്രങ്ങൾ അല്ലെങ്കിൽ വീഡിയോ എടുക്കുക" + "ഈ ഉപകരണത്തിൽ നിന്ന് ചിത്രങ്ങൾ തിരഞ്ഞെടുക്കുക" + "ഓഡിയോ റെക്കോർഡുചെയ്യുക" + "ഫോട്ടോ തിരഞ്ഞെടുക്കുക" + "മീഡിയ തിരഞ്ഞെടുത്തിരിക്കുന്നു." + "മീഡിയ തിരഞ്ഞെടുത്തത് മാറ്റി." + "%d എണ്ണം തിരഞ്ഞെടുത്തു" + "ചിത്രം %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "ചിത്രം" + "ഓഡിയോ റെക്കോർഡുചെയ്യുക" + "പങ്കിടുക" + "ഇപ്പോൾ" + "ഇപ്പോൾ" + + %d മിനിറ്റ് + %d മിനിറ്റ് + + + %d മണിക്കൂർ + %d മണിക്കൂർ + + + %d ദിവസം + %d ദിവസം + + + %d ആഴ്‌ച + ഒരു ആഴ്‌ച + + + %d മാസം + ഒരു മാസം + + + %d വർഷം + ഒരു വർഷം + + "ക്ലാസ് 0 സന്ദേശം" + "സംരക്ഷിക്കുക" + "ഉപകരണത്തിൽ ഇടം കുറവാണ്. ഇടം സ്വതന്ത്രമാക്കുന്നതിന് പഴയ സന്ദേശങ്ങളെ സന്ദേശമയയ്ക്കൽ സ്വയമേവ ഇല്ലാതാക്കും." + "സംഭരണയിടം കഴിഞ്ഞു" + "നിങ്ങളുടെ ഉപകരണത്തിൽ കൂടുതൽ ഇടം ലഭ്യമാകുന്നത് വരെ സന്ദേശമയയ്ക്കലിന് സന്ദേശങ്ങൾ അയയ്ക്കാനോ സ്വീകരിക്കാനോ ആയേക്കില്ല." + "SMS സ്റ്റോറേജ് കുറവാണ്. നിങ്ങൾക്ക് സന്ദേശങ്ങൾ ഇല്ലാതാക്കേണ്ടതായി വരാം." + "നിങ്ങളുടെ ഫോൺ നമ്പർ സ്ഥിരീകരിക്കുക" + "സന്ദേശമയയ്ക്കൽ നിങ്ങളുടെ ഗ്രൂപ്പ് സന്ദേശങ്ങൾ ശരിയായി വിതരണം ചെയ്യുമെന്ന് ഈ ഒറ്റത്തവണയുള്ള ഘട്ടം ഉറപ്പാക്കും." + "ഫോണ്‍‌ നമ്പര്‍‌" + "മീഡിയയുള്ള എല്ലാ സന്ദേശങ്ങളും ഇല്ലാതാക്കുക" + "%s എന്നതിലും പഴയ സന്ദേശങ്ങൾ ഇല്ലാതാക്കുക" + "%s മുമ്പുള്ള സന്ദേശങ്ങൾ യാന്ത്രികമായി ഇല്ലാതാക്കുക" + "നിരസിക്കുക" + "മീഡിയയുള്ള എല്ലാ സന്ദേശങ്ങളും ഇല്ലാതാക്കണോ?" + "%s മുമ്പുള്ള സന്ദേശങ്ങൾ ഇല്ലാതാക്കണോ?" + "%s മുമ്പുള്ള സന്ദേശങ്ങൾ ഇല്ലാതാക്കുകയും യാന്ത്രിക ഇല്ലാതാക്കൽ ഓണാക്കുകയും ചെയ്യണോ?" + "%s, പറഞ്ഞു" + "നിങ്ങൾ പറഞ്ഞു" + "%s എന്നയാളില്‍ നിന്നുള്ള സന്ദേശം" + "നിങ്ങൾ ഒരു സന്ദേശമയച്ചു" + "അയയ്‌ക്കുന്നു…" + "അയച്ചില്ല. വീണ്ടും ശ്രമിക്കാൻ സ്‌പർശിക്കുക." + "അയച്ചില്ല. വീണ്ടും ശ്രമിക്കുന്നു..." + "വീണ്ടും അയയ്ക്കുക അല്ലെങ്കിൽ ഇല്ലാതാക്കുക" + "അടിയന്തര സേവനങ്ങളിലേക്ക് വോയ്‌സ് കോൾ ചെയ്യുക. ഇപ്പോൾ നിങ്ങളുടെ ടെക്‌സ്‌റ്റ് സന്ദേശം കൈമാറാനായില്ല." + "പരാജയപ്പെട്ടു" + "ഡൗൺലോഡുചെയ്യാനുള്ള പുതിയ MMS സന്ദേശം" + "പുതിയ MMS സന്ദേശം" + "ഡൗൺലോഡുചെയ്യാനായില്ല" + "വീണ്ടും ശ്രമിക്കാൻ സ്‌പർശിക്കുക" + "ഡൗൺലോഡുചെയ്യാൻ സ്‌പർശിക്കുക" + "ഡൗൺലോഡുചെയ്യുക അല്ലെങ്കിൽ ഇല്ലാതാക്കുക" + "ഡൗൺലോഡുചെയ്യുന്നു…" + "സന്ദേശം കാലഹരണപ്പെട്ടു അല്ലെങ്കിൽ ലഭ്യമല്ല" + "വലുപ്പം: %1$s, കാലഹരണപ്പെടുന്നത്: %2$s" + "അയയ്‌ക്കാനാവില്ല. സ്വീകർത്താവ് അസാധുവാണ്." + "സേവനം നെറ്റ്‌വർക്കിൽ സജീവമാക്കിയിട്ടില്ല" + "നെറ്റ്‌വർക്ക് പ്രശ്‌നം കാരണം അയ‌യ്‌ക്കാനായില്ല" + "സന്ദേശം കാലഹരണപ്പെട്ടു അല്ലെങ്കിൽ ലഭ്യമല്ല" + "(വിഷയമൊന്നുമില്ല)" + "അയച്ചയാൾ അജ്ഞാതനാണ്" + "ഡെലിവർ ചെയ്‌തു" + "%2$s എന്നയാളിൽ നിന്നുള്ള %1$s എന്ന സന്ദേശം ഡൗൺലോഡുചെയ്യാനായില്ല." + "മെമ്മറി കുറവായതിനാൽ ഡാറ്റാബേസ് പ്രവർത്തനം പൂർത്തിയാക്കാനായില്ല" + "സന്ദേശം അയച്ചില്ല" + "സന്ദേശമയയ്ക്കലിൽ ചില സന്ദേശങ്ങൾ അയച്ചിട്ടില്ല" + + %d സംഭാഷണങ്ങളിൽ %d സന്ദേശങ്ങൾ + ഒരു സംഭാഷണത്തിൽ %d സന്ദേശങ്ങൾ + + "സന്ദേശം ഡൗൺലോഡുചെയ്‌തില്ല" + "സന്ദേശമയയ്ക്കലിൽ ചില സന്ദേശങ്ങൾ ഡൗൺലോഡ് ചെയ്തിട്ടില്ല" + + %d സംഭാഷണങ്ങളിൽ %d സന്ദേശങ്ങൾ + ഒരു സംഭാഷണത്തിൽ %d സന്ദേശങ്ങൾ + + "%1$s എന്ന നമ്പറിലേക്ക് സന്ദേശം അയച്ചില്ല" + "അടിയന്തര സേവനങ്ങളിലേക്ക് വോയ്‌സ് കോൾ ചെയ്യുക. ഇപ്പോൾ നിങ്ങളുടെ ടെക്‌സ്‌റ്റ് സന്ദേശം %1$s എന്ന നമ്പറിലേക്ക് കൈമാറാനായില്ല." + + %d പുതിയ സന്ദേശങ്ങൾ + പുതിയ സന്ദേശം + + "ആരംഭിക്കുക" + "ക്യാമറ ലഭ്യമല്ല" + "ക്യാമറ ലഭ്യമല്ല" + "വീഡിയോ എടുക്കൽ ലഭ്യമല്ല" + "മീഡിയ സംരക്ഷിക്കാനാവില്ല" + "ചിത്രമെടുക്കാനാവില്ല" + "മടങ്ങുക" + "ആർക്കൈവുചെയ്‌തവ" + "ഇല്ലാതാക്കുക" + "ആര്‍ക്കൈവുചെയ്യുക" + "ആർക്കൈവുചെയ്‌തത് മാറ്റുക" + "അറിയിപ്പുകൾ ഓഫാക്കുക" + "അറിയിപ്പുകൾ ഓണാക്കുക" + "കോൺടാക്റ്റ് ചേർക്കുക" + "ഡൗൺലോഡ്" + "അയയ്‌ക്കുക" + "ഇല്ലാതാക്കുക" + "ഈ സന്ദേശം ഇല്ലാതാക്കണോ?" + "ഈ പ്രവർത്തനം പഴയപടിയാക്കാനാകില്ല." + "ഇല്ലാതാക്കുക" + + ഈ സംഭാഷണങ്ങൾ ഇല്ലാതാക്കണോ? + ഈ സംഭാഷണം ഇല്ലാതാക്കണോ? + + "ഇല്ലാതാക്കുക" + "റദ്ദാക്കുക" + "സ്വീകര്‍ത്താവ്" + "ഒന്നിലധികം ചിത്രങ്ങൾ തിരഞ്ഞെടുക്കുക" + "തിരഞ്ഞെടുത്തവ സ്ഥിരീകരിക്കുക" + "%d എണ്ണം കൂടി" + "ഓഡിയോ റെക്കോർഡ് ചെയ്യാനായില്ല. വീണ്ടും ശ്രമിക്കുക." + "ഓഡിയോ പ്ലേ ചെയ്യാനായില്ല. വീണ്ടും ശ്രമിക്കുക." + "ഓഡിയോ സംരക്ഷിക്കാനായില്ല. വീണ്ടും ശ്രമിക്കുക." + "സ്‌പർശിച്ച് പിടിക്കുക" + ", " + " " + ": " + " " + "ചിത്രം" + "ഓഡിയോ ക്ലിപ്പ്" + "വീഡിയോ" + "കോൺടാക്‌റ്റ് കാർഡ്" + "ഡൗൺലോഡുചെയ്യുക" + "SMS വഴി മറുപടി നൽകുക" + "MMS വഴി മറുപടി നൽകുക" + "മറുപടി നൽകുക" + + %d പങ്കെടുക്കുന്നവർ + %d പങ്കെടുക്കുന്നയാൾ + + "ഞാന്‍" + "കോൺടാക്‌റ്റ് തടഞ്ഞ് ആർക്കൈവുചെയ്‌തു" + "കോൺടാക്റ്റിനെ തടഞ്ഞതും ആർക്കൈവ് ചെയ്‌തതും മാറ്റി" + "%d എണ്ണം ആർക്കൈവുചെ‌യ്‌തു" + "%d എണ്ണം ആർക്കൈവുചെയ്‌തത് മാറ്റി" + "അറിയിപ്പുകൾ ഓഫാക്കി" + "അറിയിപ്പുകൾ ഓണാക്കി" + "എല്ലാം സജ്ജീകരിച്ചു. വീണ്ടും അയയ്‌ക്കാൻ സ്‌‌‌പർശിക്കുക." + "സ്ഥിര SMS ആപ്പായി സന്ദേശമയയ്ക്കൽ സജ്ജീകരിച്ചു." + + അറ്റാച്ച്‌മെന്റുകൾ ഉപേക്ഷിക്കുക + അറ്റാച്ച്‌മെന്റ് ഉപേക്ഷിക്കുക + + "ഓഡിയോ അറ്റാച്ചുമെന്റ്" + "ഓഡിയോ അറ്റാച്ചുമെന്റ് പ്ലേ ചെയ്യുക" + "താൽക്കാലികമായി നിർത്തുക" + "%s എന്നയാളിൽ നിന്നുള്ള സന്ദേശം : %s ." + "%s എന്നയാളിൽ നിന്നുള്ള സന്ദേശം പരാജയപ്പെട്ടു: %s. സമയം: %s." + "%s എന്നയാളിൽ നിന്നുള്ള സന്ദേശം: %s. സമയം: %s." + "%s എന്നതിലേക്ക് സന്ദേശം അയച്ചില്ല: %s. സമയം: %s." + "%s എന്നതിലേക്ക് സന്ദേശം അയയ്‌ക്കുന്നു: %s. സമയം: %s." + "%s എന്നതിലേക്കുള്ള സന്ദേശം പരാജയപ്പെട്ടു: %s. സമയം: %s." + "%s എന്നതിലേക്കുള്ള സന്ദേശം: %s. സമയം: %s." + "%s എന്നയാളിൽ നിന്നുള്ള സന്ദേശം പരാജയപ്പെട്ടു: %s. സമയം: %s. %s." + "%s എന്നയാളിൽ നിന്നുള്ള സന്ദേശം: %s. സമയം: %s. %s." + "%s എന്നതിലേക്ക് സന്ദേശം അയച്ചില്ല: %s. സമയം: %s." + "%s എന്നതിലേക്ക് സന്ദേശം അയയ്‌ക്കുന്നു: %s. സമയം: %s." + "%s എന്നതിലേക്കുള്ള സന്ദേശം പരാജയപ്പെട്ടു: %s. സമയം: %s." + "%s എന്നതിലേക്കുള്ള സന്ദേശം: %s. സമയം: %s." + "സന്ദേശം പരാജയപ്പെട്ടു. വീണ്ടും ശ്രമിക്കുന്നതിന് സ്‌പർശിക്കുക." + "%s എന്നിവരുമായുള്ള സംഭാഷണം" + "വിഷയം ഇല്ലാതാക്കുക" + "വീഡിയോ എടുക്കുക" + "ഒരു സ്റ്റിൽ ചിത്രം എടുക്കുക" + "ചിത്രം എടുക്കുക" + "വീഡിയോ റെക്കോർഡുചെയ്യാൻ ആരംഭിക്കുക" + "പൂർണ്ണസ്ക്രീൻ ക്യാമറയിലേക്ക് മാറുക" + "മുന്നിലെയും പിന്നിലെയും ക്യാമറയ്‌ക്കിടയിൽ മാറുക" + "റെക്കോർഡുചെയ്യൽ നിർത്തി, വീഡിയോ അറ്റാച്ചുചെയ്യുക" + "വീഡിയോ റെക്കോർഡുചെയ്യൽ നിർത്തുക" + "സന്ദേശമയയ്ക്കൽ ഫോട്ടോകൾ" + + %d ഫോട്ടോകൾ \"%s\" ആൽബത്തിലേക്ക് സംരക്ഷിച്ചു + %d ഫോട്ടോ \"%s\" ആൽബത്തിലേക്ക് സംരക്ഷിച്ചു + + + %d വീഡിയോകൾ \"%s\" ആൽബത്തിലേക്ക് സംരക്ഷിച്ചു + %d വീഡിയോ \"%s\" ആൽബത്തിലേക്ക് സംരക്ഷിച്ചു + + + %d അറ്റാച്ചുമെന്റുകൾ \"%s\" ആൽബത്തിലേക്ക് സംരക്ഷിച്ചു + %d അറ്റാച്ച്‌മെന്റ് \"%s\" ആൽബത്തിലേക്ക് സംരക്ഷിച്ചു + + + %d അറ്റാച്ചുമെന്റുകൾ \"ഡൗൺലോഡുകളി\"ലേക്ക് സംരക്ഷിച്ചു + %d അറ്റാച്ചുമെന്റ് \"ഡൗൺലോഡുകളി\"ലേക്ക് സംരക്ഷിച്ചു + + + %d അറ്റാച്ച്‌മെന്റുകൾ സംരക്ഷിച്ചു + %d അറ്റാച്ച്‌മെന്റ് സംരക്ഷിച്ചു + + + %dഅറ്റാച്ച്‌മെന്റുകൾ സംരക്ഷിക്കാനായില്ല + %d അറ്റാച്ച്‌മെന്റ് സംരക്ഷിക്കാനായില്ല + + "MMS അറ്റാച്ചുമെന്റ് സംരക്ഷിച്ചു" + "ക്രമീകരണങ്ങൾ" + "ആർക്കൈവുചെയ്‌തവ" + "അടയ്‌ക്കുക" + "MMS" + "വിപുലം" + "ഡീബഗ്" + "അറിയിപ്പുകൾ" + "ശബ്‌ദം" + "നിശബ്‌ദം" + "വൈബ്രേറ്റുചെയ്യുക" + "തടഞ്ഞിരിക്കുന്നു" + "SMS ഡെലിവറി റിപ്പോർട്ടുകൾ" + "അയയ്‌ക്കുന്ന ഓരോ SMS-നും ഒരു ഡെലിവറി റിപ്പോർട്ട് അഭ്യർത്ഥിക്കുക" + "സ്വയമേവ വീണ്ടെടുക്കുക" + "MMS സന്ദേശങ്ങൾ സ്വയമേവ ലഭ്യമാക്കുക" + "റോമിംഗിൽ ലഭ്യമാക്കുക" + "റോമിംഗിലായിരിക്കുമ്പോൾ സ്വയമേ MMS വീണ്ടെടുക്കുന്നു" + "ഗ്രൂപ്പ് സന്ദേശമയയ്‌ക്കൽ" + "ഒന്നിലധികം സ്വീകർത്താക്കൾ ഉള്ളപ്പോൾ ഒരൊറ്റ സന്ദേശം അയയ്‌ക്കാൻ MMS ഉപയോഗിക്കുക." + "സ്ഥിര SMS അപ്ലിക്കേഷൻ" + "സ്ഥിര SMS അപ്ലിക്കേഷൻ" + + + "നിങ്ങളുടെ ഫോൺ നമ്പർ" + "അജ്ഞാതം" + "ഔട്ട്‌ഗോയിംഗ് സന്ദേശ ശബ്‌ദങ്ങൾ" + "SMS നൽകുക" + "ബാഹ്യ സംഭരണ ഫയലിലേക്ക് സ്വീകരിച്ച SMS റോ ഡാറ്റ നൽകുക" + "MMS നൽകുക" + "ബാഹ്യ സംഭരണ ഫയലിലേക്ക് സ്വീകരിച്ച MMS റോ ഡാറ്റ നൽകുക" + "വയർലെസ് അലേർട്ടുകൾ" + "സന്ദേശ ഓ‌പ്ഷനുകൾ" + "ടെക്‌‌സ്‌റ്റ് പകർത്തുക" + "വിശദാംശങ്ങൾ കാണുക" + "ഇല്ലാതാക്കുക" + "കൈമാറുക" + "സന്ദേശ വിശദാംശങ്ങൾ" + "തരം: " + "ടെക്‌സ്റ്റ് സന്ദേശം" + "മൾട്ടിമീഡിയ സന്ദേശം" + "അയച്ചയാൾ: " + "സ്വീകർത്താവ്: " + "അയച്ചത്: " + "ലഭിച്ചവ: " + "വിഷയം: " + "വലുപ്പം: " + "പ്രധാനപ്പെട്ടവ: " + "SIM: " + "ഉയർന്നത്" + "സാധാരണ" + "കുറഞ്ഞവ" + "SIM %s" + "അയച്ചയാളിന്റെ വിലാസം മറച്ചിരിക്കുന്നു" + "അറ്റാച്ചുമെന്റുകൾ ലോഡുചെയ്യുന്ന സമയത്ത് സന്ദേശം അയയ്‌ക്കാനാവില്ല." + "അറ്റാച്ചുമെന്റ് ലോഡുചെയ്യാനാവില്ല. വീണ്ടും ശ്രമിക്കുക." + "നെറ്റ്‌വർക്ക് തയ്യാറായില്ല. വീണ്ടും ശ്രമിക്കുക." + "ടെക്‌സ്റ്റ് ഇല്ലാതാക്കുക" + "ടെക്സ്റ്റും നമ്പറുകളും നൽകുന്നതിനിടയിൽ മാറുക" + "കൂടുതൽ പങ്കാളികളെ ചേർക്കുക" + "പങ്കെടുക്കുന്നവരെ സ്ഥിരീകരിക്കുക" + "പുതിയ സംഭാഷണം ആരംഭിക്കുക" + "ഈ ഇനം തിരഞ്ഞെടുക്കുക" + "വീഡിയോ പ്ലേചെയ്യുക" + "ആളുകളും ഓപ്‌ഷനുകളും" + "ഡീബഗ്" + "ആളുകളും ഓപ്‌ഷനുകളും" + "പൊതുവായത്" + "ഈ സംഭാഷണത്തിലെ ആളുകൾ" + "ഒരു കോൾ ചെയ്യുക" + "സന്ദേശം അയയ്ക്കുക" + "<br/><small>%s -ൽ നിന്ന്</small> സന്ദേശമയയ്‌ക്കുക" + + ഫോട്ടോകൾ അയയ്‌ക്കുക + ഫോട്ടോ അയയ്ക്കുക + + + ഓഡിയോകൾ അയയ്‌ക്കുക + ഓഡിയോ അയയ്ക്കുക + + + വീഡിയോകൾ അയയ്‌ക്കുക + വീഡിയോ അയയ്‌ക്കുക + + + കോൺടാക്റ്റ് കാർഡുകൾ അയയ്‌ക്കുക + കോൺടാക്റ്റ് കാർഡ് അയയ്ക്കുക + + + അറ്റാച്ച്‌മെന്റുകൾ അയയ്ക്കുക + അറ്റാച്ച്‌മെന്റ് അയയ്ക്കുക + + + %d അറ്റാച്ച്‌മെന്റുകൾ അയയ്‌ക്കാൻ തയ്യാറാണ് + ഒരു അറ്റാച്ച്‌മെന്റ് അയയ്‌ക്കാൻ തയ്യാറാണ് + + "ഫീഡ്‍ബാക്ക് അയയ്ക്കുക" + "Google Play Store-ൽ കാണുക" + "പതിപ്പ് വിവരം" + "പതിപ്പ് %1$s" + "ഓപ്പൺ സോഴ്‌സ് ലൈസൻസുകൾ" + "അറിയിപ്പുകൾ" + "അറ്റാച്ചുമെന്റ് പരിധിയെത്തി" + "അറ്റാച്ചുമെന്റ് ലോഡുചെയ്യുന്നത് പരാജയപ്പെട്ടു." + "കോൺടാക്റ്റിലേക്ക് ചേർക്കണോ?" + "കോൺടാക്‌റ്റ് ചേർക്കുക" + "വിഷയം" + "വിഷയം: " + "%s%s" + "കോൺടാക്‌റ്റ് കാർഡ് ലോഡുചെയ്യുന്നു" + "കോൺടാക്‌റ്റ് കാർഡ് ലോഡുചെയ്യാനായില്ല" + "കോൺടാക്റ്റ് കാർഡ് കാണുക" + + %d കോൺടാക്റ്റുകൾ + %d കോൺടാക്റ്റ് + + "കോൺ‌ടാക്‌റ്റ് കാർഡുകൾ" + "ജന്മദിനം" + "കുറിപ്പുകള്‍" + "സന്ദേശം കൈമാറൽ" + "മറുപടി നൽകുക" + "+1 ചെയ്യുക" + "+%d" + "SMS പ്രവർത്തനരഹിതമാക്കിയിരിക്കുന്നു" + "അയയ്ക്കുന്നതിന്, സ്ഥിര SMS ആപ്പ് ആയി സന്ദേശമയയ്ക്കൽ സജ്ജമാക്കുക" + "സ്ഥിര SMS ആപ്പ് ആയി സന്ദേശമയയ്ക്കൽ സജ്ജമാക്കുക" + "മാറ്റുക" + "സന്ദേശങ്ങൾ സ്വീകരിക്കുന്നതിന്, സ്ഥിര SMS ആപ്പ് ആയി സന്ദേശമയയ്ക്കൽ സജ്ജമാക്കുക" + "SMS സന്ദേശങ്ങൾ അയയ്‌ക്കാൻ പ്രസ്താവിത SIM ഒന്നും തിരഞ്ഞെടുത്തിട്ടില്ല" + "ഉപകരണത്തിന്റെ ഉടമ ഈ അപ്ലിക്കേഷനെ അനുവദിക്കുന്നില്ല." + "ശരി" + "ഒരു സംഭാഷണത്തിൽ നിരവധി പേർ പങ്കെടുക്കുന്നു" + + അസാധുവായ കോൺടാക്റ്റുകൾ + അസാധുവായ കോൺടാക്‌റ്റ് + + "ക്യാമറ ചിത്രം ലോഡുചെയ്യാനായില്ല" + "നിങ്ങൾ: " + "%s: " + "ഡ്രാഫ്റ്റ്" + "ഒരു പുതിയ സംഭാഷണം ആരംഭിക്കുമ്പോൾ, ഇത് ഇവിടെ ലിസ്‌റ്റുചെയ്‌തിരിക്കുന്നത് നിങ്ങൾ കാണും" + "ആർക്കൈവുചെയ്‌ത സംഭാഷണങ്ങൾ ഇവിടെ ദൃശ്യമാകും" + "സംഭാഷണങ്ങൾ ലോഡുചെയ്യുന്നു…" + "ചിത്രം" + "ഓഡിയോ ക്ലിപ്പ്" + "വീഡിയോ" + "കോൺടാക്‌റ്റ് കാർഡ്" + "MMS" + "പഴയപടിയാക്കുക" + "വീണ്ടും ശ്രമിക്കുക" + "ഒരു പുതിയ സന്ദേശം ആരംഭിക്കാൻ കോൺടാക്‌റ്റ് പേരോ ഫോൺ നമ്പറോ നൽകുക" + "തടയുക" + "%s എന്നയാളെ തടയുക" + "%s എന്നയാളെ തടഞ്ഞത് മാറ്റുക" + "%s തടയണോ?" + "ഈ നമ്പറിൽ നിന്ന് സന്ദേശങ്ങൾ തുടർന്നും ലഭിക്കുമെങ്കിലും ഇനി അറിയിപ്പൊന്നും ലഭിക്കില്ല. ഈ സംഭാഷണം ആർക്കൈവുചെയ്യും." + "തടഞ്ഞ കോൺടാക്‌റ്റുകൾ" + "തടഞ്ഞത് മാറ്റുക" + "തടഞ്ഞ കോൺടാക്‌റ്റുകൾ" + "പ്രമാണ ലൈബ്രറിയിൽ നിന്ന് ചിത്രം തിരഞ്ഞെടുക്കുക" + "സന്ദേശം അയക്കുന്നു" + "സന്ദേശം അയച്ചു" + "സെല്ലുലാർ ഡാറ്റ ഓഫാക്കിയിരിക്കുന്നു. നിങ്ങളുടെ ക്രമീകരണങ്ങൾ പരിശോധിക്കുക." + "വിമാന മോഡിൽ സന്ദേശങ്ങൾ അയയ്‌ക്കാനാവില്ല" + "സന്ദേശം അയയ്ക്കാനായില്ല" + "സന്ദേശം ഡൗൺലോഡുചെയ്‌തു" + "സെല്ലുലാർ ഡാറ്റ ഓഫാണ്. നിങ്ങളുടെ ക്രമീകരണം പരിശോധിക്കുക." + "വിമാന മോഡിൽ സന്ദേശങ്ങൾ ഡൗൺലോഡുചെയ്യാനാവില്ല" + "സന്ദേശം ഡൗൺലോഡുചെയ്യാനായില്ല" + "പൂജ്യം" + "ഒന്ന്" + "രണ്ട്" + "മൂന്ന്" + "നാല്" + "അഞ്ച്" + "ആറ്" + "ഏഴ്" + "എട്ട്" + "ഒൻപത്" + "%1$s എന്നതിലൂടെ സന്ദേശമയയ്‌ക്കാനായില്ല, പിശക് %2$d" + "അജ്ഞാത കാരിയറിലൂടെ സന്ദേശമയയ്‌ക്കാനായില്ല, പിശക് %1$d" + "കൈമാറിയത്: %s" + "സന്ദേശം അയച്ചില്ല: നെറ്റ്‌വർക്കിൽ സേവനം സജീവമാക്കിയിട്ടില്ല" + "സന്ദേശം അയച്ചില്ല: ലക്ഷ്യസ്ഥാന വിലാസം അസാധുവാണ്" + "സന്ദേശം അയച്ചില്ല: സന്ദേശം അസാധുവാണ്" + "സന്ദേശം അയച്ചില്ല: ഉള്ളടക്കം പിന്തുണയ്ക്കാത്തതാണ്" + "സന്ദേശം അയച്ചില്ല: സന്ദേശം പിന്തുണയ്ക്കാത്തതാണ്" + "സന്ദേശം അയച്ചില്ല: വളരെ വലുതാണ്" + "പുതിയ സന്ദേശം" + "കാണുക" + "ചിത്രം" + "അനുയോജ്യമായൊരു അപ്ലിക്കേഷൻ കണ്ടെത്താനായില്ല" + "സ്വീകർത്താവിനെ നീക്കംചെയ്യുക" + "പുതിയ സന്ദേശം" + "റദ്ദാക്കുക" + "ആക്‌സസ്സ്‌പോയിന്റ് എഡിറ്റുചെയ്യൂ" + "സജ്ജീകരിച്ചിട്ടില്ല" + "പേര്" + "APN" + "MMSC" + "MMS പ്രോക്‌സി" + "MMS പോർട്ട്" + "MCC" + "MNC" + "APN തരം" + "APN ഇല്ലാതാക്കുക" + "പുതിയ APN" + "സംരക്ഷിക്കുക" + "നിരസിക്കുക" + "പേരിന്റെ ഫീൽഡ് ശൂന്യമായിരിക്കരുത്." + "APN ശൂന്യമായിരിക്കരുത്." + "MCC ഫീൽഡിൽ 3 സംഖ്യകൾ ഉണ്ടായിരിക്കണം." + "MNC ഫീൽഡിൽ 2 അല്ലെങ്കിൽ 3 സംഖ്യകൾ ഉണ്ടായിരിക്കണം." + "സ്ഥിര APN ക്രമീകരണങ്ങൾ പുനഃസ്ഥാപിക്കുന്നു" + "സ്ഥിരമായതിലേക്ക് പുനഃസജ്ജമാക്കുക" + "സ്ഥിര APN ക്രമീകരണങ്ങൾ പുനഃസജ്ജീകരിക്കൽ പൂർത്തിയാക്കി." + "പേരില്ലാത്തത്" + "ആക്‌സസ്സ് പോയിന്റ് പേരുകൾ" + "APN-കൾ" + "പുതിയ APN" + "ആക്‌സസ്സ് പോയിന്റ് നെയിം ക്രമീകരണങ്ങൾ ഈ ഉപയോക്താവിനായി ലഭ്യമല്ല" + "ക്ലിപ്പ്‌ബോർഡിലേക്ക് പകർത്തണോ?" + "പകര്‍ത്തുക" + "%s എന്നതിലേക്ക്" + "പൊതുവായത്" + "വിപുലമായത്" + "പൊതു ക്രമീകരണങ്ങൾ" + "വിപുലമായ ക്രമീകരണങ്ങൾ" + "\"%s\" SIM" + "എല്ലാ സ്വീകർത്താക്കൾക്കും വെവ്വേറെ SMS സന്ദേശങ്ങൾ അയയ്‌ക്കുക. മറുപടികളെല്ലാം നിങ്ങൾക്കുമാത്രമേ ലഭിക്കൂ" + "എല്ലാ സ്വീകർത്താക്കൾക്കും ഒരൊറ്റ MMS അയയ്‌ക്കുക" + "അജ്ഞാത നമ്പര്‍" + "പുതിയ സന്ദേശം" + "പുതിയ സന്ദേശം." + "SIM സെലക്‌ടർ" + "%1$s തിരഞ്ഞെടുത്തു, SIM സെലക്‌ടർ" + "വിഷയം എഡിറ്റുചെയ്യുക" + "SIM തിരഞ്ഞെടുക്കുക അല്ലെങ്കിൽ വിഷയം എഡിറ്റുചെയ്യുക" + "ഓഡിയോ റെക്കോർഡുചെയ്യാൻ സ്‌പർശിച്ച് പിടിക്കുക" + "പുതിയ സംഭാഷണം ആരംഭിക്കുക" + "സന്ദേശമയയ്‌ക്കൽ" + "സന്ദേശമയയ്ക്കൽ ലിസ്റ്റ്" + "സന്ദേശമയയ്‌ക്കൽ" + "പുതിയ സന്ദേശം" + "സംഭാഷണ ലിസ്റ്റ്" + "സംഭാഷണങ്ങൾ ലോഡുചെയ്യുന്നു" + "സന്ദേശങ്ങൾ ലോഡുചെയ്യുന്നു" + "കൂടുതൽ സംഭാഷണങ്ങൾ കാണുക" + "കൂടുതൽ സന്ദേശങ്ങൾ കാണുക" + "സംഭാഷണം ഇല്ലാതാക്കി" + "സംഭാഷണം ഇല്ലാതാക്കി. മറ്റൊരു സന്ദേശമയയ്ക്കൽ സംഭാഷണം കാണിക്കുന്നതിന് സ്പർശിക്കുക" + "തടഞ്ഞു" + "തടഞ്ഞത് മാറ്റി" + "സംഭരണത്തിനുള്ള ഇടം കുറവാണ്. ചില വിവരം നഷ്‌ടപ്പെട്ടേക്കാം." + "അറ്റാച്ചുമെന്റുകൾ തിരഞ്ഞെടുക്കുക" + "തിരഞ്ഞെടുത്തവ സ്ഥിരീകരിക്കുക" + "%d എണ്ണം തിരഞ്ഞെടുത്തു" + "ഒന്നോ അതിൽ കൂടുതലോ അറ്റാച്ചുമെന്റുകൾ നീക്കംചെയ്‌തുകൊണ്ട് വീണ്ടും ശ്രമിക്കുക." + "നിങ്ങളുടെ സന്ദേശം അയയ്‌ക്കാൻ ശ്രമിക്കാമെങ്കിലും ഒന്നോ അതിലധികമോ അറ്റാച്ചുമെന്റുകൾ നീക്കംചെയ്യാതെ അത് ഡെലിവർ ചെയ്തേക്കില്ല." + "നിങ്ങൾക്ക് ഒരു സന്ദേശത്തിന് ഒരു വീഡിയോ മാത്രമേ അയയ്‌ക്കാനാകൂ. കൂടുതൽ വീഡിയോകൾ നീക്കംചെയ്‌ത് വീണ്ടും ശ്രമിക്കുക." + "അറ്റാച്ച്മെന്റ് ലോഡുചെയ്യുന്നതിൽ സന്ദേശമയയ്‌ക്കൽ പരാജയപ്പെട്ടു." + "ഏതുവിധേനയും അയയ്‌ക്കുക" + "സംഭാഷണം ആരംഭിക്കാനായില്ല" + "%1$s (%2$s)" + "തിരഞ്ഞെടുത്തത്, %s" + diff --git a/res/values-mn-rMN/arrays.xml b/res/values-mn-rMN/arrays.xml new file mode 100644 index 0000000..b47063a --- /dev/null +++ b/res/values-mn-rMN/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "гарчиггүй" + "nosubject" + + + "Тийм" + "Үгүй" + "Тийм" + "Хэхэ" + "Баярлалаа" + "Би зөвшөөрч байна" + "Янзтай" + "Очиж байна" + "За, би эргээд холбоо баръя" + ":)" + ":(" + + diff --git a/res/values-mn-rMN/strings.xml b/res/values-mn-rMN/strings.xml new file mode 100644 index 0000000..fb9106b --- /dev/null +++ b/res/values-mn-rMN/strings.xml @@ -0,0 +1,519 @@ + + + + + "Зурвас" + "Зурвас" + "Харилцааг сонгох" + "Тохиргоо" + "Зурвас Илгээх" + "Хавсралт нэмэх" + "Тусламж" + "Тавтай морилно уу" + "Алгасах" + "Дараах >" + "Дараах" + "Гарах" + "Тохиргоо >" + "Тохиргоо" + "Messaging-д SMS, Утас, Харилцагч хаягийн зөвшөөрөл шаардлагатай." + "Та зөвшөөрлийг Тохиргоо > Aпп > Зурвас > Зөвшөөрөл хэсэгт өөрчилж болно." + "Та зөвшөөрлийг Тохиргоо, Апп, Messaging, Зөвшөөрөл хэсэгт өөрчилж болно." + "БАЙНГЫН ХАРИЛЦАГЧ" + "Бүх харилцагч" + "%s руу илгээх" + "Зураг буюу видео авах" + "Энэ төхөөрөмжөөс зураг сонгоно уу" + "Аудио бичих" + "Зураг сонгох" + "Медиаг сонгосон байна." + "Медиаг сонгоогүй байна." + "%d сонгогдсон" + "зураг %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "зураг" + "Аудио бичих" + "Хуваалцах" + "Дөнгөж сая" + "Одоо" + + %d минут + %d минут + + + %d цаг + %d цаг + + + %d өдөр + %d өдөр + + + %d долоо хоног + долоо хоног + + + %d сар + нэг сар + + + %d жил + нэг жил + + "Ангилал 0 зурвас" + "Хадгалах" + "Төхөөрөмжийн багтаамж бага байна. Messaging нь багтаамж үүсгэхийн тулд хуучин зурвасыг автоматаар устгана." + "Сангийн хэмжээ дутагдаж байна" + "Мessaging нь таны төхөөрөмжид хангалттай багтаамж гарах хүртэл зурвас илгээх эсвэл хүлээн авах боломжгүй." + "SMS сан бага байна. Та зурвасуудаас устгах шаардлагатай бололтой." + "Утасны дугаараа баталгаажуулна уу" + "Нэг удаагийн алхам нь Messaging-г таны бүлэг зурвасыг асуудалгүйгээр илгээнэ." + "Утасны дугаар" + "Медиатай бүх зурвасуудыг устгах" + "%s-с өмнөх зурвасуудыг устгах" + "%s-с өмнөх зурвасуудыг автоматаар устгах" + "Үл тоох" + "Медиа бүхий бүх зурвасыг устгах уу?" + "%s-с өмнөх зурвасуудыг устгах уу?" + "%s-с өмнөх зурвасуудыг устгаж, автомат устгалтыг асаах уу?" + "%s гэж хэлсэн" + "Та хэлсэн байна" + "%s-ээс ирсэн мессеж" + "Та мессеж илгээсэн байна" + "Илгээж байна ..." + "Илгээгээгүй. Дахин оролдохын тулд хүрнэ үү." + "Илгээгээгүй. Дахин оролдож байна…" + "Дахин илгээх буюу устгах" + "Яаралтай түргэн тусламж руу залгана уу. Таны мессежийг яг одоо явуулах боломжгүй байна." + "Амжилтгүй" + "Татаж авах шинэ MMS мессеж" + "Шинэ MMS мессеж" + "Татан авч чадсангүй" + "Дахин оролдохын тулд хүрнэ үү" + "Татаж авахын тулд хүрнэ үү" + "Татаж авах буюу устгах" + "Татаж авч байна..." + "Зурвасын хугацаа өнгөрсөн буюу байхгүй" + "хэмжээ: %1$s, хугацаа дуусах: %2$s" + "Илгээх боломжгүй. Хүлээн авагч буруу." + "Сүлжээнд үйлчилгээг идэвхжүүлээгүй байна." + "Сүлжээний асуудлаас шалтгаалан илгээж чадсангүй." + "Зурвасын хугацаа өнгөрсөн буюу байхгүй" + "(Гарчиггүй)" + "Тодорхойгүй илгээгч" + "Хүргэгдсэн" + "%2$s%1$s зурвасыг татаж чадсангүй." + "Санах ой бага байгаагаас шалтгаалан датабаазын үйлдлийг гүйцээж чадсангүй" + "Зурвас илгээгдээгүй" + "Messaging-н зарим зурвасыг илгээгээгүй" + + %d Харилцан ярианд %d зурвас байна + Нэг харилцан ярианд %d зурвас байна + + "зурвасыг татаж болсонгүй" + "Зарим зурвасыг Messaging-д татаагүй" + + %d Харилцан ярианд %d зурвас байна + Нэг харилцан ярианд %d зурвас байна + + "%1$s явуулсан мессеж амжилтгүй болсон байна" + "Яаралтай түргэн тусламж руу залгана уу. Таны мессежийг %1$s дугаарт яг одоо явуулах боломжгүй байна. " + + %d шинэ зурвас + Шинэ зурвас + + "Эхлэх" + "Камер ашиглах боломжгүй" + "Камер ашиглах боломжгүй" + "Видео бичих боломжгүй" + "Медиаг хадгалах боломжгүй" + "Зураг авч болохгүй байна" + "Буцах" + "Архивлагдсан" + "Устгах" + "Архивлах" + "Архиваас гаргах" + "Мэдэгдэл унтраах" + "Мэдэгдлүүдийг асаах" + "Харилцагч нэмэх" + "Татаж авах" + "Илгээх" + "Устгах" + "Энэ зурвасыг устгах уу?" + "Энэ үйлдлийг буцаах боломжгүй." + "Устгах" + + Эдгээр харилцан яриаг устгах уу? + Энэ харилцан яриаг устах уу? + + "Устгах" + "Цуцлах" + "Хэнд" + "Олон зураг сонгох" + "Сонголтыг баталгаажуулах" + "+%d" + "Аудиог бичих боломжгүй. Дахин оролдоно уу." + "Аудиог тоглуулах боломжгүй. Дахин оролдоно уу." + "Аудиог хадгалж чадсангүй. Дахин оролдоно уу." + "Хүрээд & барих" + ", " + " " + ": " + " " + "Зураг" + "Аудио клип" + "Видео" + "Харилцагчийн карт" + "Татаж авах" + "SMS-р хариулах" + "MMS-ээр хариулах" + "Хариулах" + + %d оролцогч + %d оролцогч + + "Би" + "Харилцагчийг хориглож & архивласан" + "Харилцаа холбоог хориглоогүй & архивлагдаагүй" + "%d aрхивлагдсан" + "%d архивлагдаагүй" + "Мэдэгдлүүдийг унтраасан" + "Мэдэгдлүүдийг асаасан" + "Бүгдийг нь тохируулсан. Илгээх-д дахин хүрнэ үү." + "Мessaging-г SMS-н үндсэн апп-аар амжилттай тохирууллаа." + + Хавсралтыг цуцлах + Хавсралтыг цуцлах + + "Аудио хавсралт" + "Аудио хавсралтыг тоглуулах" + "Түр зогсоох" + "%s: %s-аас ирсэн зурвас." + "%s-с илгээсэн амжилтгүй болсон мессеж: %s. Цаг: %s." + "%s-с илгээсэн мессеж: %s. Цаг: %s." + "%s-д илгээгээгүй байгаа мессеж: %s. Цаг: %s." + "%s-д мессеж илгээж байна: %s. Цаг: %s." + "%s-д илгээсэн амжилтгүй болсон мессеж: %s. Цаг: %s." + "%s-д мессеж илгээх: %s. Цаг: %s." + "%s-с илгээсэн амжилтгүй болсон мессеж: %s. Цаг: %s. %s" + "%s-с илгээсэн мессеж: %s. Цаг: %s. %s." + "%s-т зориулсан илгээгээгүй байгаа мессеж: %s. Цаг: %s." + "%s-т мессеж илгээж байна: %s. Цаг: %s." + "%s-т зориулсан амжилтгүй болсон мессеж: %s. Цаг: %s." + "%s-т илгээх мессеж: %s. Цаг: %s." + "Амжилтгүй болсон мессеж. Дахин оролдоно уу." + "%s нартай холбоо тогтоох" + "Гарчгийг устгах" + "Видео авах" + "Хөдөлгөөнгүй зураг авах" + "Зураг авах" + "Видео бичлэг хийж эхлэх" + "Бүтэн дэлгэцийн камер руу сэлгэх" + "Урд болон хойд камер хооронд сэлгэх" + "Бичлэгийг зогсоож видеог хавсаргах" + "Видео бичилтийг зогсоох" + "Messaging-н зураг" + + %d зургийг \"%s\" цомогт хадгаллаа + %d зургийг \"%s\" цомогт хадгаллаа + + + %d бичлэгийг \"%s\" цомогт хадгаллаа + %d бичлэгийг \"%s\" цомогт хадгаллаа + + + %d хавсралтыг \"%s\" цомогт хадгаллаа + %d хавсралтыг \"%s\" цомогт хадгаллаа + + + %d хавсралтыг \"Татаж авсан файл\"-д хадгаллаа + %d хавсралтыг \"Татаж aвсан файл\"-д хадгаллаа + + + %d хавсралт хадгалсан + %d хавсралт хадгалсан + + + %d хавсралтыг хадгалж чадсангүй + %d хавсралтыг хадгалж чадсангүй + + "MMS хавсралтыг хадгалсан" + "Тохиргоо" + "Архивлагдсан" + "Хаах" + "MMS" + "Дэлгэрэнгүй" + "Дебаг" + "Мэдэгдэл" + "Дуу" + "Чимээгүй" + "Чичиргээ" + "Хориглогдсон" + "SMS хүргэлтийн репорт" + "Өөрийн илгээх SMS бүрт хүргэлтийн репорт хүснэ үү" + "Автомат-татагч" + "MMS зурвасуудыг автоматаар татах" + "Роумингын автомат-татагч" + "Роуминг ашиглаж байх үедээ MMS-ийг автоматаар сэргээж авч байх" + "Бүлгэмийн зурвас" + "Олон хүлээн авагчид нэг зурвас явуулахдаа MMS ашиглаарай" + "Үндсэн SMS програм" + "Үндсэн SMS програм" + + + "Таны утасны дугаар" + "Тодорхойгүй" + "Мессеж илгээх үеийн ая" + "SMS хадгалах" + "Хүлээн авсан SMS түүхий датаг гадаад сангийн файлд хадгалах" + "MMS хадгалах" + "Dump Хүлээн авсан MMS түүхий датаг гадаад сангийн файлд хадгалах" + "Утасгүй сэрэмжлүүлэг" + "Зурвасын тохиргоо" + "Текстийг хуулах" + "Дэлгэрэнгүй үзэх" + "Устгах" + "Дамжуулах" + "Зурвасын мэдээлэл" + "Төрөл: " + "Текст зурвас" + "Мультимедиа зурвас" + "Хэнээс: " + "Хэнд: " + "Илгээсэн: " + "Хүлээн авсан: " + "Гарчиг: " + "Хэмжээ: " + "Ач холбогдол: " + "SIM: " + "Өндөр" + "Энгийн" + "Бага" + "SIM %s" + "Илгээгчийн хаяг нууцлагдсан" + "Хавсралтуудыг ачаалах үед зурвас илгээх боломжгүй." + "Хавсралтыг ачаалж чадсангүй. Дахин оролдоно уу." + "Сүлжээ бэлэн биш байна. Дахин оролдоно уу." + "Текстийг устгах" + "Текст болон тоо оруулах хооронд сэлгэх" + "Өөр оролцогчид нэмэх" + "Оролцогчдыг баталгаажуулах" + "Шинээр харилцан яриа үүсгэж байна" + "Энэ зүйлийг сонгох" + "Видео тоглуулах" + "Хүмүүс & тохируулга" + "Дебаг" + "Хүмүүс & тохируулга" + "Ерөнхий" + "Энэ харилцаан доторх хүмүүс" + "Дуудлага хийх" + "Зурвас илгээх" + "Зурвас илгээх<br/><small>%s-с</small>" + + Зураг илгээх + Зураг илгээх + + + Аудио илгээх + Аудио илгээх + + + Видео илгээх + Видео илгээх + + + Харилцагчийн карт илгээх + Харилцагчийн карт илгээх + + + Хавсралт илгээх + Хавсралт илгээх + + + Илгээхэд бэлэн %d хавсралт байна + Илгээхэд бэлэн нэг хавсралт байна + + "Санал хүсэлт илгээх" + "Google Play дэлгүүр дээр харах" + "Хувилбарын мэдээлэл" + "Хувилбар %1$s" + "Нээлттэй эхийн лиценз" + "Мэдэгдэл" + "Хавсралтын дээд хэмжээнд хүрсэн байна" + "Хавсралтыг татаж авах үйлдэл амжилтгүй болсон байна." + "Харилцагчидад нэмэх үү?" + "Харилцагч нэмэх" + "Гарчиг" + "Гарчиг: " + "%s%s" + "Харилцагчийн картыг ачаалж байна" + "Харилцагчийн картыг дуудаж чадсангүй" + "Харилцагчийн картыг харах" + + %d харилцагч + %d харилцагч + + "Харилцагчийн картууд" + "Төрсөн өдөр" + "Тэмдэглэл" + "Зурвасыг дамжуулах" + "Хариулах" + "+1" + "+%d" + "SMS идэвхгүй" + "Илгээхийн тулд Messaging-г үндсэн SMS апп болго" + "Messaging-г үндсэн SMS апп болгох" + "Солих" + "Зурвас хүлээн авахын тулд Messaging-г үндсэн SMS апп-аар тохируулаарай" + "SMS зурвас илгээхээр сонгосон ямар ч SIM алга байна" + "Энэ апп-г төхөөрөмжийн эзэмшигч зөвшөөрөөгүй." + "ТИЙМ" + "Харилцаанд хэт олон оролцогч байна" + + Харилцагчийн хаяг буруу байна + Харилцагчийн хаяг буруу байна + + "Камерийн дүрсийг дуудаж чадсангүй" + "Та: " + "%s: " + "Ноорог" + "Шинэ харилцаа эхлүүлмэгц энэ жагсаалтад харагдах болно" + "Архивлагдсан харилцаанууд энд харагдана" + "Харилцааг ачаалж байна..." + "Зураг" + "Аудио клип" + "Видео" + "Харилцагчийн карт" + "MMS" + "Буцаах" + "Дахин оролдох" + "Шинэ зурвас эхлүүлэхийн тулд харилцагчийн нэр буюу утасны дугаар оруулна уу" + "Хориглох" + "%s-г хориглох" + "%s-н хориг авах" + "%s-г хориглох уу?" + "Та энэ дугаараас үргэлжлүүлэн зурвас хүлээн авах боловч мэдэгдэл авахгүй. Энэ харилцааг архивлах болно." + "Хориглогдсон харилцагчид" + "ХОРИГ АВАХ" + "Хориглогдсон харилцагчид" + "Документ сангаас зураг сонгох" + "Зурвасыг илгээж байна" + "Зурвас илгээгдсэн" + "Үүрэн дата унтарсан. Тохиргоог шалгана уу." + "Нислэгийн горимд зурвас илгээх боломжгүй" + "Зурвасыг илгээж чадсангүй." + "Зурвасыг татаж авсан" + "Гар утасны интернет холболт идэвхгүй байна. Тохиргоог дахин шалгана уу." + "Airplane mode дээр тавьсан байгаа тул мэдээллийг татаж авах боломжгүй байна" + "Мэдээллийг татаж авах боломжгүй байна" + "Тэг" + "Нэг" + "Хоёр" + "Гурав" + "Дөрөв" + "Тав" + "Зургаа" + "Долоо" + "Найм" + "Ес" + "%2$d алдаа бүхий %1$s мессежийг илгээх боломжгүй" + "Үл мэдэгдэх зөөвөрлөгчөөр ирсэн мессежийг илгээх боломжгүй, алдаа %1$d" + "Fwd: %s" + "Зурвас илгээгдээгүй: үйлчилгээ сүлжээн дээр идэвхжээгүй" + "Зурвас илгээгдээгүй: чиглэлийн хаяг буруу" + "Зурвас илгээгдээгүй: хүчингүй зурвас" + "Зурвас илгээгдээгүй: дэмжигдээгүй контент" + "Зурвас илгээгдээгүй: дэмжигдээгүй зурвас" + "Мессежийг илгээж болохгүй байна: хэтэрхий их хэмжээтэй байна" + "Шинэ зурвас" + "Харагдац" + "Зураг" + "Тохирох аппликешныг олж чадсангүй" + "Хүлээн авагчийг арилгах" + "Шинэ зурвас" + "Цуцлах" + "Хандалтын цэгийг засах" + "Тохируулаагүй" + "Нэр" + "APN" + "MMSC" + "MMS прокси" + "MMS порт" + "MCC" + "MNC" + "APN төрөл" + "APN устгах" + "Шинэ APN" + "Хадгалах" + "Устгах" + "Нэр оруулах талбар хоосон байж болохгүй." + "APN хоосон байж болохгүй." + "MCC талбар 3 цифртэй байх ёстой." + "MNC талбар 2 буюу 3 цифртэй байх ёстой." + "Үндсэн APN тохиргоог сэргээж байна" + "Үндсэн рүү сэргээх" + "Үндсэн APN тохиргоог дахин шинэчилж дууслаа." + "Гарчиггүй" + "Хандалтын Цэгийн Нэрс" + "APNs" + "Шинэ APN" + "Энэ хэрэглэгчийн хувьд Хандалтын цэгийн нэрийн тохиргоог ашиглах боломжгүй" + "Санах ойд хуулах уу?" + "Хуулах" + "%s руу" + "Ерөнхий" + "Дэлгэрэнгүй" + "Ерөнхий тохиргоо" + "Дэлгэрэнгүй тохиргоо" + "\" %s \"SIM" + "Бүх хүлээн авагч руу хувийн SMS мессеж илгээх. Зөвхөн та ямар ч хариу авахгүй" + "Бүх хүлээн авагч руу нэг MMS илгээх" + "Тодорхойгүй дугаар" + "Шинэ зурвас" + "Шинэ зурвас." + "SIM сонгогч" + "%1$s сонгосон, СИМ сонгогч" + "Гарчигийг засах" + "SIM-г сонгох эсвэл гарчигийг засах" + "Дуут бичлэг хийхийн тулд холбогдох хэсэгт даран түр хүлээнэ үү" + "Шинээр харилцан яриа үүсгэх" + "Messaging" + "Messaging-н жагсаалт" + "Messaging" + "Шинэ мэдээлэл" + "Харилцааны жагсаалт" + "Харилцан солилцсон мэдээллийг ачаалж байна" + "Зурвасуудыг дуудаж байна" + "Харилцан яриаг дэлгэрэнгүй харах" + "Илүү олон зурвасуудыг харах" + "Харилцаа холбоог устгасан байна" + "Харилцан яриа устсан. Messaging-н өөр харилцан яриа харуулахын тулд хүрнэ үү" + "Блоклосон байна" + "Блокыг арилгасан байна" + "Таны мэдээлэл хадгалах зай хангалтгүй байна. Зарим мэдээлэл алдагдах магадлалтай байна." + "Хавсралтыг сонгох" + "Сонголтыг баталгаажуулах" + "%d сонгосон" + "Нэг эсвэл түүнээс дээш тооны хавсралтыг арилгасны дараагаар дахин оролдоно уу." + "Та өөрийн мессежийг илгээх оролдлогыг хийж болох боловч нэг эсвэл түүнээс дээг тооны хавсралтыг арилгахаас нааш илгээгдэхгүй байх магадлалтай." + "Та нэг мессежээр нэг л видео илгээж болно. Нэмэлт видеонуудыг устгаад, дахин оролдоно уу." + "Messaging нь хавсралтыг ачаалж чадсангүй." + "Юутай ч илгээх" + "Харилцан яриаг эхэлж болохгүй байна" + "%1$s (%2$s)" + "%s сонгогдсон" + diff --git a/res/values-mr-rIN/arrays.xml b/res/values-mr-rIN/arrays.xml new file mode 100644 index 0000000..73cd4c2 --- /dev/null +++ b/res/values-mr-rIN/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "कोणताही विषय नाही" + "कोणताही विषय नाही" + + + "होय" + "नाही" + "ठीक आहे" + "हेहे" + "धन्यवाद" + "मी सहमत आहे" + "छान" + "मी वाटेत आहे" + "ठीक, नंतर मला आपल्याकडे परत येऊ द्या" + ":)" + ":(" + + diff --git a/res/values-mr-rIN/strings.xml b/res/values-mr-rIN/strings.xml new file mode 100644 index 0000000..418199c --- /dev/null +++ b/res/values-mr-rIN/strings.xml @@ -0,0 +1,519 @@ + + + + + "संदेशन" + "संदेशन" + "संभाषण निवडा" + "सेटिंग्ज" + "संदेश पाठवा" + "एखादे संलग्नक जोडा" + "मदत" + "सुस्वागतम" + "वगळा" + "पुढील >" + "पुढील" + "बाहेर पडा" + "सेटिंग्ज >" + "सेटिंग्ज" + "संदेशनला SMS, फोन आणि संपर्कांसाठी परवानगी आवश्यक आहे." + "आपण सेटिंग्ज > अॅप्स > संदेशन > परवानग्या मध्ये परवानग्या बदलू शकता." + "आपण सेटिंग्ज, अॅप्स, संदेशन, परवानग्या मध्ये परवानग्या बदलू शकता." + "वारंवार" + "सर्व संपर्क" + "%s वर पाठवा" + "चित्रे किंवा व्हिडिओ कॅप्चर करा" + "या डिव्हाइसवरून प्रतिमा निवडा" + "ऑडिओ रेकॉर्ड करा" + "फोटो निवडा" + "मीडिया निवडण्यात आला आहे." + "मीडियाची निवड रद्द करण्यात आली आहे." + "%d निवडले" + "प्रतिमा %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "प्रतिमा" + "ऑडिओ रेकॉर्ड करा" + "सामायिक करा" + "आत्ताच" + "आत्ता" + + %d मिनि + %d मिनिटे + + + %d तास + %d तास + + + %d दिवस + %d दिवस + + + %d आठवडा + %d आठवडे + + + %d महिना + %d महिने + + + %d वर्ष + %d वर्षे + + "वर्ग 0 संदेश" + "जतन करा" + "डिव्हाइसवर स्थान कमी आहे. स्थान मोकळे करण्‍यासाठी संदेशन जुने संदेश स्वयंचलितपणे हटवेल." + "संचयन स्थान संपत आहे" + "आपल्या डिव्हाइसवर आणखी स्थान उपलब्ध होईपर्यंत संदेशन संदेश पाठवू किंवा प्राप्त करू शकत नाही." + "कमी SMS संचयन. आपल्याला संदेश हटविण्याची आवश्यकता असू शकते." + "आपल्‍या फोन नंबरची पुष्‍टी करा" + "हे एक-वेळ चरण संदेशन आपले गट संदेश योग्यरित्या वितरीत करेल हे सुनिश्चित करेल." + "फोन नंबर" + "मीडिया असलेले सर्व संदेश हटवा" + "%s पेक्षा अधिक जुने संदेश हटवा" + "%s पेक्षा जुने संदेश स्वयं हटवा" + "दुर्लक्ष करा" + "मीडिया असलेले सर्व संदेश हटवायचे?" + "%s पेक्षा जुने संदेश हटवायचे?" + "%s पेक्षा जुने संदेश हटवायचे आणि स्वयं-हटवा चालू करायचे?" + "%s म्हणाले" + "आपण म्हटले" + "%s कडून संदेश" + "आपण एक संदेश पाठविला" + "पाठवित आहे..." + "पाठविले नाही. पुन्हा प्रयत्न करण्यासाठी स्पर्श करा." + "पाठविले नाही. पुन्हा प्रयत्न करत आहे…" + "पुन्हा पाठवा किंवा हटवा" + "कृपया आणीबाणी सेवांना एक व्हॉईस कॉल करा. यावेळी आपला मजकूर संदेश वितरीत केला जाऊ शकत नाही." + "अयशस्‍वी झाले" + "डाउनलोड करण्यासाठी नवीन MMS संदेश" + "नवीन MMS संदेश" + "डाउनलोड करू शकलो नाही" + "पुन्हा प्रयत्न करण्यासाठी स्पर्श करा" + "डाउनलोड करण्यासाठी स्पर्श करा" + "डाउनलोड करा किंवा हटवा" + "डाउनलोड करत आहे..." + "संदेश कालबाह्य झाला किंवा उपलब्‍ध नाही" + "आकार: %1$s, कालबाह्यता: %2$s" + "पाठवू शकलो नाही. प्राप्तकर्ता वैध नाही." + "नेटवर्कवर सेवा सक्रिय केली नाही" + "नेटवर्क समस्येमुळे पाठवू शकलो नाही" + "संदेश कालबाह्य झाला किंवा उपलब्‍ध नाही" + "(विषय नाही)" + "अज्ञात प्रेषक" + "वितरित" + "%2$s कडील %1$s संदेश डाउनलोड करू शकलो नाही" + "कमी मेमरीमुळे डेटाबेस ऑपरेशन पूर्ण करू शकलो नाही" + "संदेश पाठविला नाही" + "काही संदेश संदेशन मध्ये पाठविले गेले नाहीत" + + %d संभाषणांमधील %d संदेश + %d संभाषणांमधील %d संदेश + + "संदेश डाउनलोड केला नाही" + "काही संदेश संदेशन मध्ये डाउनलोड झाले नाहीत" + + %d संभाषणांमधील %d संदेश + %d संभाषणांमधील %d संदेश + + "%1$s वर संदेश पाठविला नाही" + "कृपया आणीबाणी सेवांना एक व्हॉईस कॉल करा. यावेळी आपला मजकूर संदेश %1$s ला वितरीत केला जाऊ शकत नाही." + + %d नवीन संदेश + %d नवीन संदेश + + "प्रारंभ करा" + "कॅमेरा उपलब्ध नाही" + "कॅमेरा उपलब्ध नाही" + "व्हिडिओ कॅप्चर उपलब्ध नाही" + "मीडिया जतन करू शकत नाही" + "चित्र घेऊ शकत नाही" + "परत" + "संग्रहित" + "हटवा" + "संग्रहण करा" + "संग्रहण रद्द करा" + "सूचना बंद करा" + "सूचना चालू करा" + "संपर्क जोडा" + "डाउनलोड करा" + "पाठवा" + "हटवा" + "हा संदेश हटवायचा?" + "ही क्रिया पूर्ववत केली जाणे शक्य नाही." + "हटवा" + + हे संभाषण हटवायचे? + ही संभाषणे हटवायची? + + "हटवा" + "रद्द करा" + "प्रति" + "एकाधिक प्रतिमा निवडा" + "निवडीची पुष्टी करा" + "+%d" + "ऑडिओ रेकॉर्ड करू शकत नाही. पुन्हा प्रयत्न करा." + "ऑडिओ प्ले करू शकत नाही. पुन्हा प्रयत्न करा." + "ऑडिओ जतन करू शकलो नाही. पुन्हा प्रयत्न करा." + "स्पर्श करा आणि धरून ठेवा" + ", " + " " + ": " + " " + "चित्र" + "ऑडिओ क्लिप" + "व्हिडिओ" + "संपर्क कार्ड" + "डाउनलोड करा" + "SMS द्वारे प्रत्युत्तर द्या" + "MMS द्वारे प्रत्‍युत्तर द्या" + "प्रत्युत्तर द्या" + + %d सहभागी + %d सहभागी + + "मी" + "संपर्क अवरोधित आणि संग्रहित केला" + "संपर्क अनावरोधित केला आणि संग्रहण करणे रद्द केले" + "%d संग्रहित केले" + "%d संग्रहण करणे रद्द केले" + "सूचना बंद" + "सूचना चालू" + "सर्व सेट केले. पुन्हा पाठवा ला स्पर्श करा." + "डीफॉल्ट SMS अॅप म्हणून संदेशन यशस्वीरित्या सेट झाले." + + संलग्नक टाकून द्या + संलग्नके टाकून द्या + + "ऑडिओ संलग्नक" + "ऑडिओ संलग्नक प्ले करा" + "विराम द्या" + "%s कडून संदेश: %s." + "%s कडील अयशस्‍वी संदेश: %s. वेळ: %s" + "%s कडील संदेश: %s. वेळ: %s" + "%s ला न पाठविलेला संदेश: %s. वेळ: %s" + "%s ला संदेश पाठवित आहे: %s. वेळ: %s" + "%s चा अयशस्‍वी संदेश: %s. वेळ: %s" + "%s चा संदेश: %s. वेळ: %s" + "%s कडील अयशस्‍वी संदेश: %s. वेळ: %s. %s" + "%s कडील संदेश: %s. वेळ: %s. %s" + "%s ला न पाठविलेला संदेश: %s. वेळ: %s" + "%s ला संदेश पाठवित आहे: %s. वेळ: %s" + "%s चा अयशस्‍वी संदेश: %s. वेळ: %s" + "%s चा संदेश: %s. वेळ: %s" + "संदेश अयशस्वी. पुन्हा प्रयत्न करण्यासाठी स्पर्श करा." + "%s सह संभाषण" + "विषय हटवा" + "व्‍हिडिओ कॅप्‍चर करा" + "एक स्थिर प्रतिमा कॅप्च करा" + "चित्र घ्या" + "व्हिडिओ रेकॉर्ड करणे प्रारंभ करा" + "पूर्ण स्क्रीन कॅमेर्‍यावर स्विच करा" + "पुढील आणि मागील कॅमेर्‍यात स्विच करा" + "रेकॉर्डिंग थांबवा आणि व्‍हिडिओ संलग्न करा" + "व्हिडिओ रेकॉर्ड करणे थांबवा" + "संदेशन फोटो" + + %d फोटो \"%s\" अल्बममध्‍ये जतन केला + %d फोटो \"%s\" अल्बममध्‍ये जतन केले + + + %d व्हिडिओ \"%s\" अल्बममध्‍ये जतन केला + %d व्हिडिओ \"%s\" अल्बममध्‍ये जतन केले + + + %d संलग्नक \"%s\" अल्बममध्‍ये जतन केले + %d संलग्नके \"%s\" अल्बममध्‍ये जतन केली + + + %d संलग्नक \"डाउनलोड\" मध्‍ये जतन केले + %d संलग्नके \"डाउनलोड\" मध्‍ये जतन केली + + + %d संलग्नक जतन केले + %d संलग्नके जतन केली + + + %d संलग्नक जतन करणे शक्य झाले नाही + %d संलग्नके जतन करणे शक्य झाले नाही + + "MMS संलग्नक जतन केले" + "सेटिंग्ज" + "संग्रहित" + "बंद करा" + "MMS" + "प्रगत" + "डीबग करणे" + "सूचना" + "ध्वनी" + "मूक" + "कंपन" + "अवरोधित केले" + "SMS वितरण अहवाल" + "आपण पाठविता त्या प्रत्‍येक SMS साठी वितरण अहवालाची विनंती करा" + "स्‍वयं-पुनर्प्राप्त करा" + "MMS संदेश स्वयंचलितपणे पुनर्प्राप्त करा" + "रोमिंग स्‍वयं-पुनर्प्राप्त करा" + "रोमिंगमध्‍ये असताना स्वयंचलितपणे MMS पुनर्प्राप्त करा" + "गट संदेशन" + "एक संदेश एकाधिक प्राप्तकर्त्यांना पाठविण्‍यासाठी MMS चा वापर करा." + "डीफॉल्ट SMS अॅप" + "डीफॉल्ट SMS अॅप" + + + "आपला फोन नंबर" + "अज्ञात" + "पाठविला जाणारा संदेश ध्वनी करतो" + "SMS साचवा" + "प्राप्त केलेला SMS अविश्लेषित डेटा बाह्य संचयन फायलीमध्‍ये साचवा" + "MMS साचवा" + "प्राप्त केलेला MMS अविश्लेषित डेटा बाह्य संचयन फायलीमध्‍ये साचवा" + "वायरलेस अॅलर्ट" + "संदेश पर्याय" + "मजकूर कॉपी करा" + "तपशील पहा" + "हटवा" + "अग्रेषित करा" + "संदेश तपशील" + "प्रकार: " + "मजकूर संदेश" + "मल्‍टीमीडिया संदेश" + "प्रेषक: " + "प्रति: " + "प्रेषित: " + "प्राप्त झाले: " + "विषय: " + "आकार: " + "प्राधान्य: " + "सिम: " + "उच्च" + "सामान्य" + "निम्न" + "सिम %s" + "लपविलेला प्रेषक पत्ता" + "संलग्नके लोड करताना संदेश पाठवू शकत नाही." + "संलग्नक लोड करू शकत नाही. पुन्हा प्रयत्न करा." + "नेटवर्क सज्ज नाही. पुन्हा प्रयत्न करा." + "मजकूर हटवा" + "मजकूर आणि संख्या प्रविष्ट करणे दरम्यान स्विच करा" + "अधिक सहभागी जोडा" + "सहभाग्यांची पुष्टी करा" + "नवीन संभाषण प्रारंभ करा" + "हा आयटम निवडा" + "व्हिडिओ प्ले करा" + "लोक आणि पर्याय" + "डीबग करणे" + "लोक आणि पर्याय" + "सामान्य" + "या संभाषणातील लोक" + "एक कॉल करा" + "संदेश पाठवा" + "<br/><small>%s वरून</small>संदेश पाठवा" + + फोटो पाठवा + फोटो पाठवा + + + ऑडिओ पाठवा + ऑडिओ पाठवा + + + व्हिडिओ पाठवा + व्हिडिओ पाठवा + + + संपर्क कार्ड पाठवा + संपर्क कार्ड पाठवा + + + संलग्नक पाठवा + संलग्नके पाठवा + + + पाठविण्‍यासाठी %d संलग्नक सज्ज + पाठविण्‍यासाठी %d संलग्नक सज्ज + + "अभिप्राय पाठवा" + "Google Play Store मध्ये पहा" + "आवृत्ती माहिती" + "आवृत्ती %1$s" + "मुक्त स्त्रोत परवाने" + "सूचना" + "संलग्नक मर्यादेपर्यंत पोहोचले" + "संलग्नक लोड करण्यात अयशस्वी." + "संपर्कांवर जोडायचे?" + "संपर्क जोडा" + "विषय" + "विषय: " + "%s%s" + "संपर्क कार्ड लोड करीत आहे" + "संपर्क कार्ड लोड करू शकलो नाही" + "संपर्क कार्ड पहा" + + %d संपर्क + %d संपर्क + + "संपर्क कार्ड" + "जन्मदिवस" + "टिपा" + "संदेश अग्रेषित करा" + "प्रत्युत्तर द्या" + "+1" + "+%d" + "SMS अक्षम" + "पाठविण्यासाठी, डीफॉल्ट SMS अॅप म्हणून संदेशन सेट करा" + "डीफॉल्ट SMS अॅप म्हणून संदेशन सेट करा" + "बदला" + "संदेश प्राप्त करण्यासाठी, डीफॉल्ट SMS अॅप म्हणून संदेशन सेट करा" + "SMS संदेश पाठविण्यासाठी कोणतेही प्राधान्यकृत सिम निवडले नाही" + "या अॅप ला डिव्हाइस मालकाने अनुमती दिलेली नाही." + "ठीक आहे" + "एका संभाषणात पुष्कळ सहभागी आहेत" + + अवैध संपर्क + अवैध संपर्क + + "कॅमेरा प्रतिमा लोड करू शकलो नाही" + "आपण: " + "%s: " + "मसुदा" + "आपण नवीन संभाषण प्रारंभ केल्यावर, आपल्याला तो येथे सूचीबद्ध केलेला दिसेल" + "संग्रहित केलेली संभाषणे येथे दिसतात" + "संभाषणे लोड करीत आहे…" + "चित्र" + "ऑडिओ क्लिप" + "व्हिडिओ" + "संपर्क कार्ड" + "MMS" + "पूर्ववत करा" + "पुन्हा प्रयत्न करा" + "एक नवीन संदेश प्रारंभ करण्यासाठी एक संपर्क नाव किंवा फोन नंबर प्रविष्ट करा" + "अवरोधित करा" + "%s अवरोधित करा" + "%s अनावरोधित करा" + "%s अवरोधित करायचे?" + "आपण या नंबरवरून संदेश प्राप्त करणे सुरु ठेवाल परंतु आपल्याला आणखी सूचित केले जाणार नाही. हे संभाषम संग्रहित केले जाईल." + "अवरोधित संपर्क" + "अनावरोधित करा" + "अवरोधित संपर्क" + "दस्तऐवज लायब्ररीतून प्रतिमा निवडा" + "संदेश पाठवित आहे" + "संदेश पाठवला" + "सेल्युलर डेटा बंद आहे. आपल्या सेटिंग्ज तपासा." + "विमान मोडमध्ये संदेश पाठवू शकत नाही" + "संदेश पाठविला जाऊ शकला नाही" + "संदेश डाउनलोड केला" + "सेल्युलर डेटा बंद आहे. आपल्या सेटिंग्ज तपासा." + "विमान मोडमध्ये संदेश डाउनलोड करू शकत नाही" + "संदेश डाउनलोड करणे शक्य झाले नाही" + "शून्य" + "एक" + "दोन" + "तीन" + "चार" + "पाच" + "सहा" + "सात" + "आठ" + "नऊ" + "%1$s सह संदेश पाठवू शकत नाही, त्रुटी %2$d" + "अज्ञात वाहकासह संदेश पाठवू शकत नाही, त्रुटी %1$d" + "अग्रेषित: %s" + "संदेश पाठविला नाही: नेटवर्क वर सेवा सक्रिय नाही" + "संदेश पाठविला नाही: अवैध गंतव्यस्‍थान पत्ता" + "संदेश पाठविला नाही: अवैध संदेश" + "संदेश पाठविला नाही: असमर्थित सामग्री" + "संदेश पाठविला नाही: असमर्थित संदेश" + "संदेश पाठविला नाही: खूप मोठा" + "नवीन संदेश" + "पहा" + "प्रतिमा" + "योग्य अनुप्रयोग शोधणे शक्य झाले नाही" + "प्राप्तकर्ता काढा" + "नवीन संदेश" + "रद्द करा" + "प्रवेश बिंदू संपादित करा" + "सेट केले नाही" + "नाव" + "APN" + "MMSC" + "MMS प्रॉक्सी" + "MMS पोर्ट" + "MCC" + "MNC" + "APN प्रकार" + "APN हटवा" + "नवीन APN" + "जतन करा" + "टाकून द्या" + "नाव फील्ड रिक्त असू शकत नाही." + "APN रिक्त असू शकत नाही." + "MCC फील्ड 3 अंकी असणे आवश्यक आहे." + "MNC फील्ड 2 किंवा 3 अंकी असणे आवश्यक आहे." + "डीफॉल्ट APN सेटिंग्ज पुनर्संचयित करत आहे." + "डीफॉल्टवर रीसेट करा" + "डीफॉल्ट APN सेटिंग्ज रीसेट करणे पूर्ण झाले." + "अशीर्षकांकित" + "प्रवेश बिंदू नावे" + "APN" + "नवीन APN" + "या वापरकर्त्यासाठी प्रवेश बिंदू नाव सेटिंग्ज उपलब्ध नाहीत" + "क्लिपबोर्डवर कॉपी करायचे?" + "कॉपी करा" + "%s वर" + "सामान्य" + "प्रगत" + "सामान्य सेटिंग्ज" + "प्रगत सेटिंग्ज" + "\"%s\" सिम" + "सर्व प्राप्तकर्त्यांना वैयक्तिक SMS संदेश पाठवा. केवळ आपणच कोणतीही प्रत्युत्तरे मिळवाल" + "सर्व प्राप्तकर्त्यांना एक एकल MMS पाठवा" + "अज्ञात नंबर" + "नवीन संदेश" + "नवीन संदेश." + "सिम निवडकर्ता" + "%1$s निवडले, सिम निवडकर्ता" + "विषय संपादित करा" + "सिम निवडा किंवा विषय संपादित करा" + "ऑडिओ रेकॉर्ड करण्‍यासाठी स्पर्श करा आणि धरून ठेवा" + "नवीन संभाषण प्रारंभ करा" + "संदेशन" + "संदेशन सूची" + "संदेशन" + "नवीन संदेश" + "संभाषण सूची" + "संभाषणे लोड करीत आहे" + "संदेश लोड करत आहे" + "अधिक संभाषणे पहा" + "अधिक संदेश पहा" + "संभाषण हटवले" + "संभाषण हटविले. भिन्न संदेशन संभाषण दर्शविण्यासाठी स्पर्श करा" + "अवरोधित केले" + "अनावरोधित केले" + "संचयन स्थान कमी आहे. काही डेटा गमावला जाऊ शकतो." + "संलग्नके निवडा" + "निवडीची पुष्टी करा" + "%d निवडली" + "कृपया एक किंवा अधिक संलग्नके काढा आणि पुन्हा प्रयत्न करा." + "आपण आपला संदेश पाठविण्याचा प्रयत्न करू शकता, परंतु आपण जोपर्यंत एक किंवा अधिक संलग्नके काढत नाही तोपर्यंत तो वितरित केला जाऊ शकत नाही." + "आपण प्रति संदेश केवळ एक व्हिडिओ पाठवू शकता. कृपया अतिरिक्त व्हिडिओ काढा आणि पुन्हा प्रयत्न करा." + "संलग्नक लोड करण्यात संदेशन अयशस्वी झाले." + "तरीही पाठवा" + "संभाषण प्रारंभ करू शकलो नाही" + "%1$s (%2$s)" + "%s निवडले" + diff --git a/res/values-ms-rMY/arrays.xml b/res/values-ms-rMY/arrays.xml new file mode 100644 index 0000000..10f0c1c --- /dev/null +++ b/res/values-ms-rMY/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "tiada subjek" + "tiada subjek" + + + "Ya" + "Tidak" + "OK" + "Hehe" + "Terima kasih" + "Saya bersetuju" + "Nice" + "Saya dalam perjalanan" + "OK, saya akan hubungi anda semula" + ":)" + ":(" + + diff --git a/res/values-ms-rMY/strings.xml b/res/values-ms-rMY/strings.xml new file mode 100644 index 0000000..80cb5c4 --- /dev/null +++ b/res/values-ms-rMY/strings.xml @@ -0,0 +1,519 @@ + + + + + "Pemesejan" + "Pemesejan" + "Pilih perbualan" + "Tetapan" + "Hantar Mesej" + "Tambah lampiran" + "Bantuan" + "Selamat datang" + "Langkau" + "Seterusnya >" + "Seterusnya" + "Keluar" + "Tetapan >" + "Tetapan" + "Pemesejan memerlukan kebenaran untuk mengakses SMS, Telefon dan Kenalan." + "Anda boleh menukar kebenaran dalam Tetapan > Apl > Pemesejan > Kebenaran." + "Anda boleh menukar kebenaran dalam Tetapan, Apl, Pemesejan, Kebenaran." + "Kerap" + "Semua kenalan" + "Hantar ke %s" + "Abadikan gambar atau video" + "Pilih imej dari peranti ini" + "Rakam audio" + "Pilih foto" + "Media dipilih." + "Media tidak dipilih." + "%d dipilih" + "imej %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "imej" + "Rakam audio" + "Kongsi" + "Sebentar tadi" + "Sekarang" + + %d min + %d min + + + %d jam + %d jam + + + %d hari + %d hari + + + %d minggu + seminggu + + + %d bulan + sebulan + + + %d tahun + setahun + + "Mesej kelas 0" + "Simpan" + "Peranti kekurangan ruang. Pemesejan akan memadamkan mesej yang lebih lama secara automatik untuk mengosongkan ruang." + "Ruang storan semakin berkurangan" + "Pemesejan mungkin tidak akan menghantar atau menerima mesej sehingga lebih banyak ruang tersedia pada peranti anda." + "Storan SMS kurang. Anda mungkin perlu memadamkan mesej." + "Sahkan nombor telefon anda" + "Langkah sekali ini akan memastikan Pemesejan menghantar mesej kumpulan anda dengan betul." + "Nombor telefon" + "Padamkan semua mesej dengan media" + "Padamkan mesej yang lebih lama daripada %s" + "Auto padam mesej yang lebih lama daripada %s" + "Abaikan" + "Padam semua mesej dengan media?" + "Padam mesej yang lebih lama daripada %s?" + "Padam mesej yang lebih lama daripada %s dan hidupkan autopadam?" + "%s kata" + "Anda kata" + "Mesej daripada %s" + "Anda telah menghantar mesej" + "Menghantar…" + "Tidak dihantar. Sentuh untuk mencuba lagi." + "Tidak dihantar. Mencuba lagi..." + "Hantar semula atau padam" + "Sila buat panggilan suara ke perkhidmatan kecemasan. Mesej teks anda tidak dapat dihantar pada masa ini." + "Gagal" + "Mesej MMS baharu untuk dimuat turun" + "Mesej MMS baharu" + "Tidak dapat memuat turun" + "Sentuh untuk mencuba lagi" + "Sentuh untuk memuat turun" + "Muat turun atau padam" + "Memuat turun..." + "Mesej telah tamat tempoh atau tidak tersedia" + "saiz: %1$s, tarikh luput: %2$s" + "Tidak dapat menghantar. Penerima tidak sah." + "Perkhidmatan tidak diaktifkan pada rangkaian" + "Tidak dapat menghantar kerana masalah rangkaian" + "Mesej telah tamat tempoh atau tidak tersedia" + "(Tiada subjek)" + "Pengirim tidak dikenali" + "Dikirimkan" + "Tidak dapat memuat turun %1$s daripada %2$s." + "Tidak dapat menyelesaikan operasi pangkalan data akibat memori rendah" + "Mesej tidak dihantar" + "Sesetengah mesej tidak dihantar dalam Pemesejan" + + %d mesej dalam %d perbualan + %d mesej dalam satu perbualan + + "Mesej tidak dimuat turun" + "Sesetengah mesej tidak dimuat turun dalam Pemesejan" + + %d mesej dalam %d perbualan + %d mesej dalam satu perbualan + + "Mesej ke %1$s tidak dihantar" + "Sila buat panggilan suara ke perkhidmatan kecemasan. Mesej teks anda ke %1$s tidak dapat dihantar pada masa ini." + + %d mesej baharu + Mesej baharu + + "Mula" + "Kamera tidak tersedia" + "Kamera tidak tersedia" + "Pengabadian video tidak tersedia" + "Tidak dapat menyimpan media" + "Tidak boleh mengambil gambar" + "Kembali" + "Diarkibkan" + "Padam" + "Arkib" + "Nyaharkib" + "Matikan pemberitahuan" + "Hidupkan pemberitahuan" + "Tambah kenalan" + "Muat turun" + "Hantar" + "Padam" + "Padamkan mesej ini?" + "Tindakan ini tidak boleh dibuat asal." + "Padam" + + Padamkan perbualan ini? + Padamkan perbualan ini? + + "Padam" + "Batal" + "Ke" + "Pilih berbilang imej" + "Sahkan pilihan" + "+%d" + "Tidak dapat merakam audio. Cuba lagi." + "Tidak dapat memainkan audio. Cuba lagi." + "Tidak dapat menyimpan audio. Cuba lagi." + "Sentuh & tahan" + ", " + " " + ": " + " " + "Gambar" + "Klip audio" + "Video dalam strim" + "Kad kenalan" + "Muat turun" + "Balas melalui SMS" + "Balas melalui MMS" + "Balas" + + %d peserta + %d peserta + + "Saya" + "Kenalan yang disekat & diarkib" + "Kenalan dinyahsekat & dinyaharkib" + "%d diarkibkan" + "%d dinyaharkib" + "Pemberitahuan dimatikan" + "Pemberitahuan dihidupkan" + "Semuanya sedia. Sentuh Hantar lagi." + "Pemesejan berjaya dijadikan sebagai apl SMS lalai." + + Buang lampiran + Buang lampiran + + "Lampiran audio" + "Mainkan lampiran audio" + "Jeda" + "Mesej daripada %s: %s." + "Mesej gagal daripada %s: %s. Masa: %s." + "Mesej daripada %s: %s. Masa: %s." + "Mesej tidak dihantar kepada %s: %s. Masa: %s." + "Menghantar mesej kepada %s: %s. Masa: %s." + "Mesej gagal kepada %s: %s. Masa: %s." + "Mesej kepada %s: %s. Masa: %s." + "Mesej gagal daripada %s: %s. Masa: %s. %s." + "Mesej daripada %s: %s. Masa: %s. %s." + "Mesej tidak dihantar kepada %s: %s. Masa: %s." + "Menghantar mesej kepada %s: %s. Masa: %s." + "Mesej gagal kepada %s: %s. Masa: %s." + "Mesej kepada %s: %s. Masa: %s." + "Mesej gagal. Sentuh untuk mencuba semula." + "Perbualan dengan %s" + "Padam subjek" + "Rakam video" + "Abadikan imej pegun" + "Ambil gambar" + "Mula merakam video" + "Tukar kepada kamera skrin penuh" + "Bertukar antara kamera hadapan dan belakang" + "Berhenti merakam dan lampirkan video" + "Berhenti merakam video" + "Foto pemesejan" + + %d foto disimpan ke album \"%s\" + %d foto disimpan ke album \"%s\" + + + %d video disimpan ke album \"%s\" + %d video disimpan ke album \"%s\" + + + %d lampiran disimpan ke album \"%s\" + %d lampiran disimpan ke album \"%s\" + + + %d lampiran disimpan ke \"Muat Turun\" + %d lampiran disimpan ke \"Muat Turun\" + + + %d lampiran disimpan + %d lampiran disimpan + + + Tidak dapat menyimpan %d lampiran + Tidak dapat menyimpan %d lampiran + + "Lampiran MMS yang disimpan" + "Tetapan" + "Diarkibkan" + "Tutup" + "MMS" + "Lanjutan" + "Nyahpepijat" + "Pemberitahuan" + "Bunyi" + "Senyap" + "Bergetar" + "Disekat" + "Laporan penghantaran SMS" + "Minta laporan penghantaran untuk setiap SMS yang anda hantar" + "Auto dapat kembali" + "Dapatkan semula mesej MMS secara automatik" + "Dapatkan kembali perayauan secara automatik" + "Dapatkan MMS secara automatik semasa merayau" + "Pemesejan kumpulan" + "Gunakan MMS untuk menghantar mesej tunggal apabila terdapat berbilang penerima" + "Apl SMS lalai" + "Apl SMS lalai" + + + "Nombor telefon anda" + "Tidak diketahui" + "Bunyi mesej keluar" + "Buang SMS" + "Buang data mentah SMS yang diterima ke dalam fail storan luaran" + "Buang MMS" + "Buang data mentah MMS yang diterima ke dalam fail storan luaran" + "Makluman wayarles" + "Pilihan mesej" + "Salin teks" + "Lihat butiran" + "Padam" + "Kirim semula" + "Butiran mesej" + "Jenis: " + "Mesej teks" + "Mesej multimedia" + "Daripada: " + "Kepada: " + "Dihantar: " + "Diterima: " + "Subjek: " + "Saiz: " + "Keutamaan: " + "SIM: " + "Tinggi" + "Biasa" + "Rendah" + "SIM %s" + "Alamat pengirim tersembunyi" + "Tidak boleh menghantar mesej semasa memuatkan lampiran." + "Tidak dapat memuatkan lampiran. Cuba lagi." + "Rangkaian tidak sedia. Cuba lagi." + "Padam teks" + "Beralih antara memasukkan teks dan nombor" + "Tambah peserta lagi" + "Sahkan peserta" + "Mulakan perbualan baharu" + "Pilih item ini" + "Mainkan video" + "Orang & pilihan" + "Nyahpepijat" + "Orang & pilihan" + "Umum" + "Orang dalam perbualan ini" + "Buat panggilan" + "Hantar mesej" + "Hantar mesej<br/><small>from %s</small>" + + Hantar foto + Hantar foto + + + Hantar audio + Hantar audio + + + Hantar video + Hantar video + + + Hantar kad kenalan + Hantar kad kenalan + + + Hantar lampiran + Hantar lampiran + + + %d lampiran sedia untuk dihantar + Satu lampiran sedia untuk dihantar + + "Hantar maklum balas" + "Lihat di Gedung Google Play" + "Maklumat versi" + "Versi %1$s" + "Lesen sumber terbuka" + "Pemberitahuan" + "Had lampiran dicapai" + "Gagal memuatkan lampiran." + "Tambah pada Kenalan?" + "Tambah Kenalan" + "Subjek" + "Subjek: " + "%s%s" + "Memuatkan kad kenalan" + "Tidak dapat memuatkan kad kenalan" + "Lihat kad kenalan" + + %d kenalan + %d kenalan + + "Kad kenalan" + "Tarikh lahir" + "Nota" + "Kirim semula mesej" + "Balas" + "1" + "+%d" + "SMS dilumpuhkan" + "Untuk menghantar, jadikan Pemesejan apl SMS lalai" + "Jadikan Pemesejan apl SMS lalai" + "Tukar" + "Untuk menerima mesej, jadikan Pemesejan apl SMS lalai" + "Tiada SIM pilihan yang dipilih untuk menghantar mesej SMS" + "Apl ini tidak dibenarkan oleh pemilik peranti." + "OK" + "Terlalu ramai peserta dalam perbualan" + + Kenalan tidak sah + Kenalan tidak sah + + "Tidak dapat memuatkan imej kamera" + "Anda: " + "%s: " + "Draf" + "Sebaik sahaja anda memulakan perbualan baharu, anda akan melihat perbualan itu disenaraikan di sini" + "Perbualan diarkibkan dipaparkan di sini" + "Memuatkan perbualan..." + "Gambar" + "Klip audio" + "Video" + "Kad kenalan" + "MMS" + "Buat asal" + "Cuba semula" + "Masukkan nama kenalan atau nombor telefon untuk memulakan mesej baharu" + "Sekat" + "Sekat %s" + "Nyahsekat %s" + "Sekat %s?" + "Anda akan terus menerima mesej daripada nombor ini tetapi tidak akan dimaklumkan lagi. Perbualan ini akan diarkibkan." + "Kenalan yang disekat" + "NYAHSEKAT" + "Kenalan yang disekat" + "Pilih imej dari perpustakaan dokumen" + "Menghantar mesej" + "Mesej dihantar" + "Data selular dimatikan. Periksa tetapan anda." + "Tidak dapat menghantar mesej dalam mod Pesawat" + "Mesej tidak dapat dihantar" + "Mesej dimuat turun" + "Data selular dimatikan. Periksa tetapan anda." + "Tidak dapat memuat turun mesej dalam mod Pesawat" + "Mesej tidak dapat dimuat turun" + "Sifar" + "Satu" + "Dua" + "Tiga" + "Empat" + "Lima" + "Enam" + "Tujuh" + "Lapan" + "Sembilan" + "Tidak dapat menghantar mesej dengan %1$s, ralat %2$d" + "Tidak dapat menghantar mesej dengan pembawa yang tidak diketahui, ralat %1$d" + "K.S.: %s" + "Mesej tidak dihantar: perkhidmatan tidak diaktifkan di rangkaian" + "Mesej tidak dihantar: alamat destinasi tidak sah" + "Mesej tidak dihantar: mesej tidak sah" + "Mesej tidak dihantar: kandungan tidak disokong" + "Mesej tidak dihantar: mesej tidak disokong" + "Mesej tidak dihantar: terlalu besar" + "Mesej baharu" + "Paparan" + "Imej" + "Tidak menemui aplikasi yang sesuai" + "Alih keluar penerima" + "Mesej baharu" + "Batal" + "Edit titik capaian" + "Tidak ditetapkan" + "Nama" + "APN" + "MMSC" + "Proksi MMS" + "Port MMS" + "MCC" + "MNC" + "Jenis APN" + "Padam APN" + "APN Baharu" + "Simpan" + "Buang" + "Medan Nama tidak boleh kosong." + "APN tidak boleh kosong." + "Medan MCC mestilah 3 digit." + "Medan MNC hendaklah mempunyai 2 atau 3 digit." + "Memulihkan tetapan lalai APN." + "Tetapkan semula kepada lalai" + "Penetapan semula tetapan lalai APN selesai." + "Tidak bertajuk" + "Nama Titik Capaian" + "APN" + "APN Baharu" + "Tetapan Nama Titik Capaian tidak tersedia untuk pengguna ini" + "Salin ke papan keratan?" + "Salin" + "ke %s" + "Umum" + "Lanjutan" + "Tetapan umum" + "Tetapan terperinci" + "SIM \"%s\"" + "Hantar mesej SMS individu kepada semua penerima. Hanya anda yang akan menerima sebarang balasan" + "Hantar MMS tunggal kepada semua penerima" + "Nombor tidak diketahui" + "Mesej baharu" + "Mesej baharu" + "Pemilih SIM" + "%1$s dipilih, pemilih SIM" + "Edit subjek" + "Pilih SIM atau edit subjek" + "Sentuh dan tahan untuk merakam audio" + "Mulakan perbualan baharu" + "Pemesejan" + "Senarai Pemesejan" + "Pemesejan" + "Mesej baharu" + "Senarai perbualan" + "Memuatkan perbualan" + "Memuatkan mesej" + "Lihat lagi perbualan" + "Lihat pesanan lagi" + "Perbualan dipadamkan" + "Perbualan dipadamkan. Sentuh untuk menunjukkan perbualan Pemesejan yang lain" + "Disekat" + "Dinyahsekat" + "Ruang storan rendah. Sesetengah data mungkin hilang." + "Pilih lampiran" + "Sahkan pilihan" + "%d dipilih" + "Sila alih keluar satu atau lebih lampiran, kemudian cuba lagi." + "Anda boleh cuba menghantar mesej tetapi mesej itu mungkin tidak dihantar kecuali anda mengalih keluar satu atau beberapa lampiran." + "Anda hanya boleh menghantar satu video bagi setiap mesej. Sila alih keluar video tambahan dan cuba lagi." + "Pemesejan gagal memuatkan lampiran." + "Hantar juga" + "Tidak dapat memulakan perbualan" + "%1$s (%2$s)" + "%s dipilih" + diff --git a/res/values-my-rMM/arrays.xml b/res/values-my-rMM/arrays.xml new file mode 100644 index 0000000..331ac85 --- /dev/null +++ b/res/values-my-rMM/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "အကြောင်းအရာ မရှိ" + "အကြောင်းအရာမဲ့" + + + "ဟုတ်" + "မဟုတ်" + "အိုကေ" + "ဟဲဟဲ" + "ကျေးဇူးပဲ" + "ကျွန်ုပ် သဘောတူသည်" + "ကောင်း" + "ကျွန်ုပ်၏ လမ်းပေါ်မှာ" + "အိုကေ၊ ကျွန်ုပ် နောက်မှာ ပြန်လာပါရစေ" + ":)" + ":(" + + diff --git a/res/values-my-rMM/strings.xml b/res/values-my-rMM/strings.xml new file mode 100644 index 0000000..35a6586 --- /dev/null +++ b/res/values-my-rMM/strings.xml @@ -0,0 +1,519 @@ + + + + + "စာပို့ခြင်း" + "စာပို့ခြင်း" + "စကားဝိုင်း ရွေးချယ်ပါ" + "ဆက်တင်များ" + "စာ ပို့ရန်" + "ပူးတွချက် တစ်ခု ထည့်ရန်" + "အကူအညီ" + "မင်္ဂလာပါ" + "ကျော်ရန်" + "နောက်တစ်ခု" + "နောက်တစ်ခု" + "ထွက်ရန်" + "ချိန်ညှိချက်များ >" + "ချိန်ညှိချက်များ" + "စာတိုပို့ခြင်က SMS၊ ဖုန်း နှင့် အဆက်အသွယ်များထံ ခွင့်ပြုချက် လိုအပ်သည်။" + "သင်သည် ခွင့်ပြုချက်များကို ဆက်တင်များထဲမှာ ပြောင်းလဲနိုင် > အက်ပ်များ > စာတိုပို့ခြင်း > ခွင့်ပြုချက်များ။" + "သင်သည် ခွင့်ပြုချက်များကို ဆက်တင်များ၊ အက်ပ်များ၊ စာတိုပို့ခြင်း၊ ခွင့်ပြုချက်များထဲတွင် ပြောင်းလဲနိုင်သည်။" + "မကြာခဏ" + "ဆက်သွယ်အားလုံး" + "%sသို့ ပို့ရန်" + "ပုံများ သို့မဟုတ် ဗီဒီယို ရိုက်ယူရန်" + "ဤစက်ပစ္စည်းမှ ပုံများ ရွေးရန်" + "အသံကို ဖမ်းရန်" + "ပုံရွေးရန်" + "မီဒီယာကိုရွေးချယ်ထားသည်။" + "မီဒီယာကို မရွေးချယ်ထားပါ။" + "%d ခု ရွေးချယ်ထား" + "ပုံ %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "ပုံ" + "အသံကို ဖမ်းရန်" + "မျှဝေရန်" + "ယခုလေးတင်" + "ယခု" + + %d မိနစ် + %d မိနစ် + + + %d နာရီ + %d နာရီ + + + %d ရက် + %d ရက် + + + %d ပတ် + ၁ ပတ် + + + %d + တစ်လ + + + %d နှစ် + တစ်နှစ် + + "အဆင် ၀ စာတို" + "သိမ်းဆည်းရန်" + "ကိရိယာထဲတွင် နေရာ နည်းနေသည်။ နေရာလွတ်ပေးရန် စာတိုပို့ခြင်းက စာတို စာသား အဟောင်းကို အလိုအလျောက် ဖယ်ရှားပစ်မည်။" + "သိုလှောင်ခန်း နေရာ ကုန်ပါတော့မည်" + "သင်၏ ကိရိယာထဲတွင် နေရာလွတ် နောက်ထပ် ပေါ်မလာမချင်း စာတိုပို့ခြင်းက စာတိုများကို ပို့ခြင်း သို့မဟုတ် လက်ခံခြင်း လုပ်နိုင်မည် မဟုတ်ပါ။" + "SMS သိုလှောင်ခန်း နည်းနေသည်။ သင်သည် စာများကို ဖျက်ချင် ဖျက်ရနိုင်သည်။" + "သင့် ဖုန်းနံပါတ်ကို အတည်ပြုပါ" + "တကြိမ် လုပ်ရသည့် ဒီခြေလှမ်းက စာတိုပို့ခြင်းမှ သင်၏ အုပ်စု စာတိုများကို စနစ်တကျ ပို့ပေးမှာကို စိတ်ချနိုင်ပါသည်။" + "ဖုန်း နံပါတ်" + "မီဒီယာနှင့် စာများ အားလုံးကို ဖျက်မည်" + "%s ထက် ပိုကြာသော စာများကို ဖျက်မည်" + "%s မတိုင်ခင်က စာများအား အလိုအလျောက် ဖျက်ရန်" + "လစ်လျှူရှုရန်" + "မီဒီယာနှင့် စာတိုများ ဖျက်မည်လား?" + "%s ထက် ပိုကြာသော စာများ ဖျက်မည်လား?" + "%s မတိုင်ခင်က စာများအား ဖျက်ပြီး စာများ အလိုအလျောက်ဖျက်နိုင်မှုကို ဖွင့်ထားမည်လား?" + "%s ပြောတာက" + "သင်ပြောတာက" + "%s ထံမှစာ" + "သင်စာပို့သည်" + "ပို့နေ၏…" + "မပို့နိုင်ပါ။ ထပ်မံကြိုးစားရန် ထိပါ။" + "မပို့နိုင်ပါ။ ထပ်မံကြိုးစားနေ၏…" + "ပြန်ပို့ရန် သို့မဟုတ် ဖျက်ရန်" + "အရေးပေါ်ဝန်ဆောင်မှုများထံ အသံဖြင့် ခေါ်ပါ။ ယခုအချိန်တွင် သင့် စာတို မပို့နိုင်ပါ။" + "မအောင်မြင်ခဲ့" + "MMS စာအသစ်ကို ဒေါင်းလုဒ် လုပ်ရန်" + "MMS စာအသစ်" + "ဒေါင်းလုဒ် မလုပ်နိုင်ခဲ့ပါ" + "ထပ်စမ်းရန် ထိပါ" + "ဒေါင်းလုပ်ရယူရန် ထိပါ" + "ဒေါင်းလုဒ် လုပ်ရန် သို့မဟုတ် ဖျက်ရန်" + "ဒေါင်းလုပ် လုပ်နေစဉ်" + "စာသက်တမ်းကုန်ဆုံးသွား၏ သို့မဟုတ် စာမရှိတော့ပါ။" + "ဆိုက်: %1$s၊ သက်တမ်း ကုန်ဆုံး: %2$s" + "မပို့နိုင်ပါ။ လက်ခံမည့်သူ မရယူနိုင်ပါ။" + "ကွန်ရက်တွင် ဝန်ဆောင်မှု စဖွင့်သတ်မှတ်ခြင်း မပြုရသေးပါ" + "ကွန်ရက် ပြဿနာကြောင့် မပို့နိုင်ခဲ့ပါ။" + "စာ သက်တမ်းကုန်ဆုံးသွား၏ သို့မဟုတ် စာမရှိတော့ပါ။" + "(အကြောင်းအရာမရှိ)" + "ပေးပို့သူ မသိရ" + "ပေးသွင်းပြီး" + "%1$sစာကို %2$sမှ ဒေါင်းလုဒ် မလုပ်နိုင်ခဲ့ပါ" + "မှတ်ဉာဏ် နည်းနေ၍ ဒေတာဘေ့စ် လုပ်ကိုင်စရာ မပြီးဆုံးနိုင်ခဲ့ပါ။" + "စာ မပို့ခဲ့ပါ" + "အချို့ စာတိုများကို စာပို့ခြင်းထဲတွင် မပို့ခဲ့ပါ။" + + စာတိုများ %d ခုမှာ စကားပြောခန်း %d ခု အတွင်းတွင် ဖြစ်၏ + စကားပြောခန်းတစ်ခုတွင်းရှိ စာတိုများ %d ခု + + "စာကို ဒေါင်းလုပ်မလုပ်ထားပါ" + "အချို့ စာတိုများကို စာပို့ခြင်းထဲသို့ ဒေါင်းလုဒ် မလုပ်ခဲ့ပါ။" + + စာတိုများ %d ခုမှာ စကားပြောခန်း %d ခု အတွင်းတွင် ဖြစ်၏ + စကားပြောခန်းတစ်ခုတွင်းရှိ စာတိုများ %d ခု + + "%1$s သို့ စာတို ပို့မရပါ" + "အရေးပေါ်ဝန်ဆောင်မှုများထံ အသံဖြင့် ခေါ်ပါ။ %1$s သို့ ယခုအချိန်တွင် သင့် စာတို မပို့နိုင်ပါ။" + + %d စာအသစ် + စာအသစ် + + "စတင်ရန်" + "ကင်မရာ အသုံးမပြုနိုင်ပါ" + "ကင်မရာ အသုံးမပြုနိုင်ပါ" + "ဗွီဒီယို မရိုက်ယူနိုင်ပါ" + "မီဒီယာအား မသိမ်းဆည်းနိုင်ပါ" + "ဓာတ်ပုံရိုက်၍ မရနိုင်ပါ" + "နောက်သို့" + "မော်ကွန်းတင်ပြီး" + "ဖျက်ရန်" + "မော်ကွန်း" + "မော်ကွန်းမှထုတ်ယူရန်" + "အက​ြောင်းကြားချက်များ ပိတ်ရန်" + "အကြောင်းကြားချက်များ ဖွင့်ရန်" + "အဆက်အသွယ် ထည့်ရန်" + "ဒေါင်းလုဒ် လုပ်ရန်" + "ပို့ရန်" + "ဖျက်ရန်" + "ဤ စာကို ဖျက်မလား?" + "ဒီလုပ်ဆောင်ချက်ကို ြပန်ဖျက်မရနိုင်ပါ။" + "ဖျက်ရန်" + + ဤစကားဝိုင်းများအား ဖျက်မည်လား? + ဤစကားဝိုင်းအား ဖျက်မည်လား? + + "ဖျက်ရန်" + "ထားတော့" + "သို့" + "ပုံများစွာ ရွေးချယ်ရန်" + "ရွေးချယ်မှုကို အတည်ပြုရန်" + "+%d" + "အသံ ဖမ်းမရပါ။ ထပ်မံကြိုးစားပါ။" + "အသံဖွင့်မရပါ။ ထပ်မံကြိုးစားပါ။" + "အသံ သိမ်းမရပါ။ ထပ်မံကြိုးစားပါ။" + "ထိပြီး & ကိုင်ထားရန်" + ", " + " " + ": " + " " + "ပုံ" + "အသံ ကလီပ်" + "ဗီဒီယို" + "အဆက်အသွယ် ကဒ်" + "ဒေါင်းလုဒ် လုပ်ရန်" + "SMS မှတစ်ဆင့် ဖြေကြားရန်" + "MMS ဖြင့် ဖြေကြားရန်" + "ဖြေကြားရန်" + + ပါဝင်သူ %d ယောက် + ပါဝင်သူ %d ယောက် + + "ကျွန်ုပ်" + "အဆက်အသွယ်ဖြတ်တောက်ပြီး & သိမ်းဆည်းပြီး" + "အဆက်အသွယ်အား မပိတ်ဆို့တော့ပါ &amp မှော်ကွန်းမတင်တော့ပါ" + "%d မှတ်တမ်းတင်သိမ်းဆည်းခဲ့၏" + "%d သိမ်းထားခြင်းမှ ပြန်ထုတ်ယူသည်" + "အသိပေးချက်များ ပိတ်ထား" + "အသိပေးချက်များ ဖွင့်ထား" + "အဆင်သင့်ဖြစ်ပြီ။ Send အား ထိပါ။" + "စာတို့ပို့ခြင်းကို SMS ပုံသေ app အဖြစ် အောင်မြင်စွာ သတ်မှတ်လိုက်ပြီ။" + + ပူးတွဲပါဖိုင်များအား ဖယ်ရှားပါ + ပူးတွဲပါဖိုင်အား ဖယ်ရှားပါ + + "အသံ ပူးတွဲဖိုင်" + "ပူးတွဲပါအသံကိုဖွင့်ပါ" + "ဆိုင်းငံ့ရန်" + "%s ထံမှ စာတို: %s ။" + "%s: %s မှ စာတိုပို့ခြင်းမအောင်မြင်ပါ။ အချိန်: %s" + "%s: %s မှ စာတို။ အချိန်: %s" + "%s: %s သို့ စာတိုမပို့ရသေးပါ။ အချိန်: %s" + "%s: %s သို့ စာတိုပို့နေသည်။ အချိန်: %s" + "%s: %s သို့ စာတိုပို့ခြင်းမအောင်မြင်ပါ။ အချိန်: %s" + "%s: %s သို့ စာတို။ အချိန်: %s" + "%s: %s မှ စာပို့ခြင်းမအောင်မြင်ပါ။အချိန်: %s. %s" + "%s: %sမှ စာတိုပို့နေသည်။ အချိန်: %s. %s" + "%s: %s သို့ စာတိုမပို့ရသေးပါ။ အချိန်: %s" + "%s: %s သို့ စာတိုပို့ နေသည်။အချိန်: %s" + "%s: %s သို့စာတိုပို့ခြင်းမအောင်မြင်ပါ။ အချိန်: %s" + "%s: %s သို့ စာတိုပို့နေသည်။ အချိန်: %s" + "စာတိုပို့ခြင်းမအောင်မြင်ပါ။ ပြန်လည်ကြိုးစားရန် ထိပါ။" + "%sနှင့် စကားဝိုင်းများ" + "အကြောင်းအရာကို ဖျက်ရန်" + "ဗီဒီယို ဖမ်းယူရန်" + "ပုံသေ တစ်ခုကို ရိုက်ယူရန်" + "ပုံ ရိုက်ယူရန်" + "ဗီဒီယို ဖမ်းယူမှု စတင်ရန်" + "မျက်နှာပြင် အပြည့် ကင်မရာသို့ ပြောင်းရန်" + "ကင်မရာ အရှေ့ဘက်နှင့် အနောက်ဘက် ပြောင်းရန်" + "မှတ်တမ်းတင်မှုရပ်ကာ ဗီဒီယို ချိတ်ရန်" + "ဗွီဒီယိုရိုက်ကူးခြင်း ရပ်ရန်" + "ဓာတ်ပုံများကို စာတိုပို့ခြင်း" + + %d ဓါတ်ပုံများကို \"%s\" အယ်လ်ဘမ်သို့ သိမ်းဆည်းပြီးပါပြီ + %d ဓါတ်ပုံကို \"%s\" အယ်လ်ဘမ်သို့ သိမ်းဆည်းပြီးပါပြီ + + + %d ဗီဒီယိုများကို \"%s\" အယ်လ်ဘမ်သို့ သိမ်းဆည်းပြီးပါပြီ + %d ဗီဒီယိုကို \"%s\" အယ်လ်ဘမ်သို့ သိမ်းဆည်းပြီးပါပြီ + + + %d ပူးတွဲပါဖိုင်များကို \"%s\" အယ်လ်ဘမ်သို့ သိမ်းဆည်းပြီးပါပြီ + %d ပူးတွဲပါဖိုင်ကို \"%s\" အယ်လ်ဘမ်သို့ သိမ်းဆည်းပြီးပါပြီ + + + %d ပူးတွဲပါဖိုင်များကို \"ဒေါင်းလုဒ်များ\" သို့ သိမ်းဆည်းပြီ:ပါပြီ + %d ပူးတွဲပါဖိုင်ကို \"ဒေါင်းလုဒ်များ\" သို့ သိမ်းဆည်းပြီးပါပြီ + + + ပူးတွဲပါဖိုင် %d ဖိုင် သိမ်းဆည်းပြီး၏ + ပူးတွဲပါဖိုင် %d ဖိုင် သိမ်းဆည်းပြီး၏ + + + ပူးတွဲပါဖိုင် %d အား မသိမ်းဆည်းနိုင်ခဲ့ပါ + ပူးတွဲပါဖိုင် %d ဖိုင်အား မသိမ်းဆည်းနိုင်ခဲ့ပါ + + "သိမ်းဆည်းထားသည့် MMS ပူးတွဲချက်" + "ဆက်တင်များ" + "မော်ကွန်းတင်ပြီး" + "ပိတ်ရန်" + "ရုပ်သံစာ" + "အဆင့်မြင့်" + "ဒီဘာဂ်" + "အကြောင်းကြားချက်များ" + "အသံ" + "အသံတိတ်ရန်" + "တုန်ခါရန်" + "ပိတ်ဆို့ထား" + "SMS ဖြန့်ဝေမှု အစီရင်ခံစာများ" + "သင် ပို့လိုက်သော စာတိုင်း အတွက် ပို့ပြီးကြောင်း အစီရင်ခံစာကို တောင်းဆိုမည်" + "အလိုအလျောက် ပြန်လည်ရယူမည်" + "MMS စာများကို အလိုအလျောက် ရယူရန်" + "ရုန်းမင်းလုပ်စဉ် အလိုအလျောက် ပြန်လည်ထုတ်ယူမည်" + "ရုန်းမင်းသုံးစဉ် MMS အလိုအလျောက် ရယူရန်" + "အဖွဲ့လိုက် စာပို့ခြင်း" + "စာတစ်စောင်ပို့ရန် လက်ခံသူအမြောက်အမြားရှိနေလျှင် ရုပ်သံစာကို သုံးရန်" + "စာတိုပို့ရန်သုံး အပလီကေးရှင်း" + "စာတိုပို့ရန်သုံး အပလီကေးရှင်း" + + + "သင့် ဖုန်းနံပါတ်" + "မသိရ" + "အပြင်သို့စာပေးပို့သည့် အသံများ" + "စိုစွတ် SMS" + "စိုစွတ် SMS အချက်လက်အကြမ်းများကို ပြင်ပသိုလှောင်မှုဖိုင် သို့လက်ခံ ရရှိပါသည်" + "စိုစွတ် MMS" + "စိုစွတ် MMS အချက်လက်အကြမ်းများကို ပြင်ပသိုလှောင်မှုဖိုင် သို့လက်ခံ ရရှိပါသည်" + "ကြိုးမဲ့ သတိပေးချက်များ" + "စာ အတွက် ရွေးစရာများ" + "စာကူးယူရန်" + "အသေးစိတ်များကို ကြည့်မည်" + "ဖျက်ရန်" + "တစ်ဆင့်ပို့ရန်" + "စာသေးစိတ်များ" + "အမျိုးအစား: " + "စာတို" + "မာလ်တီမီဒီယာ စာ" + "မှ: " + "သို့: " + "ပို့ပြီး: " + "လက်ခံရရှိခဲ့: " + "အကြောင်းအရာ: " + "အရွယ်အစား: " + "ဦးစားပေးမှု: " + "ဆင်းမ်: " + "မြင့်" + "ပုံမှန်" + "နိမ့်" + "ဆင်းမ် %s" + "ဝှက်ထားသော စာပို့သူ လိပ်စာ" + "ပူးတွဲချက်များဖွင့်စဉ် စာပို့၍မရပါ။" + "ပူးတွဲဖိုင် မရယူနိုင်ပါ။ ထပ်မံကြိုးစားပါ။" + "ကွန်ရက်အဆင်သင့် မဖြစ်သေးပါ။ ထပ်မံကြိုးစားပါ။" + "စာသားကို ဖျက်ရန်" + "စာသား နှင့် ဂဏန်းများ ရိုက်ထည့်မှု အကြားမှာ ပြောင်းလဲရန်" + "ပါဝင်သူများထပ်ထည့်မည်" + "ပါဝင်သူများ အတည်ပြုရန်" + "ပြောဆိုမှု အသစ် စတင်ရန်" + "ဒီအရာကို ရွေးရန်" + "ဗီဒီယို ဖွင့်ရန်" + "လူများ & ရွေးစရာများ" + "ဒီဘာဂ်" + "လူများ & ရွေးစရာများ" + "အထွေထွေ" + "ဒီစကားဝိုင်း ထဲက လူများ" + "ခေါ်ဆိုမှု ပြုလုပ်ရန်" + "စာတို ပို့ရန်" + "စာတိုကို<br/><> %s</small> ထံမှပို့ရန်" + + ဓာတ်ပုံများ ပို့ရန် + ဓာတ်ပုံ ပို့ရန် + + + အသံဖိုင်များ ပို့ရန် + အသံဖိုင် ပို့ရန် + + + ဗီဒီယိုများ ပို့ရန် + ဗီဒီယို ပို့ရန် + + + အဆက်အသွယ်ကဒ်များ ပို့ရန် + အဆက်အသွယ်ကဒ် ပို့ရန် + + + ပူးတွဲပါဖိုင်များ ပို့ရန် + ပူးတွဲပါဖိုင် ပို့ရန် + + + ပူးတွဲဖိုင် %d ဖိုင် ပို့ရန် အဆင်သင့်ဖြစ်၏ + ပူးတွဲဖိုင်တစ်ဖိုင် ပို့ရန် အဆင်သင့်ဖြစ်၏ + + "တုံ့ပြန်ချက် ပို့ရန်" + "Google ပလေး စတိုးတွင် ကြည့်ရန်" + "ဗားရှင်း အင်ဖို" + "ဗားရှင်း %1$s" + "အခမဲ့ ရင်းမြစ် လိုင်စင်များ" + "အကြောင်းကြားချက်များ" + "ချိတ်တွဲမှု ကန့်သတ်ချက်သို့ရောက်ရှိပါပြီ" + "ချိတ်တွဲမှု ဖွင့်ရန်မအောင်မြင်ပါ။" + "အဆက်အသွယ်များသို့ ထည့်ရမလား?" + "အဆက်အသွယ် ထည့်ရန်" + "ဘာသာရပ်" + "အကြောင်းအရာ: " + "%s%s" + "အဆက်အသွယ် ကဒ်ကို တင်ပေးနေ" + "နာမည်ကဒ် မရယူနိုင်ပါ" + "အဆက်အသွယ် ကဒ်ကို ကြည့်ရန်" + + %d အဆက်အသွယ် + %d အဆက်အသွယ် + + "နာမည် ကဒ်များ" + "မွေးနေ့" + "မှတ်စုများ" + "စာကို တစ်ဆင့်ပို့ရန်" + "စာပြန်ရန်" + "+၁" + "+%d" + "SMS ပိတ်ထား" + "ပို့ရန်အတွက် စာတိုပို့ခြင်းကို SMS ပုံသေ app အဖြစ် သတ်မှတ်ပေးပါ" + "စာတိုပို့ခြင်းကို SMS ပုံသေ app အဖြစ် သတ်မှတ်ရန်" + "ပြောင်းရန်" + "စာတိုများကို လက်ခံရန်အတွက်၊ စာတိုပို့ခြင်းကို SMS ပုံသေ app အဖြစ် သတ်မှတ်ပေးပါ" + "SMS စာများ ပို့ရန်အတွက် ဦးစားပေး ဆင်းမ်ကို မရွေးရသေးပါ" + "ဒီappကို ကိရိယာ ပိုင်ရှင်က ခွင့် မပြုပါ။" + "အိုကေ" + "စကားဝိုင်း တစ်ခုထဲမှာ ပါဝင်သူ များလွန်းနေ" + + အဆက်အသွယ်များ မမှန်ကန်ပါ + အဆက်အသွယ် မမှန်ကန်ပါ + + "ကင်မရာ ပုံ မရယူနိုင်ပါ" + "သင်: " + "%s: " + "မူကြမ်း" + "စကားပြောစတင်သည်နှင့်၊ ဤစာရင်းတွင် ပေါ်လာသည်ကို သင်မြင်ရလိမ့်မည်။" + "ဤတွင်မြင်ရသည့် ပြောဆိုမှုများအား သိမ်းဆည်းရန်" + "ပြောဆိုမှုများကို တင်နေ..." + "ပုံ" + "အသံ ကလီပ်" + "ဗီဒီယို" + "အဆက်အသွယ် ကဒ်" + "MMS" + "ပြန်ဖျက်ရန်" + "ထပ်စမ်းရန်" + "စာတို အသစ် စပို့ရန် အမည် သို့မဟုတ် ဖုန်းနံပါတ် ရိုက်ထည့်ပါ" + "ပိတ်ဆို့ရန်" + "အဆက်အသွယ်ဖြတ်ရန် %s" + "အဆက်အသွယ်ပြန်လုပ် %s" + "ပိတ်ဆို့ရမလား%s?" + "ဤနံပါတ်မှ စာတိုများ သင် ဆက်လက်ရရှိနေမည်ဖြစ်သော်လည်း အကြောင်းကြားချက်ပေးပို့တော့မည် မဟုတ်ပါ။ ဤပြောဆိုမှုများအား သိုလှောင်သိမ်းဆည်းထားမည်ဖြစ်၏။" + "အဆက်အသွယ် ဖြတ်ပြီး" + "အဆက်အသွယ်ပြန်လုပ်" + "အဆက်အသွယ် ဖြတ်ပြီး" + "စာကြည့်တိုက်မှ ပုံရွေးရန်" + "စာတိုပို့နေသည်" + "စာတိုပို့ပြီးပါပြီ" + "ဆဲလူလာ ဒေတာ ပိတ်ထား၏။ ဆက်တင်တွင် စစ်ဆေးပါ။" + "လေယာဉ်ပျံပေါ်သုံးစနစ်တွင် စာများ မပို့နိုင်ပါ" + "စာများ ပို့၍မရပါ။" + "စာတိုအားဒေါင်းလုတ်လုပ်ပြီး" + "ဆဲလူလာ ဒေတာ ပိတ်ထား၏။ ဆက်တင်တွင် စစ်ဆေးပါ။" + "လေယာဉ်ပျံမုဒ်တွင် စာတိုများကို ဒေါင်းလုပ်လုပ်၍မရနိုင်ပါ" + "စာတိုအား ဒေါင်းလုပ်လုပ်၍မရပါ" + "သုည" + "တစ်" + "နှစ်" + "သုံး" + "လေး" + "ငါး" + "ခြောက်" + "ခုနစ်" + "ရှစ်" + "ကိုး" + "%1$sဖြင့် စာတိုမပို့နိုင်ပါ၊ အမှား %2$d" + "အမည်မသိသယ်ဆောင်သူဖြင့် စာတိုမပို့နိုင်ပါ၊ အမှား %1$d" + "တစ်ဆင့်ပို့: %s" + "စာတိုကို မပို့ခဲ့: ဝန်ဆောင်မှုကို ကွန်ရက် ပေါ်မှာ စဖွင့်မသုံးရသေး" + "စာမပို့နိုင်ပါ − လက်ခံယူမည့် လိပ်စာ မှားနေ၏" + "စာမပို့နိုင်ပါ− စာ မမှန်ကန်ပါ။" + "စာမပို့နိုင်ပါ − ထောက်ပံ့မထားသော အကြောင်းအရာဖြစ်၏" + "စာမပို့နိုင်ပါ− ထောက်ပံ့မထားသော စာဖြစ်၏" + "စာပိုဒ်အလွန်ရှည်ပါက မက်ဆေ့ချ် ပို့မရပါ။" + "စာ အသစ်" + "ကြည့်ရှုရန်" + "ပုံ" + "သင့်တော်သည့် အပလီကေးရှင်းကို ရှာမရခဲ့" + "အဝေးမှ လက်ခံရရှိသူ" + "စာ အသစ်" + "ဖျက်သိမ်းရန်" + "ဝင်ရောက်မည့်ပွိုင့်အား ပြင်ဆင်ရန်" + "မသတ်မှတ်ထားပါ" + "အမည်" + "APN" + "MMSC" + "MMS ပရော့က်စီ" + "MMS ပို့တ်နံပါတ်" + "MCC" + "MNC" + "APN အမျိုးအစား" + "APN ကိုဖျက်မည်" + "APN အသစ်" + "သိမ်းဆည်းရန်" + "ဖျက်သိမ်းရန်" + "အမည်အကွက်မှာ ကွက်လပ်ဖြစ်မနေစေရပါ" + "APN မှာ ကွက်လပ်ဖြစ်မနေစေရပါ" + "MNC အကွက်မှာ ဂဏန်း ၃ လုံးဖြစ်ရမည်" + "MNC အကွက်မှာ ဂဏန်း ၂ လုံး သို့မဟုတ် ၃ လုံးဖြစ်ရမည်" + "မူရင်း APN ဆက်တင်များကိုပြန်လည်ရယူစေမည်" + "စနစ်အား မူလစက်ရုံအခြအေနေထံ ပြန်လည်သတ်မှတ်ရန်" + "APN ၏မူရင်းအပြင်အဆင်များကို အစသို့ပြန်လည်စတင်မှုအား ပြီးဆုံး၏။" + "ခေါင်းစဉ်မဲ့" + "ချိတ်ဆက်သောပွိုင့်အမည်များ" + "APNs" + "APN အသစ်" + "ဤအသုံးပြုသူ အတွက် ချိတ်ဆက်သောပွိုင့်အမည်၏ ဆက်တင်များကို မရယူနိုင်" + "ကလီပ်ဘုတ်သို့ ကူးရမလား?" + "ကူးယူရန်" + "%sသို့" + "အထွေထွေ" + "အဆင့်မြင့်သော" + "အထွေထွေ ဆက်တင်များ" + "အဆင့်မြင့် ဆက်တင်များ" + "\"%s\" SIM" + "ရရှိသူ အားလုံးတို့ထံ တစ်ဦးချင်း SMS စာများကို ပို့ပါ။ သင်သည်သာလျှင် ​ဖြေကြားချက် မှန်သမျှကို ရရှိမည်။" + "ရရှိသူ အားလုံးထံသို့ တစ်ခုတည်းသော MMS ကို ပို့ပါ" + "မသိရ နံပါတ်" + "စာ အသစ်" + "စာ အသစ်" + "SIM ရွေးချယ်ပေးရေးလက္ခဏာရပ်" + "%1$s ရွေးချယ်ပြီး၊ SIM ရွေးချယ်ပေးသူ" + "အကြောင်းအရာကို တည်းဖြတ်ပါ။" + "SIM ကို‌ရွေးချယ်ပါ သို့မဟုတ် အကြောင်းအရာတည်းဖြတ်ခြင်းကိုရွေးချယ်ပါ။" + "အသံအား ဖမ်းယူထားရန် ထိပြီးဖိထားပါ" + "ပြောဆိုမှု အသစ် စတင်ရန်" + "စာတိုပို့ခြင်း" + "စာတိုပို့ခြင်း စာရင်း" + "စာတိုပို့ခြင်း" + "စာ အသစ်" + "ပြောဆိုမှုစာရင်း" + "စကားဝိုင်းများကို ဖွင့်နေသည်" + "စာများကို တင်နေ" + "စကားပြောဆက်သွယ်မှုများကို ထပ်ပြီး ကြည့်မည်" + "နောက်ထပ် မက်ဆေ့များကြည့်ရန်" + "စကားဝိုင်းကို ဖျက်လိုက်ပြီ" + "ပြောဆိုမှုကို ဖျက်ခဲ့သည်။ အခြား စာတိုပို့ခြင်း ပြောဆိုမှုကို ပြရန် တို့ထိပါ။" + "ပိတ်ဆို့ထား" + "ပြန်ဖွင့်ထားခြင်း" + "သိုလှောင်ထားသည့်နေရာ နည်းနေပါသည်။ ဒေတာတချို့ပျောက်ဆုံးပါလိမ့်မည်။" + "ချိတ်တွဲမှုများ ရွေးချယ်မည်" + "ရွေးချယ်မှုကို အတည်ပြုရန်" + "%d ခရွေးချယ်ပြီး" + "ကျေးဇူးပြု၍ တစ်ခု သို့မဟုတ် တစ်ခုထက်ပိုသော ချိတ်တွဲမှုများကို ဖယ်ရှားပြီး နောက်တစ်ကြိမ်ကြိုးစားပါ။" + "သင့်စာတိုကို ပေးပို့ရန် သင်ကြိုးစားနိုင်ပါသည်၊ သို့သော်သင်သည် တစ်ခုသို့မဟုတ် တစ်ခုထက်ပိုသော ချိတ်တွဲမှုများကို ဖယ်ရှားခြင်းမပြုပါက ၎င်းကို ပေးပို့မှုမရှိခြင်း ဖြစ်နိုင်သည်။" + "သင်သည် မက်ဆေ့ဂျ်တစ်ခုလျှင် ဗွီဒီယိုတကားသာ ပေးပို့နိုင်သည်။ အပိုဆောင်း ဗွီဒီယိုများအား ဖယ်ရှားပြီး ထပ်မံကြိုးစားပါ။" + "စာတိုကိုပို့စဉ် ပူးတွဲချက်ကို တင် မရခဲ့ပါ။" + "မည်သို့ပင်ဖြစ်စေ ပို့မည်" + "စကားဝိုင်းအား မစတင်နိုင်ပါ" + "%1$s (%2$s)" + "%s ရွေးချယ်ပြီး၏" + diff --git a/res/values-nb/arrays.xml b/res/values-nb/arrays.xml new file mode 100644 index 0000000..c9efc6f --- /dev/null +++ b/res/values-nb/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "emne mangler" + "ikke noe emne" + + + "Ja" + "Nei" + "OK" + "Hehe" + "Takk" + "Jeg er enig" + "Bra" + "Jeg er på vei" + "OK, jeg tar kontakt med deg senere" + ":)" + ":(" + + diff --git a/res/values-nb/strings.xml b/res/values-nb/strings.xml new file mode 100644 index 0000000..7736c84 --- /dev/null +++ b/res/values-nb/strings.xml @@ -0,0 +1,519 @@ + + + + + "Meldinger" + "Meldinger" + "Markér samtalen" + "Innstillinger" + "Send meldingen" + "Legg ved et vedlegg" + "Hjelp" + "Velkommen" + "Hopp over" + "Neste >" + "Neste" + "Avslutt" + "Innstillinger >" + "Innstillinger" + "Meldinger trenger tillatelse til å bruke SMS, Telefon og Kontakter." + "Du kan endre tillatelser i Innstillinger > Apper > Meldinger > Tillatelser." + "Du kan endre tillatelser i Innstillinger > Apper > Meldinger > Tillatelser." + "Ofte brukte" + "Alle kontakter" + "Send til %s" + "Ta bilder eller spill inn video" + "Velg bilder fra denne enheten" + "Spill inn lyd" + "Velg bilde" + "Mediene er valgt." + "Valget av mediene er fjernet." + "%d valgt" + "bilde %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "bilde" + "Spill inn lyd" + "Del" + "Nå nettopp" + "Nå" + + %d minutter + %d minutt + + + %d timer + %d time + + + dager + %d dag + + + %d uker + én uke + + + %d måneder + én måned + + + %d år + ett år + + "Klasse 0-melding" + "Lagre" + "Enheten har lite lagringsplass. Meldinger-appen sletter eldre meldinger automatisk for å frigjøre lagringsplass." + "Lite ledig lagringsplass" + "Du kan kanskje ikke sende eller motta SMS i Meldinger før mer plass er tilgjengelig på enheten din." + "Du har lite lagringsplass for SMS. Du må slette andre meldinger." + "Bekreft telefonnummeret ditt" + "Dette engangstrinnet sikrer at Meldinger leverer gruppemeldingene dine på riktig måte." + "Telefonnummer" + "Slett alle meldinger med medier" + "Slett meldinger som er eldre enn %s" + "Slett meldinger som er eldre enn %s automatisk" + "Ignorer" + "Vil du slette alle meldinger med media?" + "Vil du slette meldinger som er eldre enn %s?" + "Vil du slette meldinger som er eldre enn %s og slå av automatisk sletting?" + "%s sa" + "Du sa" + "Melding fra %s:" + "Du sendte en melding" + "Sender ..." + "Ikke sendt. Trykk for å prøve på nytt." + "Ikke sendt. Prøver på nytt …" + "Send på nytt eller slett" + "Ring til alarmsentralen. Tekstmeldingen kunne ikke leveres for øyeblikket." + "Mislykket" + "Ny MMS-melding for nedlasting" + "Ny MMS-melding" + "Kan ikke laste ned" + "Trykk for å prøve på nytt" + "Trykk for å laste ned" + "Last ned eller slett" + "Laster ned …" + "Meldingen er utløpt eller ikke tilgjengelig" + "størrelse: %1$s, utløper: %2$s" + "Kan ikke sende. Mottaker er ikke gyldig." + "Tjenesten er ikke aktivert på nettverket" + "Kunne ikke sende på grunn av et nettverksproblem" + "Meldingen er utløpt eller ikke tilgjengelig" + "(Mangler emne)" + "Ukjent avsender" + "Levert" + "Kunne ikke laste ned meldingen %1$s fra %2$s." + "Databasehandlingen kunne ikke fullføres på grunn av lite minne." + "Meldingen er ikke sendt" + "Enkelte meldinger er ikke sendt i Meldinger" + + %d meldinger i %d samtaler + %d meldinger i én samtale + + "Meldingen er ikke lastet ned" + "Enkelte meldinger er ikke lastet ned i Meldinger" + + %d meldinger i %d samtaler + %d meldinger i én samtale + + "Melding til %1$s ble ikke sendt" + "Ring til alarmsentralen. Tekstmeldingen til %1$s kunne ikke leveres for øyeblikket." + + %d nye meldinger + Ny melding + + "Start" + "Kameraet er ikke tilgjengelig" + "Kameraet er ikke tilgjengelig" + "Videoinnspilling er ikke tilgjengelig" + "Kan ikke lagre media" + "Kan ikke ta bilde" + "Tilbake" + "Arkivert" + "Slett" + "Arkivér" + "Fjern fra arkivet" + "Slå av varsler" + "Slå på varsler" + "Legg til kontakt" + "Last ned" + "Send" + "Slett" + "Vil du slette denne meldingen?" + "Denne handlingen kan ikke angres." + "Slett" + + Vil du slette disse samtalene? + Vil du slette denne samtalen? + + "Slett" + "Avbryt" + "Til" + "Velg flere bilder" + "Bekreft utvalget" + "+%d" + "Kan ikke ta opp lyd. Prøv på nytt." + "Kan ikke spille av lyd. Prøv på nytt." + "Kunne ikke lagre lydfil. Prøv på nytt." + "Berør og hold nede" + ", " + " " + ": " + " " + "Bilde" + "Lydklipp" + "Video" + "Kontaktkort" + "Last ned" + "Svar via SMS" + "Svar via MMS" + "Svar" + + %d deltakere + %d deltaker + + "Meg" + "Kontakten er blokkert og arkivert" + "Kontakt ublokkerte & uarkiverte" + "%d er arkivert" + "%d er fjernet fra arkivet" + "Varsler er slått av" + "Varsler er slått av" + "Alt klart. Trykk på Send én gang til." + "Meldinger er nå angitt som standard SMS-app." + + Forkast vedlegg + Forkast vedlegget + + "Lydvedlegg" + "Spill av lydvedlegg" + "Sett på pause" + "Melding fra %s: %s." + "Mislykket melding fra %s: %s. Tidspunkt: %s." + "Melding fra %s: %s. Tidspunkt: %s." + "Usendt melding til %s: %s. Tidspunkt: %s." + "Sender melding til %s: %s. Tidspunkt: %s." + "Mislykket melding til %s: %s. Tidspunkt: %s." + "Melding til %s: %s. Tidspunkt: %s." + "Mislykket melding fra %s: %s. Tidspunkt: %s. %s." + "Melding fra %s: %s. Tidspunkt: %s. %s." + "Usendt melding til %s: %s. Tidspunkt: %s." + "Sender melding til %s: %s. Tidspunkt: %s." + "Mislykket melding til %s: %s. Tidspunkt: %s." + "Melding til %s: %s. Tidspunkt: %s." + "Meldingen ble ikke sendt. Trykk for å prøve på nytt." + "Samtale med %s" + "Slett emnet" + "Ta video" + "Ta et stillbilde" + "Ta bilde" + "Start et videoopptak" + "Bytt til fullskjermkamera" + "Bytt mellom for- og baksidekamera" + "Stopp opptak og legg ved video" + "Slutt å filme" + "Bilder i Meldinger" + + %d bilder er lagret i albumet «%s» + %d bilde er lagret i albumet «%s» + + + %d videoer er lagret i albumet «%s» + %d video er lagret i albumet «%s» + + + %d tillegg er lagret i albumet «%s» + %d tillegg er lagret i albumet «%s» + + + %d tillegg er lagret i «Nedlastinger» + %d tillegg er lagret i «Nedlastinger» + + + %d vedlegg er lagret + %d vedlegg er lagret + + + Kunne ikke lagre %d vedlegg + Kunne ikke lagre %d vedlegg + + "MMS-vedlegget er lagret" + "Innstillinger" + "Arkivert" + "Lukk" + "MMS" + "Avansert" + "Feilsøk" + "Varsler" + "Lyd" + "Stille" + "Vibrer" + "Blokkert" + "Leveringsrapporter for SMS" + "Be om leveringsrapport for hver SMS du sender" + "Automatisk henting" + "Hent MMS-meldinger automatisk" + "Automatisk henting under roaming" + "Hent MMS automatisk ved romaning" + "Gruppemeldinger" + "Bruk MMS for å sende én melding med flere mottakere" + "Standard SMS-app" + "Standard SMS-app" + + + "Telefonnummeret ditt" + "Ukjent" + "Lyder for utgående meldinger" + "Dump SMS" + "Dump rådata fra mottatte SMS-er i en ekstern lagringsfil" + "Dump MMS" + "Dump rådata fra mottatte MMS-er i en ekstern lagringsfil" + "Trådløse varsler" + "Meldingsalternativer" + "Kopiér teksten" + "Se detaljer" + "Slett" + "Videresend" + "Meldingsinformasjon" + "Type: " + "SMS" + "Bildemelding" + "Fra: " + "Til: " + "Sendt: " + "Mottatt: " + "Emne: " + "Størrelse: " + "Prioritet: " + "SIM-kort: " + "Høy" + "Vanlig" + "Lav" + "SIM-kort %s" + "Skjult avsenderadresse" + "Meldingen kan ikke sendes mens vedleggene lastes inn." + "Kan ikke laste inn vedlegg. Prøv på nytt." + "Nettverket er ikke klart. Prøv på nytt." + "Slett teksten" + "Veksle mellom å skrive inn tekst og tall" + "Legg til flere deltakere" + "Bekreft deltakere" + "Start ny samtale" + "Velg dette elementet" + "Spill av videoen" + "Personer-appen og alternativer" + "Feilsøk" + "Personer-appen og alternativer" + "Generelt" + "Folk i denne samtalen" + "Start en samtale" + "Send melding" + "Send melding<br/><small>fra %s</small>" + + Send bilder + Send bilde + + + Send lydklipp + Send lydklipp + + + Send videoer + Send video + + + Send kontaktkort + Send kontaktkort + + + Send vedlegg + Send vedlegg + + + %d vedlegg er klare til å sendes + Ett vedlegg er klart til å sendes + + "Send tilbakemelding" + "Se i Google Play-butikken" + "Versjonsinformasjon" + "Versjon %1$s" + "Lisenser for åpen kildekode" + "Varsler" + "Vedleggsgrensen er nådd" + "Kunne ikke laste inn vedlegg." + "Vil du legge til nummeret i kontakter?" + "Legg til kontakten" + "Emne" + "Emne: " + "%s%s" + "Laster inn kontaktkortet" + "Kunne ikke laste kontaktkort" + "Se kontaktkortet" + + %d kontakter + %d kontakt + + "Kontaktkort" + "Fødselsdag" + "Notater" + "Videresend meldingen" + "Svar" + "1" + "+%d" + "SMS er deaktivert" + "For å sende meldinger, angi Meldinger som standard SMS-app" + "Angi Meldinger som standard SMS-app" + "Endre" + "For å motta meldinger, angi Meldinger som standard SMS-app" + "Ingen foretrukne SIM-kort er valgt for å sende SMS-meldinger" + "Denne appen er ikke tillatt av enhetseieren." + "OK" + "For mange deltakere i en samtale" + + Ugyldige kontakter + Ugyldig kontakt + + "Kunne ikke laste inn kamerabilde" + "Du: " + "%s: " + "Utkast" + "Når du starter en ny samtale, vises den her" + "Arkiverte samtaler vises her" + "Laster inn samtaler ..." + "Bilde" + "Lydklipp" + "Instream-video" + "Kontaktkort" + "MMS" + "Angre" + "Prøv på nytt" + "Skriv inn et kontaktnavn eller -telefonnummer for å starte en ny melding" + "Blokkér" + "Blokkér %s" + "Opphev blokkeringen av %s" + "Vil du blokkere %s?" + "Du fortsetter å motta meldinger fra dette nummeret, men blir ikke lenger varslet. Denne samtalen blir arkivert." + "Blokkerte kontakter" + "OPPHEV BLOKKERING" + "Blokkerte kontakter" + "Velg et bilde fra dokumentbiblioteket" + "Sender meldingen" + "Meldingen er sendt" + "Mobildata er slått av. Sjekk innstillingene dine." + "Kan ikke sende meldinger i flymodus" + "Kan ikke sende meldingen" + "Meldingen er nedlastet" + "Mobildata er slått av. Sjekk innstillingene dine." + "Kan ikke laste ned meldinger i flymodus" + "Meldingen kunne ikke lastes ned" + "Null" + "Én" + "To" + "Tre" + "Fire" + "Fem" + "Seks" + "Sju" + "Åtte" + "Ni" + "Kan ikke sende meldinger med %1$s – feil %2$d" + "Kan ikke sende meldinger med ukjent operatør – feil %1$d" + "V.send: %s" + "Meldingen er ikke sendt: Tjenesten er ikke aktivert på nettverket" + "Meldingen ble ikke sendt: ugyldig måladresse" + "Meldingen ble ikke sendt: ugyldig melding" + "Meldingen ble ikke sendt: innholdet støttes ikke" + "Meldingen ble ikke sendt: meldingen støttes ikke" + "Meldingen ble ikke sendt: Den var for stor." + "Ny melding" + "Se" + "Bilde" + "Kunne ikke finne en passende app" + "Fjern mottakeren" + "Ny melding" + "Avbryt" + "Rediger tilgangspunkt" + "Ikke angitt" + "Navn" + "APN-navn" + "MMSC" + "Proxy-tjener for MMS" + "MMS-port" + "MCC" + "MNC" + "APN-type" + "Slett APN" + "Ny APN" + "Lagre" + "Forkast" + "Navnefeltet kan ikke være tomt." + "APN kan ikke være tomt." + "MCC-feltet må bestå av tre siffer." + "MNC-feltet må bestå av to eller tre siffer." + "Gjenoppretter standard APN-innstillinger." + "Tilbakestill til standard" + "Tilbakestilling av standard APN-innstillinger er fullført." + "Uten navn" + "Navn på tilgangspunkt" + "APN-er" + "Ny APN" + "Innstillingene for navn på tilgangspunkt er ikke tilgjengelig for denne brukeren" + "Vil du kopiere til utklippstavlen?" + "Kopiér" + "til %s" + "Generelt" + "Avansert" + "Generelle innstillinger" + "Avanserte innstillinger" + %s»-SIM-kort" + "Send SMS-meldinger til alle mottakere. Bare du mottar svarene." + "Send én enkelt MMS til alle mottakere" + "Ukjent nummer" + "Ny melding" + "Ny melding." + "SIM-kortvelger" + "%1$s er valgt – SIM-kortvelger" + "Rediger emne" + "Velg SIM-kort eller rediger emnet" + "Trykk og hold inne for å ta opp lyd" + "Start ny samtale" + "Meldinger" + "Samtaleliste" + "Meldinger" + "Ny melding" + "Samtaleliste" + "Laster inn samtaler" + "Laster inn e-postmeldinger" + "Se flere samtaler" + "Se flere e-poster" + "Samtalen er slettet" + "Samtalen er slettet. Trykk for å se en annen samtale i Meldinger" + "Blokkert" + "Blokkeringen er opphevet" + "Lite lagringsplass ledig. Noen data kan gå tapt." + "Velg vedlegg" + "Bekreft utvalget" + "%d er valgt" + "Fjern ett eller flere vedlegg og prøv på nytt." + "Du kan prøve å sende meldingen din, men det er ikke sikkert at den kommer frem med mindre du fjerner ett eller flere vedlegg." + "Du kan bare sende én video per melding. Fjern overflødige videoer og prøv igjen." + "Meldinger kunne ikke laste inn vedlegget." + "Send likevel" + "Kunne ikke starte samtalen" + "%1$s (%2$s)" + "%s er valgt" + diff --git a/res/values-ne-rNP/arrays.xml b/res/values-ne-rNP/arrays.xml new file mode 100644 index 0000000..614436d --- /dev/null +++ b/res/values-ne-rNP/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "शीर्षक नभएको" + "विषय छैन" + + + "हो" + "हैन" + "ठीक छ" + "हाहा" + "धन्यवाद" + "म सहमत छु" + "राम्रो" + "मेरो बाटोमा" + "ठीक छ, तपाईंलाई पछिको लागि मलाई अनुमति दिनुहोस्" + ":)" + ":(" + + diff --git a/res/values-ne-rNP/strings.xml b/res/values-ne-rNP/strings.xml new file mode 100644 index 0000000..c9e30e6 --- /dev/null +++ b/res/values-ne-rNP/strings.xml @@ -0,0 +1,521 @@ + + + + + "सन्देश पठाइदै" + "सन्देश पठाइदै" + "कुराकानी चयन गर्नुहोस्" + "सेटिङ्हरू" + "सन्देश पठाउनुहोस्" + "एक अनुलग्नक थप गर्नुहोस्" + "मद्दत" + "स्वागतम" + "छोड्नुहोस्" + "अर्को >" + "अर्को" + "बाहिर" + "सेटिङहरू >" + "सेटिङहरू" + "सन्देश प्रवाहलाई SMS, फोन र सम्पर्क ठेगानाहरूमा अनुमतिको आवश्यक पर्छ।" + "तपाईँ अनुमतिहरूलाई सेटिङ अनुप्रयोगहरू > सन्देश प्रवाह > अनुमतिहरूमा परिवर्तन गर्न सक्नु हुनेछ।" + "तपाईँ अनुमतिहरूलाई सेटिङ अनुप्रयोगहरू, सन्देश प्रवाह, अनुमतिहरूमा परिवर्तन गर्न सक्नु हुनेछ।" + "नियमित" + "सबै सम्पर्कहरू" + "%s मा पठाउनुहोस्" + "तस्बिर वा भिडियो लिनुहोस्" + "यस उपकरणबाट तस्बिरहरू छान्नुहोस्" + "ध्वनि रेकर्ड गर्नुहोस्" + "फोटो छान्नुहोस्" + "यस मिडिया चयन गरिएको छ।" + "यस मिडियाको चयन हटाइएको छ।" + "%d चयन गरियो" + "छवि %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "छवि" + "ध्वनि रेकर्ड गर्नुहोस्" + "साझेदारी गर्नुहोस्" + "अहिले भर्खर" + "अब" + + %d मिनेट + %d मिनेट + + + %d घन्टा + %d घन्टा + + + %d दिन + %d दिन + + + %d हप्ता + एक हप्ता + + + %d महिना + एक महिना + + + %d वर्ष + एक वर्ष + + "वर्ग ० सन्देश" + "सुरक्षित गर्नुहोस्" + "यन्त्रमा ठाउँ कम छ। ठाउँ खाली गर्न सन्देश प्रवाहले पुराना सन्देशहरूलाई स्वचालित रूपमा मेट्ने छ।" + "भण्डारण ठाउँ सकिँदै छ" + "तपाईँको यन्त्रमा थप ठाउँ उपलब्ध नभएसम्म सन्देश प्रवाहले सन्देश पठाउन वा प्राप्त नगर्न सक्छ।" + "SMS भण्डारणमा कमी। तपाईँले सन्देशहरु हटाउनु पर्नेहुन्छ।" + "आफ्नो फोन नम्बर पुष्टि गर्नुहोस्" + "यो एक पटके चरणले सन्देश प्रवाहले तपाईँको समूह सन्देशहरू उचित रूपमा डेलिभर गर्ने छ भन्ने सुनिश्चित गर्ने छ।" + "फोन नम्बर" + "मीडियाको साथमा सबै सन्देशहरु मेटाउनुहोस्" + "%s भन्दा पुराना सन्देशहरुलाई मेट्नुहोस्" + "%s भन्दा पुरानो सन्देशहरू स्वत: मेट्नुहोस्" + "बेवास्ता गर्नुहोस्" + "मिडिया भएको सन्देशहरू मेट्ने?" + "%s भन्दा पुरानो सन्देशहरू मेट्ने?" + "%s भन्दा पुरानो सन्देशहरू मेटाउनुहुन्छ र स्वचालित-मेट्ने सक्षम गर्नुहुन्छ?" + "%s ले भन्नुभयो" + "तपाईंले भन्नुभयो" + "%s बाट सन्देश" + "तपाईंले एक सन्देश पठाउनुभयो" + "पठाउँदै..." + "पठाइएको छैन। पुन: प्रयास गर्न छुनुहोस्।" + "पठाइएको छैन। पुन: प्रयास गर्दै ..." + "पुन: पठाउनुहोस् वा मेट्नुहोस्" + "कृपया आकस्मिक सेवाहरूमा आवाज कल गर्नुहोस्। तपाईंको पाठ सन्देश यो समयमा डेलिभर हुन सक्दैन।" + "विफल भयो" + "डाउनलोड गर्न नयाँ MMS सन्देश" + "नयाँ MMS सन्देश" + "डाउनलोड गर्न सकिएन" + "पुनः प्रयास गर्न छुनुहोस्" + "डाउनलोड गर्न छुनुहोस्" + "डाउनलोड वा मेट्नुहोस्" + "डाउनलोड हुँदै..." + "सन्देशको अवधि सकियो वा उपलब्ध छैन" + "आकार: %1$s, समाप्ति: %2$s" + "पठाउन सकिएन। प्रापक मान्य छैन।" + "नेटवर्कमा सेवा सक्रिय छैन" + "नेटवर्कको समस्याको कारण पठाउन सकिएन" + "सन्देशको अवधि सकियो वा उपलब्ध छैन" + "(कुनै विषय छैन)" + "अज्ञात प्रेषक" + "पुर्याइयो" + "%2$s बाट सन्देश %1$s डाउनलोड गर्न सकिएन।" + "कम मेमोरीका कारण डेटाबेस कार्य पुरा गर्न सकिएन।" + "सन्देश पठाइएन" + "केही सन्देशहरू सन्देश प्रवाहमा पठाइएनन्" + + %d वार्तालापमा %d सन्देशहरू + एउटा वार्तालापमा %d सन्देशहरू + + "सन्देश डाउनलोड भएन" + "सन्देशहरू सन्देश प्रवाहमा डाउनलोड भएनन्" + + %d वार्तालापमा %d सन्देशहरू + एउटा वार्तालापमा %d सन्देशहरू + + "%1$s मा सन्देश पठाइएन" + "कृपया आकस्मिक सेवाहरूमा आवाज कल गर्नुहोस्। %1$s मा तपाईंको पाठ सन्देश यो समयमा डेलिभर हुन सक्दैन।" + + %d नयाँ सन्देशहरू + नयाँ सन्देश + + "सुरु गर्नुहोस्" + "क्यामेरा उपलब्ध छैन" + "क्यामेरा उपलब्ध छैन" + "भिडियो क्याप्चर उपलब्ध छैन" + "मिडिया बचत गर्न सकिँदैन" + "तस्वीर लिन सक्दैन" + "पछाडि" + "अभिलेख बनाइयो" + "मेटाउनुहोस्" + "अभिलेख" + "अभिलेखबाट निकाल्नुहोस्" + "सूचनाहरू बन्द गर्नुहोस्" + "सूचनाहरू सुचारु गर्नुहोस्" + "सम्पर्क थप्नुहोस्" + "डाउनलोड गर्नुहोस्" + "पठाउनुहोस्" + "मेटाउनुहोस्" + "यस सन्देश मेटाउने हो?" + "यो कार्य पूर्ववत् पार्न सकिँदैन।" + "मेटाउनुहोस्" + + यी वार्तालापहरू मेटाउने हो? + यो वार्तालाप मेटाउने हो? + + "मेटाउनुहोस्" + "रद्द गर्नुहोस्" + "प्रापक" + "बहु तस्बिर चयन गर्नुहोस्" + "चयन यकिन गर्नुहोस्" + "+%d" + "अडियो रेकर्ड गर्न सकिँदैन। पुन: प्रयास गर्नुहोस्।" + "अडियो बजाउन सकिएन। पुन: प्रयास गर्नुहोस्।" + "अडियो सुरक्षित गर्न सकेन। फेरी प्रयास गर्नुहोस्।" + "छुनुहोस् र होल्ड गर्नुहोस्" + ", " + " " + ": " + " " + "चित्र" + "ध्वनि क्लिप" + "भिडियो" + "सम्पर्क कार्ड" + "डाउनलोड गर्नुहोस्" + "SMS मार्फत जवाफ दिनुहोस्" + "MMS मार्फत जवाफ दिनुहोस्" + "जवाफ पठाउनुहोस्" + + %d सहभागीहरू + %d सहभागी + + "म" + "सम्पर्क अवरुद्ध; अभिलेख राखियो" + "सम्पर्क खुल्ला गरियो &amp अभिलेख हटाइयो" + "%dअभिलेख गरियो" + "%d अभिलेख हटाउनुहोस्" + "सूचनाहरू निष्क्रिय पारिए" + "सूचनाहरू सक्रिय पारिए" + "सबै ठीक छ। पुन: पठाउन छुनुहोस्।" + "सन्देश प्रवाह सफलतापूर्वक पूर्वनिर्धारित SMS अनुप्रयोगको रूपमा सेट भयो।" + + संलग्नकहरू खारेज गर्नुहोस् + संलग्नक खारेज गर्नुहोस् + + "अडियो संलग्नक" + "अडियो संलग्नक प्ले गर्नुहोस्" + "पज गर्नुहोस्" + "%s बाट प्राप्त सन्देश: %s।" + "%s बाट विफल सन्देश: %s. समय: %s" + "%s बाट सन्देश: %s. समय: %s" + "%s मा नपठाइएको सन्देश: %s. समय: %s" + "%s मा सन्देश पठाउँदै: %s. समय: %s" + "%s मा विफल सन्देश: %s. समय: %s" + "%s मा सन्देश: %s. समय: %s" + "%s बाट विफल सन्देश: %s. समय: %s. %s" + "%s बाट सन्देश: %s. समय: %s. %s" + "%s मा नपठाइएको सन्देश: %s. समय: %s" + "%s मा सन्देश पठाउँदै: %s. समय: %s" + "%s मा विफल सन्देश: %s. समय: %s" + "%s मा सन्देश: %s. समय: %s" + "असफल सन्देश। पुन: प्रयास गर्न छुनुहोस्।" + "%s सँग कुराकानी" + "विषय मेट्नुहोस्" + "भिडियो खिच्नुहोस्" + "स्थिर छाँया लिदै" + "फोटो लिनुहोस्" + "भिडियो रेकर्ड सुरु गर्नुहोस्" + "पूरा पर्दा क्यामेरा खोल्नुहोस्" + "अगाडि र पछाडि क्यामेरा बीच बदल्नुहोस्" + "भिडियो रेकर्ड गर्न र संलग्न रोक्नुहोस्" + "भिडियो रेकर्ड रोक्नुहोस्" + "सन्देश प्रवाह तस्बिरहरू" + + %d तस्बिरहरू \"%s \" एल्बममा सुरक्षित गरियो + %d तस्बिर \"%s \" एल्बममा सुरक्षित गरियो + + + %d भिडियोहरू \"%s\" एल्बममा सुरक्षित गरियो + %d भिडियो \"%s\" एल्बममा सुरक्षित गरियो + + + %d संलग्नकहरू \" %s \" एल्बममा सुरक्षित गरियो + %d संलग्नक \" %s \" एल्बममा सुरक्षित गरियो + + + %d संलग्नकहरू \"डाउनलोडहरू\" मा सुरक्षित गरियो + %d संलग्नक \"डाउनलोडहरू\" मा सुरक्षित गरियो + + + %d संलग्नकहरू सुरक्षित गरियो + %d संलग्नक सुरक्षित गरियो + + + %d संलग्नकहरू सुरक्षित गर्न सकेन + %d संलग्नक सुरक्षित गर्न सकेन + + "संलग्न MMS सुरक्षित गरियो" + "सेटिङहरू" + "अभिलेख बनाइयो" + "बन्द गर्नुहोस्" + "MMS" + "उन्नत" + "डिबग" + "सूचनाहरू" + "आवाज" + "मौन" + "कम्पन गर्नुहोस्" + "अवरूद्ध गरियो" + "SMS वितरण प्रतिवेदनहरू" + "तपाईँले पठाउनु हुने हरेक SMS को लागि पुगेको खबर अनुरोध गर्नुहोस्" + "स्वत:प्राप्ति" + "MMS सन्देशहरु स्वत: रूपमा पुनः प्राप्त गर्नुहोस्" + "रोमिङ स्वतः पुनः प्राप्ति" + "रोमिङमा हुँदा MMS स्वचालित रूपमा प्राप्त गर्नुहोस्" + "समूहिक सन्देश पठाइँदै" + "जब त्यहाँ बहुप्राप्तकर्ताहरू हुन्छन् तब सन्देश पठाउनको लागि MMS प्रयोग गर्नुहोस्।" + "पूर्वनिर्धारित SMS अनुप्रयोग" + "पूर्वनिर्धारित SMS अनुप्रयोग" + + + "तपाईँको फोन नम्बर" + "अज्ञात" + "बाहिर जाने सन्देशका लागि ध्वनिहरू" + "SMS फाल्नुहोस्" + "प्राप्त भएको MMS अप्रशोधित डेटा बाह्य भण्डारण फाइलमा फाल्नुहोस्" + "MMS फाल्दिनुहोस्" + "प्राप्त भएको MMS अप्रशोधित डेटा बाह्य भण्डारण फाइलमा फाल्नुहोस्" + "ताररहित सतर्कताहरू" + "सन्देश विकल्पहरू" + "पाठ प्रतिलिपि गर्नुहोस्" + "विवरणहरू हेर्नुहोस्" + "मेटाउनुहोस्" + "फर्वार्ड गर्नुहोस्" + "सन्देश विवरणहरू" + "प्रकार: " + "पाठ सन्देश" + "मल्टिमिडिया सन्देश" + "प्रेषक: " + "प्रापक: " + "पठाइयो: " + "प्राप्त: " + "विषय: " + "आकार: " + "प्राथमिकता: " + "SIM: " + "उच्च" + "सामान्य" + "कम" + "SIM %s" + "प्रेषकको ठेगाना लुकेको" + "संलग्नहरू लोड गर्दा सन्देश पठाउन सक्दैन।" + "संलग्नक लोड गर्न सकिएन। पुन: प्रयास गर्नुहोस्।" + "नेटवर्क तयार छैन। पुन: प्रयास गर्नुहोस्।" + "पाठ मेट्नुहोस्" + "पाठ र संख्या बीच प्रविष्टि गर्न स्विच गर्नुहोस्" + "थप सहभागी थप्नुहोस्" + "सहभागीहरूको पुष्टि गर्नुहोस्" + "नयाँ कुराकानी सुरू गर्नुहोस्" + "यो वस्तु चयन गर्नुहोस्" + "भिडियो चलाउनुहोस्" + "मानिस र विकल्पहरू" + "डिबग" + "मानिसहरू र विकल्पहरु" + "सामान्य" + "यस कुराकानीमा मानिसहरू" + "कल गर्नुहोस्" + "सन्देश पठाउनुहोस्" + "सन्देश पठाउनुहोस्<br/><small>%s</small&gt बाट" + + तस्बिरहरू पठाउनुहोस् + तस्बिर पठाउनुहोस् + + + अडियोहरू पठाउनुहोस् + अडियो पठाउनुहोस् + + + भिडियोहरू पठाउनुहोस् + भिडियो पठाउनुहोस् + + + सम्पर्क कार्डहरू पठाउनुहोस् + सम्पर्क कार्ड पठाउनुहोस् + + + संलग्नक पठाउनुहोस् + संलग्नक पठाउनुहोस् + + + %d संलग्नकहरू पठाउनको लागि तयार + एउटा संलग्नक पठाउनको लागि तयार + + "प्रतिक्रिया पठाउनुहोस्" + "Google Play Store मा हेर्नुहोस्" + "संस्करण जानकारी" + + + + "खुला स्रोत इजाजतपत्रहरू" + "अधिसूचनाहरू" + "संलग्नकको सीमा पुग्यो" + "संलग्नक लोड गर्न असफल।" + "सम्पर्कहरूमा थप्नुहुन्छ?" + "सम्पर्क थप्नुहोस्" + "विषय" + "विषय: " + "%s%s" + "सम्पर्क कार्ड लोड गर्दै" + "सम्पर्क कार्ड लोड गर्न सकिएन" + "सम्पर्क कार्ड हेर्नुहोस्" + + %d सम्पर्कहरू + %d सम्पर्क + + "सम्पर्क कार्डहरू" + "जन्मदिन" + "टिप्पणीहरू" + "सन्देश फर्वार्ड गर्नुहोस्" + "जवाफ पठाउनुहोस्" + "‌+१" + "+%d" + "SMS असक्षम गरियो" + "पठाउनका लागि सन्देश प्रवाहलाई पूर्वनिर्धारित SMS अनुप्रयोगको रूपमा सेट गर्नुहोस्" + "सन्देश प्रवाहलाई पूर्वनिर्धारित SMS अनुप्रयोगको रूपमा सेट गर्नुहोस्" + "परिवर्तन गर्नुहोस्" + "सन्देशहरू प्राप्त गर्न, सन्देश प्रवाहलाई पूर्वनिर्धारित SMS अनुप्रयोगको रूपमा सेट गर्नुहोस्" + "SMS सन्देशहरू पठाउनको लागि मान्यता प्राप्त SIM चयन गरिएको छैन।" + "यो अनुप्रयोग उपकरण मालिकद्वारा अनुमति छैन।" + "ठीक छ" + "कुराकानीमा धेरै सहभागीहरू" + + अमान्य सम्पर्कहरू + अमान्य सम्पर्क + + "क्यामेराको तस्बिर लोड गर्न सकिएन" + "तपाईँ: " + "%s: " + "मस्यौदा" + "तपाईँ नयाँ कुराकानी सुरु भएपछि, तपाईँ यसलाई यहाँ सूचीबद्ध देख्नुहुने छ" + "अभिलेख कुराकानी यहाँ देखा पर्नेछ" + "कुराकानीहरू लोड हुँदै..." + "तस्बिर" + "अडियो क्लिप" + "भिडियो" + "सम्पर्क कार्ड" + "MMS" + "अनडु गर्नुहोस्" + "पुनःप्रयास गर्नुहोस्" + "सम्पर्क नाम वा फोन नम्बर प्रविष्ट गरी नयाँ सन्देश सुरु गर्नुहोस्" + "रोक्नुहोस्" + "अवरुद्ध %s" + "अनब्लक %s" + "अवरुद्ध गर्नुस् %s?" + "तपाईँ यो नम्बरबाट सन्देश प्राप्त गरिरहनु हुनेछ तर तपाईँलाई सूचीत गरिने छैन। यो कुराकानीलाई संग्रह गरिने छ।" + "अवरुद्ध सम्पर्कहरू" + "UNBLOCK" + "अवरुद्ध सम्पर्कहरू" + "कागजात लाइब्रेरीबाट तस्बिर छान्नुहोस्" + "सन्देश पठाउँदै" + "सन्देश पठाइयो" + "सेलुलर डेटा बन्द छ। आफ्नो सेटिङ जाँच गर्नुहोस्।" + "हवाइजहाज मोडमा सन्देश पठाउन सक्नुहुन्‍न्" + "सन्देश पठाउन सकिएन" + "सन्देश डाउनलोड गरियो" + "सेलुलर डाटा बन्द गरिएको छ। आफ्नो सेटिङहरू जाँच्नुहोस्।" + "हवाइजहाज मोडमा सन्देशहरू डाउनलोड गर्न सक्दैन" + "सन्देश डाउनलोड गर्न सकिएन" + "शून्य" + "एक" + "दुई" + "तीन" + "चार" + "पाँच" + "छ" + "सात" + "आठ" + "नौ" + "%1$s मार्फत सन्देश पठाउन सक्दैन, त्रुटि %2$d" + "अज्ञात वाहकमार्फत सन्देश पठाउन सक्दैन, त्रुटि %1$d" + "फर्वाड गर्नुहोस्: %s" + "सन्देश पठाइएको छैन: सञ्जालमा सेवा सक्रिय छैन" + "सन्देश पठाइएको छैन: अवैध गन्तव्य ठेगाना" + "सन्देश पठाइएको छैन: अमान्य सन्देश" + "सन्देश पठाइएको छैन: असमर्थित सामाग्री" + "सन्देश पठाइएको छैन: असमर्थित सन्देश" + "सन्देश पठाइएन: निकै ठूलो छ" + "नयाँ सन्देश" + "हेर्नुहोस्" + "तस्बिर" + "उपयुक्त अनुप्रयोग फेला पार्न सकिएन" + "प्राप्तकर्ता हटाउनुहोस्" + "नयाँ सन्देश" + "रद्द गर्नुहोस्" + "पहुँच बिन्दु सम्पादन गर्नुहोस्" + "सेट गरेको छैन" + "नाम" + "APN" + "MMSC" + "MMS प्रोक्सी" + "MMS पोर्ट" + "MCC" + "MNC" + "APN प्रकार" + "APN मेटाउनुहोस्" + "नयाँ APN" + "सुरक्षित गर्नुहोस्" + "त्याग्नुहोस्" + "नाम क्षेत्र खाली हुन सक्दैन।" + "APN खाली हुन सक्दैन।" + "MCC क्षेत्र कम्तिमा ३ अङ्कको हुनु पर्छ।" + "MNC क्षेत्रमा २ वा ३ अंकहरू हुनु पर्दछ।" + "पूर्वनिर्धारित APN सेटिङ्हरू पुनःप्राप्त गर्दै।" + "पूर्वनिर्धारितमा पुनःसेट गर्नुहोस्" + "पूर्वनिर्धारित APN सेटिङ्हरू पुनः सेट गर्ने काम पूरा भयो।" + "शीर्षक विहिन" + "पहुँच बिन्दु नामहरू" + "APNs" + "नयाँ APN" + "पहुँच बिन्दु नामको सेटिङ्हरू यो प्रयोगकर्ताको लागि उपलब्ध छैन" + "क्लिपबोर्डमा प्रतिलिपि गर्ने?" + "प्रतिलिपि गर्नुहोस्" + "%s लाई" + "सामान्य" + "उन्नत" + "साधारण सेटिङ्हरू" + "उन्नत सेटिङ्हरू" + "\"%s\" SIM" + "सबै प्रापकलाई व्यक्तिगत SMS सन्देशहरू पठाउनुहोस्। केवल तपाईँले कुनै पनि उत्तर प्राप्त गर्नुहुने छ" + "सबै प्रापकलाई एउटा एकल MMS पठाउनुहोस्" + "अज्ञात संख्या" + "नयाँ सन्देश" + "नयाँ सन्देश।" + "सिम चयनकर्ता" + "%1$s चयन गरियो, SIM चयनकर्ता" + "विषय सम्पादन गर्नुहोस्" + "सिम चयन गर्नुहोस् वा विषय सम्पादन गर्नुहोस्" + "अडियो रेकर्ड गर्न छुनुहोस् र होल्ड गर्नुहोस्" + "नयाँ कुराकानी सुरू गर्नुहोस्" + "सन्देश प्रवाह" + "सन्देश प्रवाह सूची" + "सन्देश प्रवाह" + "नयाँ सन्देश" + "कुराकानी सूची" + "कुराकानीहरू लोड हुँदै..." + "सन्देशहरू लोड हुँदै" + "थप कुराकानीहरू हेर्नुहोस्" + "थप सन्देशहरू हेर्नुहोस्" + "कुराकानी मेटिएको छ" + "वार्तालाप मेटियो। एउटा फरक सन्देश प्रवाह वार्तालाप देखाउन छुनुहोस्" + "अवरूद्ध गरियो" + "अवरोध हटाइयो" + "भण्डारण ठाउँ कम छ। केही डाटा हराउन सक्छ।" + "संलग्नकहरू चयन गर्नुहोस्" + "चयन पुष्टि गर्नुहोस्" + "%d चयन गरियो" + "कृपया एक वा बढी संलग्नकहरू हटाउनुहोस् र फेरि प्रयास गर्नुहोस्।" + "तपाईं आफ्नो सन्देश पठाउन प्रयास गर्न सक्नुहुन्छ, तर तपाईंले एक वा बढी संलग्नकहरू नहटाएसम्म यो डेलिभर नहुन सक्छ।" + "तपाईँले प्रति सन्देश एउटा मात्र भिडियो पठाउन सक्नुहुन्छ। अतिरिक्त भिडियो हटाउनुहोस् र फेरि प्रयास गर्नुहोस्।" + "संलग्नक लोड गर्न सन्देश प्रवाह असफल भयो।" + "जसरी भए पनि पठाउनुहोस्" + "कुराकानी सुरु गर्न सकेन" + "%1$s (%2$s)" + "%s चयन गरियो" + diff --git a/res/values-nl/arrays.xml b/res/values-nl/arrays.xml new file mode 100644 index 0000000..adfdcbe --- /dev/null +++ b/res/values-nl/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "geen onderwerp" + "geenonderwerp" + + + "Ja" + "Nee" + "OK" + "Haha" + "Hartelijk dank" + "Ik ga akkoord" + "Mooi" + "Ik ben onderweg" + "Hier kom ik later op terug" + ":)" + ":(" + + diff --git a/res/values-nl/strings.xml b/res/values-nl/strings.xml new file mode 100644 index 0000000..4f1a53c --- /dev/null +++ b/res/values-nl/strings.xml @@ -0,0 +1,519 @@ + + + + + "Berichten" + "Berichten" + "Gesprek selecteren" + "Instellingen" + "Bericht verzenden" + "Een bijlage toevoegen" + "Help" + "Welkom" + "Overslaan" + "Volgende >" + "Volgende" + "Sluiten" + "Instellingen >" + "Instellingen" + "Berichten heeft toegang tot Sms, Telefoon en Contacten nodig." + "U kunt rechten wijzigen via \'Instellingen\' > \'Apps\' > \'Berichten\' > \'Rechten\'." + "U kunt rechten wijzigen via \'Instellingen\' > \'Apps\' > \'Berichten\' > \'Rechten\'." + "Veelgebruikt" + "Alle contacten" + "Verzenden naar %s" + "Foto\'s of video vastleggen" + "Afbeeldingen kiezen op dit apparaat" + "Audio opnemen" + "Foto kiezen" + "Het medium is geselecteerd." + "Het medium is gedeselecteerd." + "%d geselecteerd" + "afbeelding %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "afbeelding" + "Audio opnemen" + "Delen" + "Zojuist" + "Nu" + + %d min. + %d min. + + + %d uur + %d uur + + + %d dagen + %d dag + + + %d weken + één week + + + %d maanden + één maand + + + %d jaar + één jaar + + "Berichtklasse 0" + "Opslaan" + "Er is weinig ruimte vrij op het apparaat. Oudere berichten worden automatisch verwijderd om ruimte vrij te maken." + "Opslagruimte is bijna vol" + "Berichten verzendt of ontvangt mogelijk geen berichten totdat er meer ruimte beschikbaar is op uw apparaat." + "Weinig opslagruimte voor sms-berichten beschikbaar. Je moet mogelijk berichten verwijderen." + "Uw telefoonnummer bevestigen" + "Deze eenmalige stap zorgt ervoor dat Berichten uw groepsberichten correct aflevert." + "Telefoonnummer" + "Alle berichten met media verwijderen" + "Berichten ouder dan %s verwijderen" + "Berichten ouder dan %s automatisch verwijderen" + "Negeren" + "Alle berichten met media verwijderen?" + "Berichten ouder dan %s verwijderen?" + "Berichten ouder dan %s verwijderen en automatisch verwijderen inschakelen?" + "%s zei" + "U zei" + "Bericht van %s:" + "U heeft een bericht verzonden" + "Verzenden…" + "Niet verzonden. Tik om het opnieuw te proberen." + "Niet verzonden. Nieuwe poging…" + "Opnieuw verzenden of verwijderen" + "Bel de hulpdiensten. Uw sms kan momenteel niet worden afgeleverd." + "Mislukt" + "Nieuw mms-bericht downloaden" + "Nieuw mms-bericht" + "Kan niet downloaden" + "Tik voor nieuwe poging" + "Tik om te downloaden" + "Downloaden of verwijderen" + "Downloaden..." + "Bericht verlopen of niet beschikbaar" + "grootte: %1$s, verloopt: %2$s" + "Kan niet verzenden. Ontvanger niet geldig." + "Service niet geactiveerd in netwerk" + "Kan niet verzenden wegens een netwerkprobleem" + "Bericht verlopen of niet beschikbaar" + "(Geen onderwerp)" + "Onbekende afzender" + "Bezorgd" + "Kan bericht %1$s niet downloaden van %2$s." + "Kan databasebewerking niet voltooien wegens onvoldoende geheugen" + "Bericht niet verzonden" + "Sommige berichten niet verzonden in Berichten" + + %d berichten in %d gesprekken + %d berichten in één gesprek + + "Bericht niet gedownload" + "Sommige berichten zijn niet gedownload in Berichten" + + %d berichten in %d gesprekken + %d berichten in één gesprek + + "Bericht naar %1$s niet verzonden" + "Bel de hulpdiensten. Uw sms aan %1$s kan momenteel niet worden afgeleverd." + + %d nieuwe berichten + Nieuw bericht + + "Starten" + "Camera niet beschikbaar" + "Camera niet beschikbaar" + "Video-opname niet beschikbaar" + "Kan media niet opslaan" + "Kan geen foto maken" + "Terug" + "Gearchiveerd" + "Verwijderen" + "Archiveren" + "Terugzetten uit archief" + "Meldingen uitschakelen" + "Meldingen inschakelen" + "Contact toevoegen" + "Downloaden" + "Verzenden" + "Verwijderen" + "Dit bericht verwijderen?" + "Deze actie kan niet ongedaan worden gemaakt." + "Verwijderen" + + Deze gesprekken verwijderen? + Dit gesprek verwijderen? + + "Verwijderen" + "Annuleren" + "Aan" + "Meerdere afbeeldingen selecteren" + "Selectie bevestigen" + "+%d" + "Kan audio niet opnemen. Probeer het opnieuw." + "Kan audio niet afspelen. Probeer het opnieuw." + "Kan audio niet opslaan. Probeer het opnieuw." + "Blijven aanraken" + ", " + " " + ": " + " " + "Afbeelding" + "Audioclip" + "Video" + "Visitekaartje" + "Downloaden" + "Reageren via sms" + "Reageren via mms" + "Beantwoorden" + + %d deelnemers + %d deelnemer + + "Ik" + "Contact geblokkeerd en gearchiveerd" + "Blokkering van contact opgeheven en teruggezet uit archief" + "%d gearchiveerd" + "%d teruggezet uit archief" + "Meldingen uitgeschakeld" + "Meldingen ingeschakeld" + "Alles is ingesteld. Tik nogmaals op \'Verzenden\'." + "Berichten is ingesteld als de standaard sms-app" + + Bijlagen verwijderen + Bijlage verwijderen + + "Audiobijlage" + "Audiobijlage afspelen" + "Onderbreken" + "Bericht van %s: %s." + "Mislukt bericht van %s: %s. Tijd: %s." + "Bericht van %s: %s. Tijd: %s." + "Niet-verzonden bericht aan %s: %s. Tijd: %s." + "Bericht verzenden naar %s: %s. Tijd: %s." + "Mislukt bericht aan %s: %s. Tijd: %s." + "Bericht aan %s: %s. Tijd: %s." + "Mislukt bericht van %s: %s. Tijd: %s. %s." + "Bericht van %s: %s. Tijd: %s. %s." + "Niet-verzonden bericht aan %s: %s. Tijd: %s." + "Bericht verzenden naar %s: %s. Tijd: %s." + "Mislukt bericht aan %s: %s. Tijd: %s." + "Bericht aan %s: %s. Tijd: %s." + "Bericht mislukt. Tik om het opnieuw te proberen." + "Gesprek met %s" + "Onderwerp verwijderen" + "Video opnemen" + "Een stilstaand beeld opnemen" + "Foto maken" + "Video opnemen" + "Overschakelen naar camera op volledig scherm" + "Schakelen tussen camera aan voorzijde en camera aan achterzijde" + "Opname stoppen en video bijvoegen" + "Video-opname stoppen" + "Foto\'s in Berichten" + + %d foto\'s opgeslagen in het album \"%s\" + %d foto opgeslagen in het album \"%s\" + + + %d video\'s opgeslagen in het album \"%s\" + %d video opgeslagen in het album \"%s\" + + + %d bijlagen opgeslagen in het album \"%s\" + %d bijlage opgeslagen in het album \"%s\" + + + %d bijlagen opgeslagen in \"Downloads\" + %d bijlage opgeslagen in \"Downloads\" + + + %d bijlagen opgeslagen + %d bijlage opgeslagen + + + Kan %d bijlagen niet opslaan + Kan %d bijlage niet opslaan + + "Opgeslagen mms-bijlage" + "Instellingen" + "Gearchiveerd" + "Sluiten" + "Mms" + "Geavanceerd" + "Fouten opsporen" + "Meldingen" + "Geluid" + "Stil" + "Trillen" + "Geblokkeerd" + "Bezorgingsrapporten voor sms" + "Ontvangstbevestiging vragen voor elke verzonden sms" + "Automatisch ophalen" + "Mms-berichten automatisch ophalen" + "Automatisch ophalen tijdens roaming" + "Automatisch MMS ophalen tijdens roaming" + "Groepsberichten" + "Mms gebruiken om één bericht te verzenden wanneer er meerdere ontvangers zijn" + "Standaard sms-app" + "Standaard sms-app" + + + "Uw telefoonnummer" + "Onbekend" + "Geluiden voor uitgaand bericht" + "Sms dumpen" + "Onbewerkte gegevens voor ontvangen sms\'jes in een extern opslagbestand dumpen" + "Mms dumpen" + "Onbewerkte gegevens voor ontvangen mms\'jes in een extern opslagbestand dumpen" + "Waarschuwingen voor draadloos" + "Berichtopties" + "Tekst kopiëren" + "Details weergeven" + "Verwijderen" + "Doorsturen" + "Berichtdetails" + "Type: " + "Sms-bericht" + "Mms-bericht" + "Van: " + "Aan: " + "Verzonden: " + "Ontvangen: " + "Onderwerp: " + "Grootte: " + "Prioriteit: " + "Simkaart: " + "Hoog" + "Normaal" + "Laag" + "Simkaart %s" + "Verborgen adres verzender" + "Kan bericht niet verzenden terwijl bijlagen worden geladen." + "Kan bijlage niet laden. Probeer het opnieuw." + "Netwerk is niet gereed. Probeer het opnieuw." + "Tekst verwijderen" + "Schakelen tussen letters en cijfers invoeren" + "Meer deelnemers toevoegen" + "Deelnemers bevestigen" + "Nieuw gesprek starten" + "Dit item selecteren" + "Video afspelen" + "Mensen en opties" + "Fouten opsporen" + "Mensen en opties" + "Algemeen" + "Mensen in dit gesprek" + "Bellen" + "Bericht verzenden" + "Bericht verzenden<br/><small>via %s</small>" + + Foto\'s verzenden + Foto verzenden + + + Audio-opnamen verzenden + Audio-opname verzenden + + + Video\'s verzenden + Video verzenden + + + Contactkaarten verzenden + Contactkaart verzenden + + + Bijlagen verzenden + Bijlage verzenden + + + %d bijlagen klaar voor verzenden + Eén bijlage klaar voor verzenden + + "Feedback verzenden" + "Bekijken in de Google Play Store" + "Versie-informatie" + "Versie %1$s" + "Open-sourcelicenties" + "Meldingen" + "Bijlagelimiet bereikt" + "Kan bijlage niet laden." + "Toevoegen aan contacten?" + "Contact toevoegen" + "Onderwerp" + "Onderwerp: " + "%s%s" + "Contactkaart laden" + "Kan visitekaartje niet laden" + "Contactkaart weergeven" + + %d contacten + %d contact + + "Visitekaartjes" + "Verjaardag" + "Notities" + "Bericht doorsturen" + "Beantwoorden" + "+1" + "+%d" + "Sms uitgeschakeld" + "Stel Berichten in als standaard sms-app om berichten te verzenden" + "Berichten instellen als standaard sms-app" + "Wijzigen" + "Stel Berichten in als standaard sms-app om berichten te ontvangen" + "Geen voorkeurs-simkaart geselecteerd voor het verzenden van sms\'jes" + "Deze app is niet toegestaan door de eigenaar van het apparaat." + "OK" + "Te veel deelnemers in een gesprek" + + Ongeldige contacten + Ongeldig contact + + "Kan camera-afbeelding niet laden" + "U: " + "%s: " + "Concept" + "Zodra u een nieuw gesprek start, wordt dit hier vermeld" + "Gearchiveerde gesprekken worden hier weergegeven" + "Gesprekken laden..." + "Afbeelding" + "Audioclip" + "Video" + "Visitekaartje" + "Mms" + "Ongedaan maken" + "Opnieuw proberen" + "Geef de naam of het telefoonnummer van een contact op om een nieuw bericht te starten" + "Blokkeren" + "%s blokkeren" + "Blokkering van %s opheffen" + "%s blokkeren?" + "U blijft berichten van dit nummer ontvangen, maar krijgt geen meldingen meer. Dit gesprek wordt gearchiveerd." + "Geblokkeerde contacten" + "BLOKKERING OPHEFFEN" + "Geblokkeerde contacten" + "Afbeelding kiezen uit documentbibliotheek" + "Bericht wordt verzonden" + "Bericht verzonden" + "Mobiele gegevens zijn ingeschakeld. Controleer uw instellingen." + "Kan geen berichten verzenden in vliegtuigmodus" + "Kan bericht niet verzenden" + "Bericht gedownload" + "Mobiele gegevens zijn uitgeschakeld. Controleer uw instellingen." + "Kan geen berichten downloaden in de vliegtuigmodus" + "Bericht kan niet worden gedownload" + "Nul" + "Eén" + "Twee" + "Drie" + "Vier" + "Vijf" + "Zes" + "Zeven" + "Acht" + "Negen" + "Kan bericht niet verzenden via %1$s, fout %2$d" + "Kan bericht niet verzenden via onbekende provider, fout %1$d" + "Fwd: %s" + "Bericht niet verzonden: service niet geactiveerd in netwerk" + "Bericht niet verzonden: ongeldig bestemmingsadres" + "Bericht niet verzonden: ongeldig bericht" + "Bericht niet verzonden: niet-ondersteunde inhoud" + "Bericht niet verzonden: niet-ondersteund bericht" + "Bericht niet verzonden: te groot" + "Nieuw bericht" + "Weergeven" + "Afbeelding" + "Kan geen geschikte applicatie vinden" + "Ontvanger verwijderen" + "Nieuw bericht" + "Annuleren" + "Toegangspunt bewerken" + "Niet ingesteld" + "Naam" + "APN" + "MMSC" + "MMS-proxy" + "MMS-poort" + "MCC" + "MNC" + "APN-type" + "APN verwijderen" + "Nieuwe APN" + "Opslaan" + "Verwijderen" + "Het veld \'Naam\' mag niet leeg zijn." + "De APN mag niet leeg zijn." + "MCC-veld moet drie cijfers bevatten." + "MNC-veld moet twee of drie cijfers bevatten." + "Standaard-APN-instellingen herstellen." + "Standaardinstellingen herstellen" + "Herstellen van standaard-APN-instellingen voltooid." + "Naamloos" + "Namen toegangspunten" + "APN\'s" + "Nieuwe APN" + "Instellingen voor toegangspuntnamen zijn niet beschikbaar voor deze gebruiker" + "Kopiëren naar klembord?" + "Kopiëren" + "naar %s" + "Algemeen" + "Geavanceerd" + "Algemene instellingen" + "Geavanceerde instellingen" + "Sim van \'%s\'" + "Afzonderlijke sms\'jes verzenden naar alle ontvangers. U bent de enige die eventuele reacties ontvangt" + "Eén mms verzenden naar alle ontvangers" + "Onbekend nummer" + "Nieuw bericht" + "Nieuw bericht." + "Simkaart selecteren" + "%1$s geselecteerd, simkaartkiezer" + "Onderwerp bewerken" + "Simkaart selecteren of onderwerp bewerken" + "Blijf aanraken om audio op te nemen" + "Nieuw gesprek starten" + "Berichten" + "Lijst in Berichten" + "Berichten" + "Nieuw bericht" + "Gesprekslijst" + "Gesprekken laden" + "Berichten laden" + "Meer gesprekken bekijken" + "Meer berichten bekijken" + "Gesprek verwijderd" + "Gesprek verwijderd. Tik om een ander Berichten-gesprek weer te geven" + "Geblokkeerd" + "Blokkering opgeheven" + "Er is weinig opslagruimte beschikbaar. Er kunnen gegevens verloren gaan." + "Bijlagen selecteren" + "Selectie bevestigen" + "%d geselecteerd" + "Verwijder een of meer bijlagen en probeer het opnieuw." + "U kunt proberen uw bericht te verzenden, maar het bericht wordt mogelijk niet bezorgd, tenzij u een of meer bijlagen verwijdert." + "U kunt slechts één video verzenden per bericht. Verwijder extra video\'s en probeer het opnieuw." + "Berichten kan de bijlage niet laden." + "Toch verzenden" + "Kan gesprek niet starten" + "%1$s (%2$s)" + "%s geselecteerd" + diff --git a/res/values-pa-rIN/arrays.xml b/res/values-pa-rIN/arrays.xml new file mode 100644 index 0000000..1cf23e9 --- /dev/null +++ b/res/values-pa-rIN/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "ਕੋਈ ਵਿਸ਼ਾ ਨਹੀਂ" + "ਕੋਈ ਵਿਸ਼ਾ ਨਹੀਂ" + + + "ਹਾਂ" + "ਨਹੀਂ" + "ਠੀਕ" + "Hehe" + "ਧੰਨਵਾਦ" + "ਮੈਂ ਸਹਿਮਤ ਹਾਂ" + "ਵਧੀਆ" + "ਰਸਤੇ ਵਿੱਚ ਹਾਂ" + "ਠੀਕ, ਮੈਂ ਤੁਹਾਨੂੰ ਬਾਅਦ ਵਿੱਚ ਸੰਪਰਕ ਕਰਾਂਗਾ" + ":)" + ":(" + + diff --git a/res/values-pa-rIN/strings.xml b/res/values-pa-rIN/strings.xml new file mode 100644 index 0000000..3be69e1 --- /dev/null +++ b/res/values-pa-rIN/strings.xml @@ -0,0 +1,519 @@ + + + + + "Messaging" + "Messaging" + "ਗੱਲਬਾਤ ਚੁਣੋ" + "ਸੈਟਿੰਗਾਂ" + "ਸੁਨੇਹਾ ਭੇਜੋ" + "ਇੱਕ ਅਟੈਚਮੈਂਟ ਜੋੜੋ" + "ਸਹਾਇਤਾ" + "ਸੁਆਗਤ ਹੈ" + "ਛੱਡੋ" + "ਅਗਲਾ >" + "ਅਗਲਾ" + "ਬਾਹਰ ਜਾਓ" + "ਸੈਟਿੰਗਾਂ >" + "ਸੈਟਿੰਗਾਂ" + "Messaging ਨੂੰ SMS, ਫੋਨ ਅਤੇ ਸੰਪਰਕਾਂ ਲਈ ਅਨੁਮਤੀ ਦੀ ਲੋੜ ਹੈ।" + "ਤੁਸੀਂ ਸੈਟਿੰਗਾਂ > ਐਪਸ > Messaging > ਅਨੁਮਤੀਆਂ ਵਿੱਚ ਅਨੁਮਤੀਆਂ ਬਦਲ ਸਕਦੇ ਹੋ।" + "ਤੁਸੀਂ ਸੈਟਿੰਗਾਂ, ਐਪਸ, Messaging, ਅਨੁਮਤੀਆਂ ਵਿੱਚ ਅਨੁਮਤੀਆਂ ਬਦਲ ਸਕਦੇ ਹੋ।" + "ਫ੍ਰੀਕਵੈਂਟਸ" + "ਸਾਰੇ ਸੰਪਰਕ" + "%s ਨੂੰ ਭੇਜੋ" + "ਤਸਵੀਰਾਂ ਜਾ ਵੀਡੀਓ ਕੈਪਚਰ ਕਰੋ" + "ਇਸ ਡਿਵਾਈਸ ਤੋਂ ਚਿੱਤਰ ਚੁਣੋ" + "ਔਡੀਓ ਰਿਕਾਰਡ ਕਰੋ" + "ਫੋਟੋ ਚੁਣੋ" + "ਮੀਡੀਆ ਚੁਣਿਆ ਗਿਆ ਹੈ।" + "ਮੀਡੀਆ ਅਚੋਣਵਾਂ ਕੀਤਾ ਗਿਆ ਹੈ।" + "%d ਚੁਣਿਆ ਗਿਆ" + "ਚਿੱਤਰ %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "ਚਿੱਤਰ" + "ਔਡੀਓ ਰਿਕਾਰਡ ਕਰੋ" + "ਸ਼ੇਅਰ ਕਰੋ" + "ਬਿਲਕੁਲ ਹੁਣੇ" + "ਹੁਣ" + + %d ਮਿੰਟ + %d ਮਿੰਟ + + + %d ਘੰਟੇ + %d ਘੰਟੇ + + + %d ਦਿਨ + %d ਦਿਨ + + + %d ਹਫ਼ਤੇ + %d ਹਫ਼ਤੇ + + + %d ਮਹੀਨੇ + %d ਮਹੀਨੇ + + + %d ਸਾਲ + %d ਸਾਲ + + "ਕਲਾਸ 0 ਸੁਨੇਹਾ" + "ਸੁਰੱਖਿਅਤ ਕਰੋ" + "ਡਿਵਾਈਸ ਵਿੱਚ ਘੱਟ ਸਪੇਸ ਹੈ। Messaging ਆਟੋਮੈਟਿਕਲੀ ਸਪੇਸ ਖਾਲੀ ਕਰਨ ਲਈ ਪੁਰਾਣੇ ਸੁਨੇਹੇ ਮਿਟਾ ਦੇਵੇਗਾ।" + "ਸਟੋਰੇਜ ਸਪੇਸ ਖ਼ਤਮ ਹੋ ਰਿਹਾ ਹੈ" + "Messaging ਸੁਨੇਹੇ ਭੇਜ ਜਾਂ ਪ੍ਰਾਪਤ ਨਹੀਂ ਕਰ ਸਕਦਾ ਜਦੋਂ ਤੱਕ ਕਿ ਤੁਹਾਡੀ ਡਿਵਾਈਸ ਤੇ ਹੋਰ ਸਪੇਸ ਉਪਲਬਧ ਨਾ ਹੋਵੇ।" + "ਘੱਟ SMS ਸਟੋਰੇਜ। ਤੁਹਾਨੂੰ ਸੁਨੇਹੇ ਮਿਟਾਉਣ ਦੀ ਲੋੜ ਪੈ ਸਕਦੀ ਹੈ।" + "ਆਪਣੇ ਫੋਨ ਨੰਬਰ ਦੀ ਪੁਸ਼ਟੀ ਕਰੋ" + "ਇਹ ਵਨ-ਟਾਈਮ ਸਟੈਪ ਯਕੀਨੀ ਬਣਾਏਗਾ ਕਿ Messaging ਤੁਹਾਡੇ ਸਮੂਹ ਦੇ ਸੁਨੇਹੇ ਸਹੀ ਢੰਗ ਨਾਲ ਡਿਲੀਵਰ ਕਰੇਗਾ।" + "ਫੋਨ ਨੰਬਰ" + "ਮੀਡੀਆ ਵਾਲੇ ਸਾਰੇ ਸੁਨੇਹੇ ਮਿਟਾਓ" + "%s ਤੋਂ ਪੁਰਾਣੇ ਸੁਨੇਹੇ ਮਿਟਾਓ" + "%s ਤੋਂ ਪੁਰਾਣੇ ਸੁਨੇਹੇ ਆਟੋ-ਮਿਟਾਓ" + "ਅਣਡਿੱਠ ਕਰੋ" + "ਕੀ ਮੀਡੀਆ ਵਾਲੇ ਸਾਰੇ ਸੁਨੇਹੇ ਮਿਟਾਉਣੇ ਹਨ?" + "ਕੀ %s ਤੋਂ ਪੁਰਾਣੇ ਸੁਨੇਹੇ ਮਿਟਾਉਣੇ ਹਨ?" + "ਕੀ %s ਤੋਂ ਪੁਰਾਣੇ ਸੁਨੇਹੇ ਮਿਟਾਉਣੇ ਹਨ ਅਤੇ ਆਟੋ-ਮਿਟਾਓ ਨੂੰ ਚਾਲੂ ਕਰਨਾ ਹੈ?" + "%s ਨੇ ਕਿਹਾ" + "ਤੁਸੀਂ ਕਿਹਾ" + "%s ਦਾ ਸੁਨੇਹਾ" + "ਤੁਸੀਂ ਇੱਕ ਸੁਨੇਹਾ ਭੇਜਿਆ" + "ਭੇਜ ਰਿਹਾ ਹੈ..." + "ਨਹੀਂ ਭੇਜਿਆ। ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰਨ ਲਈ ਛੋਹਵੋ।" + "ਨਹੀਂ ਭੇਜਿਆ। ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰ ਰਿਹਾ ਹੈ..." + "ਦੁਬਾਰਾ ਭੇਜੋ ਜਾਂ ਮਿਟਾਓ" + "ਕਿਰਪਾ ਕਰਕੇ ਐਮਰਜੈਂਸੀ ਸੇਵਾਵਾਂ ਨੂੰ ਇੱਕ ਵੌਇਸ ਕਾਲ ਕਰੋ। ਤੁਹਾਡਾ ਟੈਕਸਟ ਸੁਨੇਹਾ ਇਸ ਵੇਲੇ ਡਿਲੀਵਰ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕੇਗਾ।" + "ਅਸਫਲ" + "ਡਾਊਨਲੋਡ ਕਰਨ ਲਈ ਨਵਾਂ MMS ਸੁਨੇਹਾ" + "ਨਵਾਂ MMS ਸੁਨੇਹਾ" + "ਡਾਊਨਲੋਡ ਨਹੀਂ ਕਰ ਸਕਿਆ" + "ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰਨ ਲਈ ਛੋਹਵੋ" + "ਡਾਊਨਲੋਡ ਕਰਨ ਲਈ ਛੋਹਵੋ" + "ਡਾਊਨਲੋਡ ਕਰੋ ਜਾਂ ਮਿਟਾਓ" + "ਡਾਊਨਲੋਡ ਕਰ ਰਿਹਾ ਹੈ..." + "ਸੁਨੇਹੇ ਦੀ ਮਿਆਦ ਪੁੱਗੀ ਜਾਂ ਉਪਲਬਧ ਨਹੀਂ" + "ਆਕਾਰ: %1$s, ਮਿਆਦ ਪੁੱਗਣ ਦਾ ਸਮਾਂ: %2$s" + "ਭੇਜ ਨਹੀਂ ਸਕਦਾ। ਪ੍ਰਾਪਤਕਰਤਾ ਪ੍ਰਮਾਣਿਕ ਨਹੀਂ ਹੈ।" + "ਨੈਟਵਰਕ ਤੇ ਸੇਵਾ ਸਕਿਰਿਆ ਨਹੀਂ ਕੀਤੀ" + "ਨੈਟਵਰਕ ਸਮੱਸਿਆ ਦੇ ਕਾਰਨ ਨਹੀਂ ਭੇਜ ਸਕਿਆ" + "ਸੁਨੇਹੇ ਦੀ ਮਿਆਦ ਪੁੱਗੀ ਜਾਂ ਉਪਲਬਧ ਨਹੀਂ" + "(ਕੋਈ ਵਿਸ਼ਾ ਨਹੀਂ)" + "ਅਗਿਆਤ ਭੇਜਣ ਵਾਲਾ" + "ਡਿਲੀਵਰ ਕੀਤਾ" + "%2$s ਦਾ %1$s ਸੁਨੇਹਾ ਡਾਊਨਲੋਡ ਨਹੀਂ ਕਰ ਸਕਿਆ।" + "ਘੱਟ ਮੈਮਰੀ ਦੇ ਕਾਰਨ ਡਾਟਾਬੇਸ ਓਪਰੇਸ਼ਨ ਪੂਰਾ ਨਹੀਂ ਕਰ ਸਕਿਆ" + "ਸੁਨੇਹਾ ਨਹੀਂ ਭੇਜਿਆ ਗਿਆ" + "ਕੁਝ ਸੁਨੇਹੇ Messaging ਵਿੱਚ ਨਹੀਂ ਭੇਜੇ ਗਏ ਹਨ" + + %d ਗੱਲਬਾਤਾਂ ਵਿੱਚ %d ਸੁਨੇਹੇ + %d ਗੱਲਬਾਤਾਂ ਵਿੱਚ %d ਸੁਨੇਹੇ + + "ਸੁਨੇਹਾ ਡਾਉਨਲੋਡ ਨਹੀਂ ਕੀਤਾ ਗਿਆ" + "ਕੁਝ ਸੁਨੇਹੇ Messaging ਵਿੱਚ ਡਾਊਨਲੋਡ ਨਹੀਂ ਕੀਤੇ ਗਏ ਹਨ" + + %d ਗੱਲਬਾਤਾਂ ਵਿੱਚ %d ਸੁਨੇਹੇ + %d ਗੱਲਬਾਤਾਂ ਵਿੱਚ %d ਸੁਨੇਹੇ + + "%1$s ਨੂੰ ਸੁਨੇਹਾ ਨਹੀਂ ਭੇਜਿਆ ਗਿਆ" + "ਕਿਰਪਾ ਕਰਕੇ ਐਮਰਜੈਂਸੀ ਸੇਵਾਵਾਂ ਨੂੰ ਇੱਕ ਵੌਇਸ ਕਾਲ ਕਰੋ। ਤੁਹਾਡਾ ਟੈਕਸਟ ਸੁਨੇਹਾ ਇਸ ਵੇਲੇ %1$s ਨੂੰ ਡਿਲੀਵਰ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕੇਗਾ।" + + %d ਨਵੇਂ ਸੁੁਨੇਹੇ + %d ਨਵੇਂ ਸੁੁਨੇਹੇ + + "ਚਾਲੂ ਕਰੋ" + "ਕੈਮਰਾ ਉਪਲਬਧ ਨਹੀਂ" + "ਕੈਮਰਾ ਉਪਲਬਧ ਨਹੀਂ" + "ਵੀਡੀਓ ਕੈਪਚਰ ਉਪਲਬਧ ਨਹੀਂ" + "ਮੀਡੀਆ ਨੂੰ ਸੁਰੱਖਿਅਤ ਨਹੀਂ ਕਰ ਸਕਦਾ" + "ਤਸਵੀਰ ਨਹੀਂ ਲੈ ਸਕਦਾ" + "ਪਿੱਛੇ" + "ਆਰਕਾਈਵ ਕੀਤਾ" + "ਮਿਟਾਓ" + "ਆਰਕਾਈਵ ਕਰੋ" + "ਅਨਆਰਕਾਈਵ ਕਰੋ" + "ਸੂਚਨਾਵਾਂ ਬੰਦ ਕਰੋ" + "ਸੂਚਨਾਵਾਂ ਚਾਲੂ ਕਰੋ" + "ਸੰਪਰਕ ਜੋੜੋ" + "ਡਾਊਨਲੋਡ ਕਰੋ" + "ਭੇਜੋ" + "ਮਿਟਾਓ" + "ਕੀ ਇਹ ਸੁਨੇਹਾ ਮਿਟਾਉਣਾ ਹੈ?" + "ਇਹ ਕਿਰਿਆ ਨਸ਼ਟ ਨਹੀਂ ਕੀਤੀ ਜਾ ਸਕਦੀ।" + "ਮਿਟਾਓ" + + ਕੀ ਇਹ ਗੱਲਬਾਤਾਂ ਮਿਟਾਉਣੀਆਂ ਹਨ? + ਕੀ ਇਹ ਗੱਲਬਾਤਾਂ ਮਿਟਾਉਣੀਆਂ ਹਨ? + + "ਮਿਟਾਓ" + "ਰੱਦ ਕਰੋ" + "ਵੱਲ" + "ਮਲਟੀਪਲ ਚਿੱਤਰ ਚੁਣੋ" + "ਚੋਣ ਦੀ ਪੁਸ਼ਟੀ ਕਰੋ" + "+%d" + "ਔਡੀਓ ਰਿਕਾਰਡ ਨਹੀਂ ਕਰ ਸਕਦਾ। ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।" + "ਔਡੀਓ ਪਲੇ ਨਹੀਂ ਕਰ ਸਕਦਾ। ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।" + "ਔਡੀਓ ਸੁਰੱਖਿਅਤ ਨਹੀਂ ਕਰ ਸਕਿਆ। ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।" + "ਛੋਹਵੋ & ਹੋਲਡ ਕਰੋ" + ", " + " " + ": " + " " + "ਤਸਵੀਰ" + "ਔਡੀਓ ਕਲਿਪ" + "ਵੀਡੀਓ" + "ਸੰਪਰਕ ਕਾਰਡ" + "ਡਾਊਨਲੋਡ ਕਰੋ" + "SMS ਰਾਹੀਂ ਜਵਾਬ" + "MMS ਰਾਹੀਂ ਜਵਾਬ" + "ਜਵਾਬ" + + %d ਭਾਗੀਦਾਰ + %d ਭਾਗੀਦਾਰ + + "ਮੈਂ" + "ਬਲੌਕ ਕੀਤਾ & ਆਰਕਾਈਵ ਕੀਤਾ ਸੰਪਰਕ" + "ਸੰਪਰਕ ਅਨਬਲੌਕ ਕੀਤਾ & ਅਨਆਰਕਾਈਵ ਕੀਤਾ" + "%d ਆਰਕਾਈਵ ਕੀਤੀਆਂ" + "%d ਅਨਆਰਕਾਈਵ ਕੀਤੀਆਂ" + "ਸੂਚਨਾਵਾਂ ਬੰਦ ਕੀਤੀਆਂ" + "ਸੂਚਨਾਵਾਂ ਚਾਲੂ ਕੀਤੀਆਂ" + "ਸਾਰੇ ਸੈਟ ਕੀਤੇ। ਭੇਜੋ ਨੂੰ ਦੁਬਾਰਾ ਛੋਹਵੋ।" + "Messaging ਡਿਫੌਲਟ SMS ਐਪਸ ਦੇ ਤੌਰ ਤੇ ਸਫਲਤਾਪੂਰਵਕ ਸੈਟ ਕੀਤਾ ਗਿਆ।" + + ਅਟੈਚਮੈਂਟਾਂ ਬਰਖਾਸਤ ਕਰੋ + ਅਟੈਚਮੈਂਟਾਂ ਬਰਖਾਸਤ ਕਰੋ + + "ਔਡੀਓ ਅਟੈਚਮੈਂਟ" + "ਔਡੀਓ ਅਟੈਚਮੈਂਟ ਪਲੇ ਕਰੋ" + "ਰੋਕੋ" + "%s ਦਾ ਸੁਨੇਹਾ: %s।" + "%s ਦਾ ਸੁਨੇਹਾ ਅਸਫਲ: %s। ਸਮਾਂ: %s।" + "%s ਦਾ ਸੁਨੇਹਾ: %s। ਸਮਾਂ: %s।" + "%s ਨੂੰ ਨਾ ਭੇਜਿਆ ਗਿਆ ਸੁਨੇਹਾ: %s। Time: %s।" + "%s ਨੂੰ ਸੁਨੇਹਾ ਭੇਜ ਰਿਹਾ ਹੈ: %s। ਸਮਾਂ: %s।" + "%s ਨੂੰ ਸੁਨੇਹਾ ਭੇਜਣਾ ਅਸਫਲ: %s। ਸਮਾਂ: %s।" + "%s ਨੂੰ ਸੁਨੇਹਾ: %s। ਸਮਾਂ: %s।" + "%s ਦਾ ਸੁਨੇਹਾ ਅਸਫਲ: %s। ਸਮਾਂ: %s%s।" + "%s ਦਾ ਸੁਨੇਹਾ: %s. ਸਮਾਂ: %s%s।" + "%s ਨੂੰ ਨਾ ਭੇਜਿਆ ਗਿਆ ਸੁਨੇਹਾ: %s। ਸਮਾਂ: %s।" + "%s ਨੂੰ ਸੁਨੇਹਾ ਭੇਜ ਰਿਹਾ ਹੈ: %s। ਸਮਾਂ: %s।" + "%s ਨੂੰ ਸੁਨੇਹਾ ਭੇਜਣਾ ਅਸਫਲ: %s। ਸਮਾਂ: %s।" + "%s ਨੂੰ ਸੁਨੇਹਾ: %s। ਸਮਾਂ: %s।" + "ਸੁਨੇਹਾ ਅਸਫਲ। ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰਨ ਲਈ ਛੋਹਵੋ।" + "%s ਨਾਲ ਗੱਲਬਾਤ ਕਰੋ" + "ਵਿਸ਼ਾ ਹਟਾਓ" + "ਵੀਡੀਓ ਕੈਪਚਰ ਕਰੋ" + "ਇੱਕ ਸਟਿਲ ਚਿੱਤਰ ਕੈਪਚਰ ਕਰੋ" + "ਤਸਵੀਰ ਲਓ" + "ਵੀਡੀਓ ਰਿਕਾਰਡ ਕਰਨਾ ਚਾਲੂ ਕਰੋ" + "ਪੂਰੀ ਸਕ੍ਰੀਨ ਕੈਮਰਾ ਤੇ ਸਵਿਚ ਕਰੋ" + "ਫ੍ਰੰਟ ਅਤੇ ਬੈਕ ਕੈਮਰਾ ਵਿਚਕਾਰ ਸਵਿਚ ਕਰੋ" + "ਰਿਕਾਰਡਿੰਗ ਰੋਕੋ ਅਤੇ ਵੀਡੀਓ ਅਟੈਚ ਕਰੋ" + "ਵੀਡੀਓ ਰਿਕਾਰਡ ਕਰਨਾ ਰੋਕੋ" + "Messaging ਫੋਟੋਆਂ" + + %d ਫੋਟੋਆਂ ਨੂੰ \"%s\" ਐਲਬਮ ਵਿੱਚ ਸੁਰੱਖਿਅਤ ਕੀਤਾ ਗਿਆ + %d ਫੋਟੋਆਂ ਨੂੰ \"%s\" ਐਲਬਮ ਵਿੱਚ ਸੁਰੱਖਿਅਤ ਕੀਤਾ ਗਿਆ + + + %d ਵੀਡਿਓ ਨੂੰ \"%s\" ਐਲਬਮ ਵਿੱਚ ਸੁਰੱਖਿਅਤ ਕੀਤਾ ਗਿਆ + %d ਵੀਡਿਓ ਨੂੰ \"%s\" ਐਲਬਮ ਵਿੱਚ ਸੁਰੱਖਿਅਤ ਕੀਤਾ ਗਿਆ + + + %d ਅਟੈਚਮੈਂਟਾਂ ਨੂੰ \"%s\" ਐਲਬਮ ਵਿੱਚ ਸੁਰੱਖਿਅਤ ਕੀਤਾ ਗਿਆ + %d ਅਟੈਚਮੈਂਟਾਂ ਨੂੰ \"%s\" ਐਲਬਮ ਵਿੱਚ ਸੁਰੱਖਿਅਤ ਕੀਤਾ ਗਿਆ + + + %d ਅਟੈਚਮੈਂਟਾਂ ਡਾਊਨਲੋਡਸ\" ਵਿੱਚ ਸੁਰੱਖਿਅਤ ਕੀਤੀਆਂ ਗਈਆਂ + %d ਅਟੈਚਮੈਂਟਾਂ ਡਾਊਨਲੋਡਸ\" ਵਿੱਚ ਸੁਰੱਖਿਅਤ ਕੀਤੀਆਂ ਗਈਆਂ + + + %d ਅਟੈਚਮੈਂਟਾਂ ਸੁਰੱਖਿਅਤ ਕੀਤੀਆਂ + %d ਅਟੈਚਮੈਂਟਾਂ ਸੁਰੱਖਿਅਤ ਕੀਤੀਆਂ + + + %d ਅਟੈਚਮੈਂਟਾਂ ਨੂੰ ਸੁੁਰੱਖਿਅਤ ਨਹੀਂ ਕਰ ਸਕਿਆ + %d ਅਟੈਚਮੈਂਟਾਂ ਨੂੰ ਸੁੁਰੱਖਿਅਤ ਨਹੀਂ ਕਰ ਸਕਿਆ + + "ਸੁਰੱਖਿਅਤ ਕੀਤੀ MMS ਅਟੈਚਮੈਂਟ" + "ਸੈਟਿੰਗਾਂ" + "ਆਰਕਾਈਵ ਕੀਤਾ" + "ਬੰਦ ਕਰੋ" + "MMS" + "ਉੱਨਤ" + "ਡੀਬਗ ਕਰੋ" + "ਸੂਚਨਾਵਾਂ" + "ਅਵਾਜ਼" + "ਸਾਈਲੈਂਟ" + "ਵਾਈਬ੍ਰੇਟ" + "ਬਲੌਕ ਕੀਤਾ" + "SMS ਡਿਲੀਵਰੀ ਰਿਪੋਰਟਾਂ" + "ਤੁਸੀਂ ਜੋ ਵੀ SMS ਭੇਜੋ ਉਸ ਹਰੇਕ ਲਈ ਇੱਕ ਡਿਲੀਵਰੀ ਰਿਪੋਰਟ ਦੀ ਬੇਨਤੀ ਕਰੋ" + "ਆਟੋ-ਮੁੜ ਪ੍ਰਾਪਤ ਕਰੋ" + "ਆਟੋਮੈਟਿਕਲੀ MMS ਸੁਨੇਹੇ ਪ੍ਰਾਪਤ ਕਰੋ" + "ਰੋਮਿੰਗ ਆਟੋ-ਮੁੜ ਪ੍ਰਾਪਤ ਕਰੋ" + "ਰੋਮਿੰਗ ਵੇਲੇ MMS ਆਟੋਮੈਟਿਕਲੀ ਮੁੜ ਪ੍ਰਾਪਤ ਕਰੋ" + "ਸਮੂਹ ਮੈਸੇਂਜ਼ਿੰਗ" + "ਜਦੋਂ ਮਲਟੀਪਲ ਪ੍ਰਾਪਤਕਰਤਾ ਹੋਣ ਤਾਂ ਇੱਕ ਸਿੰਗਲ ਸੁਨੇਹਾ ਭੇਜਣ ਲਈ MMS ਵਰਤੋ" + "ਡਿਫੌਲਟ SMS ਐਪ" + "ਡਿਫੌਲਟ SMS ਐਪ" + + + "ਤੁਹਾਡਾ ਫੋਨ ਨੰਬਰ" + "ਅਗਿਆਤ" + "ਆਊਟਗੋਇੰਗ ਸੁਨੇਹਾ ਅਵਾਜ਼ਾਂ" + "SMS ਡੰਪ ਕਰੋ" + "ਅੰਦਰੂਨੀ ਸਟੋਰੇਜ ਫਾਈਲ ਵਿੱਚ ਪ੍ਰਾਪਤ ਕੀਤਾ SMS ਅਧੂਰਾ ਡਾਟਾ ਡੰਪ ਕਰੋ" + "MMS ਡੰਪ ਕਰੋ" + "ਅੰਦਰੂਨੀ ਸਟੋਰੇਜ ਫਾਈਲ ਵਿੱਚ ਪ੍ਰਾਪਤ ਕੀਤਾ MMS ਅਧੂਰਾ ਡਾਟਾ ਡੰਪ ਕਰੋ" + "ਵਾਇਰਲੈਸ ਚਿਤਾਵਨੀਆਂ" + "ਸੁਨੇਹਾ ਚੋਣਾਂ" + "ਟੈਕਸਟ ਕਾਪੀ ਕਰੋ" + "ਵੇਰਵੇ ਦੇਖੋ" + "ਮਿਟਾਓ" + "ਅੱਗੇ ਭੇਜੋ" + "ਸੁਨੇਹਾ ਵੇਰਵੇ" + "ਟਾਈਪ: " + "ਟੈਕਸਟ ਸੁਨੇਹਾ" + "ਮਲਟੀਮੀਡੀਆ ਸੁਨੇਹਾ" + "ਇਸ ਤੋਂ: " + "ਵੱਲ: " + "ਭੇਜਿਆ: " + "ਪ੍ਰਾਪਤ ਕੀਤਾ: " + "ਵਿਸ਼ਾ: " + "ਆਕਾਰ: " + "ਤਰਜੀਹ: " + "SIM: " + "ਉੱਚ" + "ਸਧਾਰਨ" + "ਘੱਟ" + "SIM %s" + "ਭੇਜਣ ਵਾਲੇ ਦਾ ਪਤਾ ਲੁਕਾਇਆ ਗਿਆ" + "ਅਟੈਚਮੈਂਟਾਂ ਲੋਡ ਕਰਨ ਵੇਲੇ ਸੁਨੇਹਾ ਨਹੀਂ ਭੇਜ ਸਕਦਾ।" + "ਅਟੈਚਮੈਂਟ ਲੋਡ ਨਹੀਂ ਕਰ ਸਕਦਾ। ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।" + "ਨੈੱਟਵਰਕ ਤਿਆਰ ਨਹੀਂ ਹੈ। ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।" + "ਟੈਕਸਟ ਮਿਟਾਓ" + "ਟੈਕਸਟ ਅਤੇ ਨੰਬਰ ਦਰਜ ਕਰਨ ਵਿਚਕਾਰ ਸਵਿਚ ਕਰੋ" + "ਹੋਰ ਭਾਗੀਦਾਰ ਜੋੜੋ" + "ਭਾਗੀਦਾਰਾਂ ਦੀ ਪੁਸ਼ਟੀ ਕਰੋ" + "ਨਵੀਂ ਗੱਲਬਾਤ ਚਾਲੂ ਕਰੋ" + "ਇਹ ਆਈਟਮ ਚੁਣੋ" + "ਵੀਡੀਓ ਪਲੇ ਕਰੋ" + "ਲੋਕ & ਚੋਣਾਂ" + "ਡੀਬਗ ਕਰੋ" + "ਲੋਕ & ਚੋਣਾਂ" + "ਸਧਾਰਨ" + "ਇਸ ਗੱਲਬਾਤ ਵਿੱਚ ਲੋਕ" + "ਇੱਕ ਕਾਲ ਕਰੋ" + "ਸੁਨੇਹਾ ਭੇਜੋ" + "ਸੁਨੇਹਾ ਭੇਜੋ<br/><ਛੋਟਾ>%s</ਤੋਂ ਛੋਟਾ>" + + ਫੋਟੋਆਂ ਭੇਜੋ + ਫੋਟੋਆਂ ਭੇਜੋ + + + ਔਡੀਓ ਭੇਜੋ + ਔਡੀਓ ਭੇਜੋ + + + ਵੀਡਿਓ ਭੇਜੋ + ਵੀਡਿਓ ਭੇਜੋ + + + ਸੰਪਰਕ ਕਾਰਡ ਭੇਜੋ + ਸੰਪਰਕ ਕਾਰਡ ਭੇਜੋ + + + ਅਟੈਚਮੈਂਟਾਂ ਭੇਜੋ + ਅਟੈਚਮੈਂਟਾਂ ਭੇਜੋ + + + %d ਅਟੈਚਮੈਂਟਾਂ ਭੇਜਣ ਲਈ ਤਿਆਰ + %d ਅਟੈਚਮੈਂਟਾਂ ਭੇਜਣ ਲਈ ਤਿਆਰ + + "ਫੀਡਬੈਕ ਭੇਜੋ" + "Google Play ਸਟੋਰ ਵਿੱਚ ਦੇਖੋ" + "ਵਰਜਨ ਜਾਣਕਾਰੀ" + "ਵਰਜਨ %1$s" + "ਓਪਨ ਸ੍ਰੋਤ ਲਸੰਸ" + "ਸੂਚਨਾਵਾਂ" + "ਅਟੈਚਮੈਂਟ ਸੀਮਾ ਪੂਰੀ ਹੋਈ" + "ਅਟੈਚਮੈਂਟ ਲੋਡ ਕਰਨ ਵਿੱਚ ਅਸਫਲ।" + "ਕੀ ਸੰਪਰਕ ਜੋੜਨੇ ਹਨ?" + "ਸੰਪਰਕ ਜੋੜੋ" + "ਵਿਸ਼ਾ" + "ਵਿਸ਼ਾ: " + "%s%s" + "ਸੰਪਰਕ ਕਾਰਡ ਲੋਡ ਕਰ ਰਿਹਾ ਹੈ" + "ਸੰਪਰਕ ਕਾਰਡ ਲੋਡ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਿਆ।" + "ਸੰਪਰਕ ਕਾਰਡ ਦੇਖੋ" + + %d ਸੰਪਰਕ + %d ਸੰਪਰਕ + + "ਸੰਪਰਕ ਕਾਰਡ" + "ਜਨਮਦਿਨ" + "ਸੂਚਨਾਵਾਂ" + "ਸੁਨੇਹਾ ਅੱਗੇ ਭੇਜੋ" + "ਜਵਾਬ ਦਿਓ" + "+1" + "+%d" + "SMS ਅਸਮਰਥਿਤ" + "ਭੇਜਣ ਲਈ, Messaging ਨੂੰ ਡਿਫੌਲਟ SMS ਐਪ ਦੇ ਤੌਰ ਤੇ ਸੈਟ ਕਰੋ" + "Messaging ਨੂੰ ਡਿਫੌਲਟ SMS ਐਪ ਦੇ ਤੌਰ ਤੇ ਸੈਟ ਕਰੋ" + "ਬਦਲੋ" + "ਸੁਨੇਹੇ ਪ੍ਰਾਪਤ ਕਰਨ ਲਈ, Messaging ਨੂੰ ਡਿਫੌਲਟ SMS ਐਪਸ ਦੇ ਤੌਰ ਤੇ ਸੈਟ ਕਰੋ" + "SMS ਸੁਨੇਹੇ ਭੇਜਣ ਲਈ ਕੋਈ ਤਰਜੀਹੀ SIM ਨਹੀਂ ਚੁਣਿਆ ਗਿਆ" + "ਡਿਵਾਈਸ ਮਾਲਕ ਵੱਲੋਂ ਇਸ ਐਪ ਦੀ ਆਗਿਆ ਨਹੀਂ ਹੈ।" + "ਠੀਕ" + "ਇੱਕ ਗੱਲਬਾਤਾ ਵਿੱਚ ਬਹੁਤ ਜ਼ਿਆਦਾ ਪ੍ਰਾਪਤਕਰਤਾ" + + ਅਪ੍ਰਮਾਣਿਕ ਸੰਪਰਕ + ਅਪ੍ਰਮਾਣਿਕ ਸੰਪਰਕ + + "ਕੈਮਰਾ ਚਿੱਤਰ ਲੋਡ ਨਹੀਂ ਕਰ ਸਕਿਆ" + "ਤੁਸੀਂ: " + "%s: " + "ਡ੍ਰਾਫਟ" + "ਜਦੋਂ ਤੁਸੀਂ ਇੱਕ ਨਵੀਂ ਗੱਲਬਾਤ ਚਾਲੂ ਕਰਦੇ ਹੋ, ਤਾਂ ਤੁਸੀਂ ਇਸਨੂੰ ਇੱਥੇ ਸੂਚੀਬੱਧ ਦੇਖੋਗੇ" + "ਆਰਕਾਈਵ ਕੀਤੀਆਂ ਗੱਲਬਾਤਾਂ ਇੱਥੇ ਪ੍ਰਗਟ ਹੋਣਗੀਆਂ" + "ਗੱਲਬਾਤਾਂ ਲੋਡ ਕਰ ਰਿਹਾ ਹੈ…" + "ਤਸਵੀਰ" + "ਔਡੀਓ ਕਲਿਪ" + "ਵੀਡੀਓ" + "ਸੰਪਰਕ ਕਾਰਡ" + "MMS" + "ਪਹਿਲਾਂ ਵਰਗਾ ਕਰੋ" + "ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ" + "ਇੱਕ ਨਵਾਂ ਸੁਨੇਹਾ ਚਾਲੂ ਕਰਨ ਲਈ ਇੱਕ ਸੰਪਰਕ ਨਾਮ ਜਾਂ ਫੋਨ ਨੰਬਰ ਦਰਜ ਕਰੋ" + "ਬਲੌਕ ਕਰੋ" + "%s ਨੂੰ ਬਲੌਕ ਕਰੋ" + "%s ਨੂੰ ਅਨਬਲੌਕ ਕਰੋ" + "ਕੀ %s ਨੂੰ ਬਲੌਕ ਕਰਨਾ ਹੈ?" + "ਤੁਸੀਂ ਇਸ ਨੰਬਰ ਤੋਂ ਲਗਾਤਾਰ ਸੁਨੇਹੇ ਪ੍ਰਾਪਤ ਕਰੋਗੇ ਪਰੰਤੂ ਹੁਣ ਤੁਹਾਨੂੰ ਸੂਚਿਤ ਨਹੀਂ ਕੀਤਾ ਜਾਏਗਾ। ਇਹ ਗੱਲਬਾਤ ਆਰਕਾਈਵ ਕੀਤੀ ਜਾਏਗੀ।" + "ਬਲੌਕ ਕੀਤੇ ਸੰਪਰਕ" + "ਅਨਬਲੌਕ ਕਰੋ" + "ਬਲੌਕ ਕੀਤੇ ਸੰਪਰਕ" + "ਦਸਤਾਵੇਜ਼ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚੋਂ ਚਿੱਤਰ ਚੁਣੋ" + "ਸੁਨੇਹਾ ਭੇਜ ਰਿਹਾ ਹੈ" + "ਸੁਨੇਹਾ ਭੇਜਿਆ ਗਿਆ" + "ਸੈਲਿਊਲਰ ਡਾਟਾ ਬੰਦ ਹੈ। ਆਪਣੀਆਂ ਸੈਟਿੰਗਾਂ ਦੀ ਜਾਂਚ ਕਰੋ।" + "ਏਅਰਪਲੇਨ ਮੋਡ ਵਿੱਚ ਸੁਨੇਹੇ ਨਹੀਂ ਭੇਜ ਸਕਦਾ" + "ਸੁਨੇਹਾ ਨਹੀਂ ਭੇਜਿਆ ਜਾ ਸਕੇਗਾ" + "ਸੁਨੇਹਾ ਡਾਊਨਲੋਡ ਕੀਤਾ" + "ਸੈਲਿਊਲਰ ਡਾਟਾ ਬੰਦ ਹੈ। ਆਪਣੀਆਂ ਸੈਟਿੰਗਾਂ ਦੀ ਜਾਂਚ ਕਰੋ।" + "ਏਅਰਪਲੇਨ ਮੋਡ ਵਿੱਚ ਸੁਨੇਹੇ ਡਾਊਨਲੋਡ ਨਹੀਂ ਕੀਤੇ ਜਾ ਸਕਦੇ" + "ਸੁਨੇਹਾ ਡਾਊਨਲੋਡ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕੇਗਾ" + "ਸਿਫਰ" + "ਇੱਕ" + "ਦੋ" + "ਤਿੰਨ" + "ਚਾਰ" + "ਪੰਜ" + "ਛੇ" + "ਸੱਤ" + "ਅੱਠ" + "ਨੌ" + "%1$s ਨਾਲ ਸੁਨੇਹਾ ਨਹੀਂ ਭੇਜ ਸਕਦਾ, ਅਸ਼ੁੱਧੀ %2$d" + "ਅਗਿਆਤ ਕੈਰੀਅਰ ਨਾਲ ਸੁਨੇਹਾ ਨਹੀਂ ਭੇਜ ਸਕਦਾ, ਅਸ਼ੁੱਧੀ %1$d" + "Fwd: %s" + "ਸੁਨੇਹਾ ਨਹੀਂ ਭੇਜਿਆ: ਨੈਟਵਰਕ ਤੇ ਸੇਵਾ ਸਕਿਰਿਆ ਨਹੀਂ" + "ਸੁਨੇਹਾ ਨਹੀਂ ਭੇਜਿਆ: ਅਪ੍ਰਮਾਣਿਕ ਡੈਸਟੀਨੇਸ਼ਨ ਪਤਾ" + "ਸੁਨੇਹਾ ਨਹੀਂ ਭੇਜਿਆ: ਅਪ੍ਰਮਾਣਿਤ ਸੁਨੇਹਾ" + "ਸੁਨੇਹਾ ਨਹੀਂ ਭੇਜਿਆ: ਅਸਮਰਥਿਤ ਸਮੱਗਰੀ" + "ਸੁਨੇਹਾ ਨਹੀਂ ਭੇਜਿਆ: ਅਸਮਰਥਿਤ ਸੁਨੇਹਾ" + "ਸੁਨੇਹਾ ਨਹੀਂ ਭੇਜਿਆ ਗਿਆ: ਬਹੁਤ ਜ਼ਿਆਦਾ ਵੱਡਾ" + "ਨਵਾਂ ਸੁਨੇਹਾ" + "ਦੇਖੋ" + "ਚਿੱਤਰ" + "ਇੱਕ ਅਨੁੁਕੂਲ ਐਪਲੀਕੇਸ਼ਨ ਨਹੀਂ ਲੱਭ ਸਕਿਆ" + "ਪ੍ਰਾਪਤਕਰਤਾ ਹਟਾਓ" + "ਨਵਾਂ ਸੁਨੇਹਾ" + "ਰੱਦ ਕਰੋ" + "ਪਹੁੰਚ ਬਿੰਦੂ ਸੰਪਾਦਿਤ ਕਰੋ" + "ਸੈਟ ਨਹੀਂ ਕੀਤਾ" + "ਨਾਮ" + "APN" + "MMSC" + "MMS ਪ੍ਰੌਕਸੀ" + "MMS ਪੋਰਟ" + "MCC" + "MNC" + "APN ਪ੍ਰਕਾਰ" + "APN ਮਿਟਾਓ" + "ਨਵਾਂ APN" + "ਸੁਰੱਖਿਅਤ ਕਰੋ" + "ਅਣਡਿੱਠਾ" + "ਨਾਮ ਖੇਤਰ ਨੂੰ ਖਾਲੀ ਨਹੀਂ ਛੱਡਿਆ ਜਾ ਸਕਦਾ।" + "APN ਨੂੰ ਖਾਲੀ ਨਹੀਂ ਛੱਡਿਆ ਜਾ ਸਕਦਾ।" + "MCC ਖੇਤਰ 3 ਅੰਕਾਂ ਦਾ ਹੋਣਾ ਲਾਜ਼ਮੀ ਹੈ।" + "MNC ਖੇਤਰ 2 ਜਾਂ 3 ਅੰਕਾਂ ਦਾ ਹੋਣਾ ਲਾਜ਼ਮੀ ਹੈ।" + "ਡਿਫੌਲਟ APN ਸੈਟਿੰਗਾਂ ਰੀਸਟੋਰ ਕਰ ਰਿਹਾ ਹੈ।" + "ਡਿਫੌਲਟਸ ਤੇ ਰੀਸੈਟ ਕਰੋ" + "ਡਿਫੌਲਟ APN ਸੈਟਿੰਗਾਂ ਰੀਸੈਟ ਕਰਨਾ ਪੂਰਾ ਹੋਇਆ।" + "ਬਿਨਾਂ ਨਾਮ" + "ਪਹੁੰਚ ਬਿੰਦੂ ਨਾਮ" + "APNs" + "ਨਵਾਂ APN" + "ਪਹੁੰਚ ਬਿੰਦੂ ਨਾਮ ਸੈਟਿੰਗਾਂ ਇਸ ਉਪਭੋਗਤਾ ਲਈ ਉਪਲਬਧ ਨਹੀਂ ਹਨ" + "ਕੀ ਕਲਿਪਬੋਰਡ ਤੇ ਕਾਪੀ ਕਰਨਾ ਹੈ?" + "ਕਾਪੀ ਕਰੋ" + "%s ਨੂੰ" + "ਸਧਾਰਨ" + "ਉੱਨਤ" + "ਸਧਾਰਨ ਸੈਟਿੰਗਾਂ" + "ਉੱਨਤ ਸੈਟਿੰਗਾਂ" + "\"%s\" SIM" + "ਸਾਰੇ ਪ੍ਰਾਪਤਕਰਤਾਵਾਂ ਨੂੰ ਵੱਖ-ਵੱਖ SMS ਸੁਨੇਹੇ ਭੇਜੋ। ਕੇਵਲ ਤੁਸੀਂ ਹੀ ਕੋਈ ਜਵਾਬ ਪ੍ਰਾਪਤ ਕਰੋਗੇ" + "ਸਾਰੇ ਪ੍ਰਾਪਤਕਰਤਾਵਾਂ ਨੂੰ ਇੱਕ ਸਿੰਗ MMS ਭੇਜੋ।" + "ਅਗਿਆਤ ਨੰਬਰ" + "ਨਵਾਂ ਸੁਨੇਹਾ" + "ਨਵਾਂ ਸੁਨੇਹਾ।" + "SIM ਚੋਣਕਾਰ" + "%1$s ਚੁਣਿਆ ਗਿਆ, SIM ਚੋਣਕਰਤਾ" + "ਵਿਸ਼ਾ ਸੰਪਾਦਿਤ ਕਰੋ" + "SIM ਚੁਣੋ ਜਾਂ ਵਿਸ਼ਾ ਸੰਪਾਦਿਤ ਕਰੋ" + "ਔਡੀਓ ਰਿਕਾਰਡ ਕਰਨ ਲਈ ਛੋਹਵੋ ਅਤੇ ਹੋਲਡ ਕਰੋ" + "ਨਵੀਂ ਗੱਲਬਾਤ ਚਾਲੂ ਕਰੋ" + "Messaging" + "Messaging ਸੂਚੀ" + "Messaging" + "ਨਵਾਂ ਸੁਨੇਹਾ" + "ਗੱਲਬਾਤ ਸੂਚੀ" + "ਗੱਲਬਾਤਾਂ ਲੋਡ ਕਰ ਰਿਹਾ ਹੈ" + "ਸੁਨੇਹੇ ਲੋਡ ਕਰ ਰਿਹਾ ਹੈ" + "ਹੋਰ ਗੱਲਬਾਤਾਂ ਦੇਖੋ" + "ਹੋਰ ਸੁਨੇਹੇ ਦੇਖੋ" + "ਗੱਲਬਾਤ ਮਿਟਾਈ ਗਈ" + "ਗੱਲਬਾਤ ਮਿਟਾਈ ਗਈ। ਇੱਕ ਵੱਖਰੀ Messaging ਗੱਲਬਾਤ ਦਿਖਾਉਣ ਲਈ ਛੋਹਵੋ" + "ਬਲੌਕ ਕੀਤਾ" + "ਅਨਬਲੌਕ ਕੀਤਾ" + "ਸਟੋਰੇਜ ਸਪੇਸ ਘੱਟ ਹੈ। ਕੁਝ ਡਾਟਾ ਨਸ਼ਟ ਹੋ ਸਕਦਾ ਹੈ।" + "ਅਟੈਚਮੈਂਟਾਂ ਚੁਣੋ" + "ਚੋਣ ਦੀ ਪੁਸ਼ਟੀ ਕਰੋ" + "%d ਚੁਣਿਆ ਗਿਆ" + "ਕਿਰਪਾ ਕਰਕੇ ਇੱਕ ਜਾਂ ਵੱਧ ਅਟੈਚਮੈਂਟਾਂ ਹਟਾਓ ਅਤੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।" + "ਤੁਸੀਂ ਆਪਣਾ ਸੁਨੇਹਾ ਭੇਜਣ ਦੀ ਕੋਸ਼ਿਸ਼ ਕਰ ਸਕਦੇ ਹੋ, ਪਰੰਤੂ ਇਹ ਸ਼ਾਇਦ ਨਾ ਭੇਜਿਆ ਜਾ ਸਕੇ, ਜਦੋਂ ਤੱਕ ਤੁਸੀਂ ਇੱਕ ਜਾਂ ਵੱਧ ਅਟੈਚਮੈਂਟਾਂ ਨਹੀਂ ਹਟਾਉਂਦੇ।" + "ਤੁਸੀਂ ਪ੍ਰਤੀ ਸੁਨੇਹਾ ਕੇਵਲ ਇੱਕ ਵੀਡੀਓ ਭੇਜ ਸਕਦੇ ਹੋ। ਕਿਰਪਾ ਕਰਕੇ ਵਾਧੂ ਵੀਡੀਓ ਹਟਾਓ ਅਤੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।" + "Messaging ਅਟੈਚਮੈਂਟ ਲੋਡ ਕਰਨ ਵਿੱਚ ਅਸਫਲ।" + "ਫੇਰ ਵੀ ਭੇਜੋ" + "ਗੱਲਬਾਤ ਚਾਲੂ ਨਹੀਂ ਕਰ ਸਕਿਆ" + "%1$s (%2$s)" + "%s ਚੁਣਿਆ ਗਿਆ" + diff --git a/res/values-pl/arrays.xml b/res/values-pl/arrays.xml new file mode 100644 index 0000000..2bd82e4 --- /dev/null +++ b/res/values-pl/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "brak tematu" + "braktematu" + + + "Tak" + "Nie" + "OK" + "Ha ha" + "Dzięki" + "Zgadzam się" + "Super" + "Jestem w drodze" + "Skontaktuję się z Tobą później" + ":)" + ":(" + + diff --git a/res/values-pl/strings.xml b/res/values-pl/strings.xml new file mode 100644 index 0000000..0ee2511 --- /dev/null +++ b/res/values-pl/strings.xml @@ -0,0 +1,571 @@ + + + + + "Wiadomości" + "Wiadomości" + "Wybierz wątek" + "Ustawienia" + "Wyślij wiadomość" + "Dodaj załącznik" + "Pomoc" + "Witamy" + "Pomiń" + "Dalej >" + "Dalej" + "Zakończ" + "Ustawienia >" + "Ustawienia" + "Aplikacja SMS/MMS potrzebuje uprawnień do SMS-ów, telefonu i kontaktów." + "Uprawnienia możesz zmienić, klikając Ustawienia > Aplikacje > SMS/MMS > Uprawnienia." + "Uprawnienia możesz zmienić, klikając Ustawienia, Aplikacje, SMS/MMS, Uprawnienia." + "Częste kontakty" + "Wszystkie kontakty" + "Wyślij pod numer %s" + "Zrób zdjęcia lub nagraj film" + "Wybierz obrazy z tego urządzenia" + "Nagraj dźwięk" + "Wybierz zdjęcie" + "Plik multimedialny został wybrany." + "Plik multimedialny nie został wybrany." + "Wybrano: %d" + "obraz – %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "obraz" + "Nagraj dźwięk" + "Udostępnij" + "W tej chwili" + "Teraz" + + %d min + %d min + %d min + %d min + + + %d godziny + %d godzin + %d godziny + %d godzina + + + %d dni + %d dni + %d dnia + %d dzień + + + %d tygodnie + %d tygodni + %d tygodnia + tydzień + + + %d miesiące + %d miesięcy + %d miesiąca + miesiąc + + + %d lata + %d lat + %d roku + rok + + "Wiadomość klasy 0" + "Zapisz" + "Na urządzeniu jest mało miejsca. Aplikacja SMS/MMS automatycznie usunie starsze SMS-y, by zwolnić miejsce." + "Kończy się miejsce" + "Aplikacja SMS/MMS może nie wysyłać i nie odbierać wiadomości do momentu zwolnienia miejsca na urządzeniu." + "Mało miejsca na SMS-y. Może usuniesz niektóre wiadomości?" + "Potwierdź swój numer telefonu" + "Dzięki tej jednorazowej czynności aplikacja SMS/MMS będzie poprawnie dostarczać Twoje wiadomości grupowe." + "Numer telefonu" + "Usuń wszystkie MMS-y" + "Usuń wiadomości starsze niż %s" + "Automatycznie usuwaj wiadomości starsze niż %s" + "Ignoruj" + "Usunąć wszystkie MMS-y?" + "Usunąć wiadomości starsze niż %s?" + "Usunąć wiadomości starsze niż %s i włączyć automatyczne usuwanie?" + "%s pisze" + "Treść Twojej wiadomości" + "Wiadomość od użytkownika %s" + "Wysłałeś wiadomość" + "Wysyłam…" + "Nie wysłano. Kliknij, by spróbować ponownie." + "Nie wysłano. Próbuję ponownie…" + "Wyślij ponownie lub usuń" + "Zadzwoń do służb ratunkowych. Tym razem nie udało się dostarczyć SMS-a." + "Błąd" + "Nowa wiadomość MMS do pobrania" + "Nowa wiadomość MMS" + "Nie udało się pobrać" + "Kliknij, by spróbować ponownie" + "Kliknij, by pobrać" + "Pobierz lub usuń" + "Pobieram…" + "Wiadomość wygasła lub jest niedostępna" + "rozmiar: %1$s, termin ważności: %2$s" + "Nie można wysłać. Nieprawidłowy adresat." + "Usługa nie została aktywowana w sieci" + "Nie udało się wysłać z powodu problemu z siecią" + "Wiadomość wygasła lub jest niedostępna" + "(Brak tematu)" + "Nieznany nadawca" + "Dostarczono" + "Nie udało się pobrać wiadomości %1$s od użytkownika %2$s." + "Nie można dokończyć operacji na bazie danych z powodu zbyt małej ilości pamięci" + "Wiadomość nie została wysłana" + "Niektóre wiadomości nie zostały wysłane w aplikacji SMS/MMS" + + %d wiadomości w %d wątkach + %d wiadomości w %d wątkach + %d wiadomości w %d wątku + %d wiadomości w jednym wątku + + "Wiadomość nie została pobrana" + "Niektóre wiadomości nie zostały pobrane w aplikacji SMS/MMS" + + %d wiadomości w %d wątkach + %d wiadomości w %d wątkach + %d wiadomości w %d wątku + %d wiadomości w jednym wątku + + "Wiadomość do %1$s nie została wysłana" + "Zadzwoń do służb ratunkowych. Tym razem nie udało się dostarczyć SMS-a pod numer %1$s." + + %d nowe wiadomości + %d nowych wiadomości + %d nowej wiadomości + Nowa wiadomość + + "Rozpocznij" + "Aparat jest niedostępny" + "Aparat jest niedostępny" + "Nagrywanie filmu jest niedostępne" + "Nie można zapisać pliku multimedialnego" + "Nie można zrobić zdjęcia" + "Wstecz" + "Archiwum" + "Usuń" + "Archiwizuj" + "Przywróć z archiwum" + "Wyłącz powiadomienia" + "Włącz powiadomienia" + "Dodaj kontakt" + "Pobierz" + "Wyślij" + "Usuń" + "Usunąć tę wiadomość?" + "Nie można tego cofnąć." + "Usuń" + + Usunąć te wątki? + Usunąć te wątki? + Usunąć te wątki? + Usunąć ten wątek? + + "Usuń" + "Anuluj" + "Do" + "Wybierz wiele obrazów" + "Potwierdź wybór" + "Jeszcze %d" + "Nie można nagrać dźwięku. Spróbuj ponownie." + "Nie można odtworzyć dźwięku. Spróbuj ponownie." + "Nie można zapisać dźwięku. Spróbuj ponownie." + "Kliknij i przytrzymaj" + ", " + " " + ": " + " " + "Zdjęcie" + "Klip dźwiękowy" + "Film" + "Wizytówka" + "Pobierz" + "Odpowiedz SMS-em" + "Wyślij MMS-a" + "Odpowiedz" + + %d uczestników + %d uczestników + %d uczestnika + %d uczestnik + + "Ja" + "Kontakt został zablokowany i zarchiwizowany" + "Kontakt odblokowany i rozpakowany" + "Zarchiwizowano: %d" + "Przywrócone z archiwum: %d" + "Powiadomienia wyłączone" + "Powiadomienia włączone" + "Wszystko gotowe. Ponownie kliknij Wyślij." + "SMS/MMS to teraz domyślna aplikacja do SMS-ów." + + Odrzuć załączniki + Odrzuć załączniki + Odrzuć załączniki + Odrzuć załącznik + + "Załącznik audio" + "Odtwórz załącznik audio" + "Pauza" + "Wiadomość od użytkownika %s: %s." + "Nieudane odbieranie wiadomości od: %s: %s. Godzina: %s." + "Wiadomość od: %s: %s. Godzina: %s." + "Niewysłana wiadomość do: %s: %s. Godzina: %s." + "Wysyłanie wiadomości do: %s: %s. Godzina: %s." + "Nieudane wysyłanie wiadomości do: %s: %s. Godzina: %s." + "Wiadomość do: %s: %s. Godzina: %s." + "Nieudane odbieranie wiadomości od: %s: %s. Godzina: %s. %s." + "Wiadomość od: %s: %s. Godzina: %s. %s." + "Niewysłana wiadomość do: %s: %s. Godzina: %s." + "Wysyłanie wiadomości do: %s: %s. Godzina: %s." + "Nieudane wysyłanie wiadomości do: %s: %s. Godzina: %s." + "Wiadomość do: %s: %s. Godzina: %s." + "Niedostarczona wiadomość. Kliknij, aby ponowić próbę." + "Rozmowa z: %s" + "Usuń temat" + "Nagraj film" + "Zrób zdjęcie" + "Zrób zdjęcie" + "Zacznij nagrywać film" + "Włącz aparat pełnoekranowy" + "Przełącz między kamerą z przodu i z tyłu" + "Zatrzymaj nagrywanie i dołącz film" + "Zatrzymaj nagrywanie filmu" + "Zdjęcia z aplikacji SMS/MMS" + + %d zdjęcia zapisane w albumie „%s + %d zdjęć zapisanych w albumie „%s + %d zdjęcia zapisane w albumie „%s + %d zdjęcie zapisane w albumie „%s + + + %d filmy zapisane w albumie „%s + %d filmów zapisanych w albumie „%s + %d filmu zapisane w albumie „%s + %d film zapisany w albumie „%s + + + %d załączniki zapisane w albumie „%s + %d załączników zapisanych w albumie „%s + %d załącznika zapisane w albumie „%s + %d załącznik zapisany w albumie „%s + + + %d załączniki zapisane w folderze „Pobrane pliki” + %d załączników zapisanych w folderze „Pobrane pliki” + %d załącznika zapisane w folderze „Pobrane pliki” + %d załącznik zapisany w folderze „Pobrane pliki” + + + %d załączniki zapisane + %d załączników zapisanych + %d załącznika zapisane + %d załącznik zapisany + + + Nie udało się zapisać %d załączników + Nie udało się zapisać %d załączników + Nie udało się zapisać %d załącznika + Nie udało się zapisać %d załącznika + + "Zapisany załącznik MMS-a" + "Ustawienia" + "Zarchiwizowane" + "Zamknij" + "MMS" + "Zaawansowane" + "Debugowanie" + "Powiadomienia" + "Dźwięk" + "Cichy" + "Wibracje" + "Zablokowany" + "Raporty doręczeń SMS-ów" + "Otrzymuj raport o dostarczeniu każdego wysłanego SMS-a" + "Autopobieranie" + "Automatycznie pobieraj MMS-y" + "Autopobieranie w roamingu" + "Automatycznie pobieraj MMS-y w roamingu" + "Wiadomości grupowe" + "Używaj MMS-ów, gdy chcę wysłać jedną wiadomość do wielu odbiorców" + "Domyślna aplikacja do SMS-ów" + "Domyślna aplikacja do SMS-ów" + + + "Twój numer telefonu" + "Nieznany" + "Dźwięki przy wysyłaniu wiadomości" + "Zrzuć SMS-y" + "Zrzuć odebrane nieprzetworzone dane SMS-ów do pliku w pamięci zewnętrznej" + "Zrzuć MMS-y" + "Zrzuć odebrane nieprzetworzone dane MMS-ów do pliku w pamięci zewnętrznej" + "Alerty o zagrożeniach" + "Opcje wiadomości" + "Skopiuj tekst" + "Wyświetl szczegóły" + "Usuń" + "Przekaż dalej" + "Szczegóły wiadomości" + "Typ: " + "SMS" + "Wiadomość multimedialna" + "Od: " + "Do: " + "Wysłano: " + "Odebrane: " + "Temat: " + "Rozmiar: " + "Priorytet: " + "SIM: " + "Wysoki" + "Normalny" + "Niski" + "SIM %s" + "Ukryty adres nadawcy" + "Nie można wysłać wiadomości w czasie ładowania załączników." + "Nie można załadować załącznika. Spróbuj ponownie." + "Sieć nie jest gotowa. Spróbuj ponownie." + "Usuń tekst" + "Przełącz się między wpisywaniem tekstu i cyfr" + "Dodaj uczestników" + "Potwierdź uczestników" + "Rozpocznij nowy wątek" + "Wybierz ten element" + "Odtwórz film" + "Osoby i opcje" + "Debuguj" + "Osoby i opcje" + "Ogólne" + "Uczestnicy tej rozmowy" + "Zadzwoń" + "Wyślij wiadomość" + "Wyślij wiadomość<br/><small>z %s</small>" + + Wyślij zdjęcia + Wyślij zdjęcia + Wyślij zdjęcia + Wyślij zdjęcie + + + Wyślij pliki audio + Wyślij pliki audio + Wyślij pliki audio + Wyślij plik audio + + + Wyślij filmy + Wyślij filmy + Wyślij filmy + Wyślij film + + + Wyślij wizytówki + Wyślij wizytówki + Wyślij wizytówki + Wyślij wizytówkę + + + Wyślij załączniki + Wyślij załączniki + Wyślij załączniki + Wyślij załącznik + + + %d załączniki gotowe do wysłania + %d załączników gotowych do wysłania + %d załącznika gotowe do wysłania + Jeden załącznik gotowy do wysłania + + "Prześlij opinię" + "Zobacz w Sklepie Google Play" + "Informacje o wersji" + "Wersja %1$s" + "Licencje open source" + "Powiadomienia" + "Osiągnięto limit załączników" + "Nie udało się załadować załącznika." + "Dodać do kontaktów?" + "Dodaj kontakt" + "Temat" + "Temat: " + "%s%s" + "Ładowanie wizytówki" + "Nie można załadować wizytówki" + "Wyświetl wizytówkę" + + %d kontakty + %d kontaktów + %d kontaktu + %d kontakt + + "Wizytówki" + "Urodziny" + "Notatki" + "Przekaż wiadomość dalej" + "Odpowiedz" + "+1" + "+%d" + "SMS-y wyłączone" + "Aby wysyłać wiadomości, ustaw SMS/MMS jako domyślną aplikację do SMS-ów" + "Ustaw SMS/MMS jako domyślną aplikację do SMS-ów" + "Zmień" + "Aby odbierać wiadomości, ustaw SMS/MMS jako domyślną aplikację do SMS-ów" + "Nie wybrano preferowanej karty SIM do wysyłania SMS-ów" + "Właściciel urządzenia nie zezwolił na działanie tej aplikacji." + "OK" + "Zbyt wielu uczestników rozmowy" + + Nieprawidłowe kontakty + Nieprawidłowe kontakty + Nieprawidłowe kontakty + Nieprawidłowy kontakt + + "Nie udało się załadować zdjęcia z aparatu" + "Ty: " + "%s: " + "Wersja robocza" + "Po rozpoczęciu nowej rozmowy zobaczysz ją tutaj" + "Tutaj będą widoczne zarchiwizowane wątki" + "Ładuję wątki…" + "Zdjęcie" + "Klip dźwiękowy" + "Film" + "Wizytówka" + "MMS" + "Cofnij" + "Ponów" + "Wpisz nazwę kontaktu lub numer telefonu, by rozpocząć nową wiadomość" + "Zablokuj" + "Zablokuj: %s" + "Odblokuj: %s" + "Zablokować %s?" + "Będziesz nadal otrzymywać wiadomości z tego numeru, ale nie będzie już powiadomień o nich. Ten wątek zostanie zarchiwizowany." + "Zablokowane kontakty" + "ODBLOKUJ" + "Zablokowane kontakty" + "Wybierz obraz z biblioteki dokumentów" + "Wysyłam wiadomość" + "Wiadomość wysłana" + "Komórkowa transmisja danych jest wyłączona. Sprawdź ustawienia." + "W trybie samolotowym nie można wysyłać wiadomości" + "Nie można wysłać wiadomości" + "Wiadomość pobrana" + "Komórkowa transmisja danych jest wyłączona. Sprawdź ustawienia." + "Wiadomości nie można pobierać w trybie samolotowym" + "Nie udało się pobrać wiadomości" + "Zero" + "Jeden" + "Dwa" + "Trzy" + "Cztery" + "Pięć" + "Sześć" + "Siedem" + "Osiem" + "Dziewięć" + "Nie można wysłać wiadomości w sieci %1$s (błąd %2$d)" + "Nie można wysłać wiadomości w sieci nieznanego operatora (błąd %1$d)" + "PD: %s" + "Nie wysłano wiadomości: usługa nie została aktywowana w sieci" + "Wiadomość nie została wysłana: nieprawidłowy adres docelowy" + "Wiadomość nie została wysłana: nieprawidłowa wiadomość" + "Wiadomość nie została wysłana: nieobsługiwane treści" + "Wiadomość nie została wysłana: nieobsługiwana wiadomość" + "Wiadomość nie została wysłana: jest za duża" + "Nowa wiadomość" + "Wyświetl" + "Obraz" + "Nie udało się znaleźć stosownej aplikacji" + "Usuń odbiorcę" + "Nowa wiadomość" + "Anuluj" + "Edytuj punkt dostępu" + "Nie ustawiono" + "Nazwa" + "APN" + "MMSC" + "Proxy dla MMS" + "Port MMS" + "MCC" + "MNC" + "Typ APN" + "Usuń APN" + "Nowy APN" + "Zapisz" + "Odrzuć" + "Pole Nazwa nie może być puste." + "Nazwa APN nie może być pusta." + "Pole MCC musi zawierać 3 cyfry." + "Pole MNC musi zawierać 2 lub 3 cyfry." + "Przywracam domyślne ustawienia APN." + "Przywróć domyślne" + "Przywrócono domyślne ustawienia APN." + "Bez nazwy" + "Nazwy punktów dostępu" + "APN-y" + "Nowy APN" + "Ustawienia nazwy punktu dostępu są niedostępne dla tego użytkownika" + "Skopiować do schowka?" + "Kopiuj" + "do %s" + "Ogólne" + "Zaawansowane" + "Ustawienia ogólne" + "Ustawienia zaawansowane" + "Karta SIM „%s”" + "Wyślij osobne SMS-y do wszystkich odbiorców. Odpowiedzi trafią tylko do Ciebie" + "Wysyłaj jednego MMS-a do wszystkich odbiorców" + "Nieznany numer" + "Nowa wiadomość" + "Nowa wiadomość." + "Przełącznik SIM" + "Przełącznik SIM, wybrana karta: %1$s" + "Edytuj temat" + "Wybierz SIM lub edytuj temat" + "Dotknij i przytrzymaj, aby nagrać dźwięk" + "Zacznij nową rozmowę" + "Wiadomości" + "Lista wiadomości" + "Wiadomości" + "Nowa wiadomość" + "Lista wątków" + "Ładuję wątki" + "Wczytuję wiadomości" + "Wyświetl więcej wątków" + "Wyświetl więcej wiadomości" + "Wątek został usunięty" + "Rozmowa usunięta. Kliknij, by zobaczyć inną rozmowę w aplikacji SMS/MMS" + "Zablokowano" + "Odblokowano" + "Mała ilość miejsca. Część danych może zostać utracona." + "Wybierz załączniki" + "Potwierdź wybór" + "Wybrano %d" + "Usuń co najmniej jeden załącznik i spróbuj ponownie." + "Możesz spróbować wysłać wiadomość, ale może ona nie zostać dostarczona, dopóki nie usuniesz przynajmniej jednego załącznika." + "W jednej wiadomości możesz wysłać tylko jeden film. Usuń dodatkowe filmy i spróbuj ponownie." + "Nie udało się załadować załącznika w aplikacji SMS/MMS." + "Wyślij mimo wszystko" + "Nie udało się rozpocząć rozmowy" + "%1$s (%2$s)" + "Wybrano %s" + diff --git a/res/values-pt-rPT/arrays.xml b/res/values-pt-rPT/arrays.xml new file mode 100644 index 0000000..3f99811 --- /dev/null +++ b/res/values-pt-rPT/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "sem assunto" + "sem assunto" + + + "Sim" + "Não" + "OK" + "Eh eh" + "Obrigado" + "Aceito" + "Boa" + "Estou a caminho" + "OK, ligo mais tarde" + ":)" + ":(" + + diff --git a/res/values-pt-rPT/strings.xml b/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000..e546031 --- /dev/null +++ b/res/values-pt-rPT/strings.xml @@ -0,0 +1,519 @@ + + + + + "Mensagens" + "Mensagens" + "Selecionar conversa" + "Definições" + "Enviar mensagem" + "Adicionar um anexo" + "Ajuda" + "Bem-vindo" + "Ignorar" + "Seguinte >" + "Seguinte" + "Sair" + "Definições >" + "Definições" + "A aplicação Mensagens necessita de autorização para SMS, Telefone e Contactos." + "Pode alterar as autorizações em Definições > Aplicações > Mensagens > Autorizações." + "Pode alterar as autorizações em Definições, Aplicações, Mensagens, Autorizações." + "Frequentes" + "Todos os contactos" + "Enviar para %s" + "Capturar imagens ou vídeo" + "Escolher imagens deste dispositivo" + "Gravar áudio" + "Escolher fotografia" + "O ficheiro multimédia está selecionado." + "O ficheiro multimédia está desselecionado." + "%d selecionado(s)" + "imagem de %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "imagem" + "Gravar áudio" + "Partilhar" + "Agora mesmo" + "Agora" + + %d min + %d min + + + %d horas + %d hora + + + %d dias + %d dia + + + %d semanas + uma semana + + + %d meses + um mês + + + %d anos + um ano + + "Mensagem de classe 0" + "Guardar" + "O dispositivo tem pouco espaço disponível. A aplicação Mensagens elimina automaticamente mensagens mais antigas para libertar espaço." + "Está quase sem espaço de armazenamento" + "A aplicação Mensagens pode não enviar ou receber mensagens até que exista mais espaço disponível no dispositivo." + "Armazenamento SMS reduzido. Pode ter de eliminar mensagens." + "Confirmar número de telefone" + "Este passo único assegura que a aplicação Mensagens fornece as suas mensagens de grupo corretamente." + "Número de telefone" + "Eliminar todas as mensagens com multimédia" + "Eliminar mensagens com mais de %s" + "Eliminar automaticamente mensagens com mais de %s" + "Ignorar" + "Eliminar todas as mensagens com multimédia?" + "Eliminar mensagens com mais de %s?" + "Eliminar mensagens com mais de %s e ativar eliminação automática?" + "%s disse" + "Você disse" + "Mensagem de %s" + "Enviou uma mensagem" + "A enviar..." + "Não enviado. Toque para tentar novamente." + "Não enviado. A tentar novamente..." + "Reenviar ou eliminar" + "Faça uma chamada de voz para os serviços de emergência. Neste momento, não foi possível entregar a sua mensagem de texto." + "Falha" + "Nova mensagem MMS para transferir" + "Nova mensagem MMS" + "Não foi possível transferir" + "Toque para tentar novamente" + "Toque para transferir" + "Transferir ou eliminar" + "A transferir..." + "Mensagem expirada ou não disponível" + "tamanho: %1$s, data de validade: %2$s" + "Não é possível enviar. O destinatário não é válido." + "Serviço não ativado na rede" + "Não foi possível enviar devido a um problema de rede" + "Mensagem expirada ou não disponível" + "(Sem assunto)" + "Remetente desconhecido" + "Entregue" + "Não foi possível transferir a mensagem %1$s de %2$s." + "Não foi possível concluir a operação da base de dados devido a pouca memória" + "Mensagem não enviada" + "Algumas mensagens não enviadas na aplicação Mensagens" + + %d mensagens em %d conversas + %d mensagens numa conversa + + "Mensagem não transferida" + "Algumas mensagens não transferidas na aplicação Mensagens" + + %d mensagens em %d conversas + %d mensagens numa conversa + + "Mensagem para %1$s não enviada" + "Faça uma chamada de voz para os serviços de emergência. Neste momento, não foi possível entregar a sua mensagem de texto para o número %1$s." + + %d novas mensagens + Nova mensagem + + "Iniciar" + "Câmara não disponível" + "Câmara não disponível" + "Captura de vídeo não disponível" + "Não é possível guardar multimédia" + "Não é possível tirar a fotografia" + "Anterior" + "Arquivadas" + "Eliminar" + "Arquivar" + "Retirar do arquivo" + "Desativar as notificações" + "Ativar notificações" + "Adicionar contacto" + "Transferir" + "Enviar" + "Eliminar" + "Pretende eliminar esta mensagem?" + "Esta ação não pode ser anulada." + "Eliminar" + + Pretende eliminar estas conversas? + Pretende eliminar esta conversa? + + "Eliminar" + "Cancelar" + "Para" + "Selecionar várias imagens" + "Confirmar seleção" + "+%d" + "Não é possível gravar áudio. Tente novamente." + "Não é possível reproduzir áudio. Tente novamente." + "Não foi possível guardar áudio. Tente novamente." + "Toque sem largar" + ", " + " " + ": " + " " + "Imagem" + "Clipe de áudio" + "Vídeo" + "Cartão de contacto" + "Transferir" + "Responder por SMS" + "Responder por MMS" + "Responder" + + %d participantes + %d participante + + "Eu" + "Contacto bloqueado e arquivado" + "Contacto desbloqueado e não arquivado" + "%d arquivada(s)" + "%d não arquivada(s)" + "Notificações desativadas" + "Notificações ativadas" + "Tudo definido. Toque novamente em Enviar." + "A aplicação Mensagens foi definida com êxito como a aplicação de SMS predefinida." + + Rejeitar anexos + Rejeitar anexo + + "Anexo de áudio" + "Reproduzir anexo de áudio" + "Interromper" + "Mensagem de %s: %s." + "Mensagem falhada de %s: %s. Hora: %s." + "Mensagem de %s: %s. Hora: %s." + "Mensagem não enviada para %s: %s. Hora: %s." + "A enviar mensagem para %s: %s. Hora: %s." + "Mensagem falhada para %s: %s. Hora: %s." + "Mensagem para %s: %s. Hora: %s." + "Mensagem falhada de %s: %s. Hora: %s. %s." + "Mensagem de %s: %s. Hora: %s. %s." + "Mensagem não enviada para %s: %s. Hora: %s." + "A enviar mensagem para %s: %s. Hora: %s." + "Mensagem falhada para %s: %s. Hora: %s." + "Mensagem para %s: %s. Hora: %s." + "Mensagem falhada. Toque para tentar novamente." + "Conversa com %s" + "Eliminar assunto" + "Capturar vídeo" + "Capturar uma imagem fixa" + "Tirar fotografia" + "Começar a gravar vídeo" + "Mudar para a câmara de ecrã completo" + "Alternar entre as câmaras frontal e traseira" + "Parar gravação e anexar vídeo" + "Parar a gravação de vídeo" + "Fotos das Mensagens" + + %d fotos guardadas no álbum \"%s\" + %d foto guardada no álbum \"%s\" + + + %d vídeos guardados no álbum \"%s\" + %d vídeo guardado no álbum \"%s\" + + + %d anexos guardados no álbum \"%s\" + %d anexo guardado no álbum \"%s\" + + + %d anexos guardados em \"Transferências\" + %d anexo guardado em \"Transferências\" + + + %d anexos guardados + %d anexo guardado + + + Não foi possível guardar %d anexos + Não foi possível guardar %d anexo + + "Anexo de MMS guardado" + "Definições" + "Arquivadas" + "Fechar" + "MMS" + "Avançadas" + "Depurar" + "Notificações" + "Som" + "Silencioso" + "Vibrar" + "Bloqueado" + "Relatórios de entrega de SMS" + "Solicitar um relatório de entrega para cada SMS enviada" + "Recuperação automática" + "Recuperar automaticamente mensagens MMS" + "Recuperar automat. em roaming" + "Obter automaticamente MMS em roaming" + "Mensagens de grupo" + "Utilizar MMS para enviar uma única mensagem quando houver vários destinatários" + "Aplicação de SMS predefinida" + "Aplicação de SMS predefinida" + + + "O seu número de telefone" + "Desconhecido" + "Sons de mensagem enviada" + "Criar cópia de segurança de SMS" + "A cópia de segurança recebeu dados SMS não processados no ficheiro de armazenamento externo" + "Criar cópia de segurança de MMS" + "A cópia de segurança recebeu dados MMS não processados no ficheiro de armazenamento externo" + "Alertas sem fios" + "Opções da mensagem" + "Copiar texto" + "Ver detalhes" + "Eliminar" + "Encaminhar" + "Detalhes da mensagem" + "Tipo: " + "Mensagem de texto" + "Mensagem multimédia" + "De: " + "Para: " + "Enviadas: " + "Recebidas: " + "Assunto: " + "Tamanho: " + "Prioridade: " + "SIM: " + "Alta" + "Normal" + "Baixa" + "SIM %s" + "Endereço do remetente oculto" + "Não é possível enviar a mensagem enquanto os anexos estiverem a ser carregados." + "Não é possível carregar o anexo. Tente novamente." + "A rede não está pronta. Tente novamente." + "Eliminar texto" + "Alternar entre a introdução de texto e números" + "Adicionar mais participantes" + "Confirmar participantes" + "Iniciar nova conversa" + "Selecionar este item" + "Reproduzir vídeo" + "Pessoas e opções" + "Depurar" + "Pessoas e opções" + "Geral" + "Pessoas nesta conversa" + "Efetuar uma chamada" + "Enviar mensagem" + "Enviar mensagem<br/><small>de %s</small>" + + Enviar fotos + Enviar foto + + + Enviar ficheiros de áudio + Enviar ficheiro de áudio + + + Enviar vídeos + Enviar vídeo + + + Enviar cartões de contacto + Enviar cartão de contacto + + + Enviar anexos + Enviar anexo + + + %d anexos prontos a enviar + Um anexo pronto a enviar + + "Enviar comentários" + "Ver na Google Play Store" + "Informações da versão" + "Versão %1$s" + "Licenças de código aberto" + "Notificações" + "Foi atingido o limite de anexos" + "Falha ao carregar o anexo." + "Adicionar a Contactos?" + "Adicionar contacto" + "Assunto" + "Assunto: " + "%s%s" + "A carregar o cartão de contacto" + "Não foi possível carregar o cartão de contacto" + "Ver cartão de contacto" + + %d contactos + %d contacto + + "Cartões de contacto" + "Aniversário" + "Notas" + "Encaminhar mensagem" + "Responder" + "+1" + "+%d" + "SMS desativado" + "Para enviar, defina a aplicação Mensagens como a aplicação de SMS predefinida" + "Defina a aplicação Mensagens como a aplicação de SMS predefinida" + "Alterar" + "Para receber mensagens, defina a aplicação Mensagens como a aplicação de SMS predefinida" + "Nenhum SIM preferido selecionado para o envio de mensagens SMS" + "Esta aplicação não é permitida pelo proprietário do dispositivo." + "OK" + "Demasiados participantes numa conversa" + + Contactos inválidos + Contacto inválido + + "Não foi possível carregar a imagem da câmara" + "Utilizador: " + "%s: " + "Rascunho" + "Ao iniciar uma nova conversa, esta será listada aqui" + "As conversas arquivadas aparecem aqui" + "A carregar conversações..." + "Imagem" + "Clipe de áudio" + "Vídeo" + "Cartão de contacto" + "MMS" + "Anular" + "Tentar novamente" + "Introduza um nome ou número de telefone do contacto para iniciar uma nova mensagem" + "Bloquear" + "Bloquear %s" + "Desbloquear %s" + "Pretende bloquear %s?" + "O utilizador continuará a receber mensagens deste número, mas deixará de ser notificado. Esta conversa será arquivada." + "Contactos bloqueados" + "DESBLOQUEAR" + "Contactos bloqueados" + "Escolher imagem da biblioteca de documentos" + "A enviar mensagem" + "Mensagem enviada" + "Os dados celulares estão desligados. Verifique as suas definições." + "Não é possível enviar mensagens no modo de avião" + "Não foi possível enviar a mensagem" + "Mensagem transferida" + "Os dados celulares estão desligados. Verifique as suas definições." + "Não é possível transferir mensagens no modo de avião" + "Não foi possível transferir a mensagem" + "Zero" + "Um" + "Dois" + "Três" + "Quatro" + "Cinco" + "Seis" + "Sete" + "Oito" + "Nove" + "Não é possível enviar a mensagem com %1$s, erro %2$d" + "Não é possível enviar a mensagem com operador desconhecido, erro %1$d" + "Enc: %s" + "Mensagem não enviada: serviço não ativado na rede" + "Mensagem não enviada: endereço de destino inválido" + "Mensagem não enviada: mensagem inválida" + "Mensagem não enviada: conteúdo não suportado" + "Mensagem não enviada: a mensagem não é compatível" + "Mensagem não enviada: demasiado grande" + "Nova mensagem" + "Ver" + "Imagem" + "Não foi possível encontrar uma aplicação adequada" + "Remover o destinatário" + "Nova mensagem" + "Cancelar" + "Editar ponto de acesso" + "Não definido" + "Nome" + "APN" + "MMSC" + "Proxy de MMS" + "Porta MMS" + "MCC" + "MNC" + "Tipo de APN" + "Eliminar APN" + "Novo APN" + "Guardar" + "Rejeitar" + "O campo Nome não pode estar vazio." + "O APN não pode estar vazio." + "O campo MCC tem de ter 3 dígitos." + "O campo MNC tem de ter 2 ou 3 dígitos." + "A restaurar as predefinições do APN" + "Repor as predefinições" + "Reposição das predefinições do APN concluída." + "Sem nome" + "Nomes dos Pontos de Acesso" + "APNs" + "Novo APN" + "As definições de Nome do Ponto de Acesso não estão disponíveis para este utilizador" + "Pretende copiar para a área de transferência?" + "Copiar" + "em %s" + "Geral" + "Avançadas" + "Definições gerais" + "Definições avançadas" + "SIM \"%s\"" + "Enviar mensagens SMS individuais para todos os destinatários. Apenas o utilizador recebe as respostas" + "Enviar uma única MMS para todos os destinatários" + "Número desconhecido" + "Nova mensagem" + "Nova mensagem." + "Seletor de SIM" + "%1$s selecionado, seletor de SIM" + "Editar assunto" + "Selecionar SIM ou editar assunto" + "Tocar sem soltar para gravar áudio" + "Iniciar nova conversa" + "Mensagens" + "Lista de mensagens" + "Mensagens" + "Nova mensagem" + "Lista de conversas" + "A carregar conversas" + "A carregar mensagens" + "Ver mais conversas" + "Ver mais mensagens" + "Conversa eliminada" + "Conversa eliminada. Toque para mostrar uma conversa das Mensagens diferente" + "Bloqueado" + "Desbloqueado" + "O espaço de armazenamento é reduzido. Alguns dados podem ser perdidos." + "Selecionar anexos" + "Confirmar seleção" + "%d selecionado(s)" + "Remova um ou mais anexos e tente novamente." + "Pode tentar enviar a sua mensagem, mas esta pode não ser entregue se não remover um ou mais anexos." + "Apenas pode enviar um vídeo por mensagem. Remova os vídeos adicionais e tente novamente." + "Falha da aplicação Mensagens ao carregar o anexo." + "Enviar mesmo assim" + "Não foi possível iniciar a conversa" + "%1$s (%2$s)" + "%s selecionado" + diff --git a/res/values-pt/arrays.xml b/res/values-pt/arrays.xml new file mode 100644 index 0000000..0ab0334 --- /dev/null +++ b/res/values-pt/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "sem assunto" + "nosubject" + + + "Sim" + "Não" + "Ok" + "Hehe" + "Obrigado" + "Concordo" + "Legal" + "Estou a caminho" + "OK, falo com você mais tarde" + ":)" + ":(" + + diff --git a/res/values-pt/strings.xml b/res/values-pt/strings.xml new file mode 100644 index 0000000..c9fdfad --- /dev/null +++ b/res/values-pt/strings.xml @@ -0,0 +1,519 @@ + + + + + "Mensagens" + "Mensagens" + "Selecionar uma conversa" + "Configurações" + "Enviar mensagem" + "Adicionar um anexo" + "Ajuda" + "Bem-vindo" + "Pular" + "Próxima >" + "Próximo" + "Sair" + "Configurações >" + "Configurações" + "O app Mensagens precisa de permissão para SMS, telefone e contatos." + "É possível alterar as permissões em \"Config. > Apps > Mensagens > Permissões\"." + "É possível alterar as permissões em \"Config. > Apps > Mensagens > Permissões\"." + "Frequentes" + "Todos os contatos" + "Enviar para %s" + "Capturar fotos ou vídeos" + "Escolher imagens do dispositivo" + "Gravar áudio" + "Escolher foto" + "A mídia foi selecionada." + "A mídia foi desmarcada." + "%d selecionado" + "imagem %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "imagem" + "Gravar áudio" + "Compartilhar" + "Há pouco" + "Agora" + + %d min + %d min + + + %d horas + %d horas + + + %d dias + %d dias + + + %d semanas + %d semanas + + + %d meses + %d meses + + + %d anos + %d anos + + "Mensagem classe 0" + "Salvar" + "O dispositivo está com pouco espaço. As mensagens mais antigas serão excluídas automaticamente para liberar espaço." + "Pouco espaço de armazenamento" + "O app Mensagens talvez não envie ou receba mensagens até que mais espaço esteja disponível no seu dispositivo." + "Pouco espaço para SMS. Pode ser necessário excluir mensagens." + "Confirme seu número de telefone" + "Esta etapa única garantirá que o app Mensagens entregue suas mensagens em grupo corretamente." + "Número de telefone" + "Excluir todas as mensagens com mídia" + "Excluir mensagens com mais de %s" + "Excluir automaticamente mensagens com mais de %s" + "Ignorar" + "Excluir todas as mensagens com mídia?" + "Excluir mensagens com mais de %s?" + "Excluir mensagens com mais de %s e ativar a exclusão automática?" + "%s disse" + "Você disse" + "Mensagem de %s" + "Você enviou uma mensagem" + "Enviando…" + "Não enviado. Toque para tentar novamente." + "Não enviado. Tentando novamente…" + "Reenviar ou excluir" + "Faça uma chamada de voz para os serviços de emergência. Não foi possível entregar sua mensagem de texto no momento." + "Falhou" + "Nova mensagem MMS para fazer o download" + "Nova mensagem MMS" + "Falha no download" + "Tocar para tentar novamente" + "Tocar para fazer o download" + "Fazer o download ou excluir" + "Fazendo o download…" + "A mensagem expirou ou não está disponível" + "tamanho: %1$s, validade: %2$s" + "Falha ao enviar. Destinatário inválido." + "Serviço não ativado na rede" + "Falha ao enviar devido a um problema de rede" + "A mensagem expirou ou não está disponível" + "(Sem assunto)" + "Remetente desconhecido" + "Entregue" + "Não foi possível fazer o download da mensagem %1$s de %2$s." + "Não foi possível concluir a operação do banco de dados devido a memória insuficiente" + "Mensagem não enviada" + "Algumas mensagens não foram enviadas no app Mensagens" + + %d mensagens em %d conversas + %d mensagens em %d conversas + + "Download da mensagem não realizado" + "Não foi feito o download de algumas mensagens no app Mensagens" + + %d mensagens em %d conversas + %d mensagens em %d conversas + + "Mensagem para %1$s não enviada" + "Faça uma chamada de voz para os serviços de emergência. Não foi possível entregar sua mensagem de texto para %1$s no momento." + + %d novas mensagens + %d novas mensagens + + "Iniciar" + "Câmera não disponível" + "Câmera não disponível" + "Captura de vídeo não disponível" + "Falha ao salvar a mídia" + "Não é possível tirar uma foto" + "Voltar" + "Arquivadas" + "Excluir" + "Arquivar" + "Desarquivar" + "Desativar notificações" + "Ativar notificações" + "Adicionar contato" + "Fazer o download" + "Enviar" + "Excluir" + "Excluir esta mensagem?" + "Esta ação não pode ser desfeita." + "Excluir" + + Excluir estas conversas? + Excluir estas conversas? + + "Excluir" + "Cancelar" + "Para" + "Selecionar várias imagens" + "Confirmar seleção" + "+%d" + "Falha ao gravar o áudio. Tente novamente." + "Falha ao reproduzir o áudio. Tente novamente." + "Falha ao salvar o áudio. Tente novamente." + "Toque e mantenha pressionado" + ", " + " " + ": " + " " + "Imagem" + "Clipe de áudio" + "Vídeo" + "Cartão de visita" + "Download" + "Responder via SMS" + "Responder via MMS" + "Responder" + + %d participantes + %d participantes + + "Eu" + "Contato bloqueado e arquivado" + "Contato desbloqueado e desarquivado" + "%d arquivada(s)" + "%d desarquivada(s)" + "Notificações desativadas" + "Notificações ativadas" + "Tudo pronto. Toque em \"Enviar\" novamente." + "O app Mensagens foi definido como o app padrão de SMS." + + Descartar anexos + Descartar anexos + + "Anexo de áudio" + "Reproduzir anexo de áudio" + "Pausa" + "Mensagem de %s: %s." + "Falha na mensagem de %s: %s. Hora: %s." + "Mensagem de %s: %s. Hora: %s." + "Mensagem não enviada para %s: %s. Hora: %s." + "Enviando mensagem para %s: %s. Hora: %s." + "Falha na mensagem para %s: %s. Hora: %s." + "Mensagem para %s: %s. Hora: %s." + "Falha na mensagem de %s: %s. Hora: %s. %s." + "Mensagem de %s: %s. Hora: %s. %s." + "Mensagem não enviada para %s: %s. Hora: %s." + "Enviando mensagem para %s: %s. Hora: %s." + "Falha na mensagem para %s: %s. Hora: %s." + "Mensagem para %s: %s. Hora: %s." + "Falha na mensagem. Toque para tentar novamente." + "Conversa com %s" + "Excluir assunto" + "Capturar vídeo" + "Capturar uma imagem estática" + "Tirar foto" + "Iniciar gravação de vídeo" + "Alternar para câmera em tela cheia" + "Alternar entre câmera frontal e traseira" + "Parar a gravação e anexar vídeo" + "Parar gravação de vídeo" + "Fotos do app Mensagens" + + %d fotos salvas no álbum \"%s\" + %d fotos salvas no álbum \"%s\" + + + %d vídeos salvos no álbum \"%s\" + %d vídeos salvos no álbum \"%s\" + + + %d anexos salvos no álbum \"%s\" + %d anexos salvos no álbum \"%s\" + + + %d anexos salvos em \"Downloads\" + %d anexos salvos em \"Downloads\" + + + %d anexos salvos + %d anexos salvos + + + Não foi possível salvar %d anexos + Não foi possível salvar %d anexos + + "Anexo MMS salvo" + "Configurações" + "Arquivadas" + "Fechar" + "MMS" + "Avançadas" + "Depurar" + "Notificações" + "Som" + "Silencioso" + "Vibração" + "Bloqueado" + "Relat. de entrega de SMS" + "Solicitar um relatório de entrega para cada SMS enviado" + "Recuperação automática" + "Recuperar mensagens MMS automaticamente" + "Recup. autom. em roaming" + "Recuperar MMS automaticamente durante roaming" + "Mensagens em grupo" + "Usar MMS para enviar uma única mensagem para vários destinatários" + "App de SMS padrão" + "App de SMS padrão" + + + "Seu número de telefone" + "Desconhecido" + "Sons de mensagem de saída" + "Descartar SMS" + "Descartar dados brutos de SMS recebidos no arquivo de armazenamento externo" + "Descartar MMS" + "Descartar dados brutos de MMS recebidos no arquivo de armazenamento externo" + "Alertas sem fio" + "Opções de mensagem" + "Copiar texto" + "Ver detalhes" + "Excluir" + "Avançar" + "Detalhes da mensagem" + "Tipo: " + "Mensagem de texto" + "Mensagem multimídia" + "De: " + "Para: " + "Enviada: " + "Recebida: " + "Assunto: " + "Tamanho: " + "Prioridade: " + "SIM: " + "Alta" + "Normal" + "Baixa" + "SIM %s" + "Endereço do remetente oculto" + "Não é possível enviar mensagens ao carregar anexos." + "Falha ao carregar o anexo. Tente novamente." + "A rede não está pronta. Tente novamente." + "Excluir texto" + "Alternar entre entrada de texto ou números" + "Adicionar mais participantes" + "Confirmar participantes" + "Iniciar nova conversa" + "Selecione este item" + "Reproduzir vídeo" + "Pessoas e opções" + "Depurar" + "Pessoas e opções" + "Gerais" + "Pessoas nesta conversa" + "Fazer uma chamada" + "Enviar mensagem" + "Enviar mensagem<br/><small>de %s</small>" + + Enviar fotos + Enviar fotos + + + Enviar áudios + Enviar áudios + + + Enviar vídeos + Enviar vídeos + + + Enviar cards de contato + Enviar cards de contato + + + Enviar anexos + Enviar anexos + + + %d anexos prontos para envio + %d anexos prontos para envio + + "Enviar feedback" + "Ver na Google Play Store" + "Informações da versão" + "Versão %1$s" + "Licenças de código aberto" + "Notificações" + "Limite de anexos alcançado" + "Falha ao carregar anexo." + "Adicionar a Contatos?" + "Adicionar contato" + "Assunto" + "Assunto: " + "%s%s" + "Carregando cartão de visita" + "Falha ao carregar o cartão de visita" + "Ver cartão de visita" + + %d contatos + %d contatos + + "Cartões de visita" + "Aniversário" + "Notas" + "Encaminhar mensagem" + "Responder" + "+1" + "+%d" + "SMS desativado" + "Para enviar, defina o Mensagens como app padrão de SMS" + "Definir o Mensagens como o app padrão de SMS" + "Alterar" + "Para receber mensagens, defina o Mensagens como app padrão de SMS" + "Nenhum SIM preferido para o envio de mensagens SMS selecionado" + "Este app não é permitido pelo proprietário do dispositivo." + "Ok" + "Muitos participantes em uma conversa" + + Contatos inválidos + Contatos inválidos + + "Falha ao carregar a imagem da câmera" + "Você: " + "%s: " + "Rascunho" + "Quando uma nova conversa for iniciada, será listada aqui" + "As conversas arquivadas aparecem aqui" + "Carregando conversas…" + "Imagem" + "Clipe de áudio" + "Vídeo" + "Cartão de visita" + "MMS" + "Desfazer" + "Repetir" + "Digite o nome ou telefone de um contato para iniciar uma nova mensagem" + "Bloquear" + "Bloquear %s" + "Desbloquear %s" + "Bloquear %s?" + "Você continuará recebendo mensagens deste número, mas não será notificado. Esta conversa será ativada." + "Contatos bloqueados" + "DESBLOQUEAR" + "Contatos bloqueados" + "Escolher imagem da biblioteca de documentos" + "Enviando mensagem" + "Mensagem enviada" + "Dados da rede celular desativados. Verifique suas configurações." + "Não é possível enviar mensagens no modo avião" + "Não foi possível enviar a mensagem" + "Mensagem baixada" + "Dados da rede celular desativados. Verifique suas configurações." + "Não é possível fazer o download de mensagens no modo Avião" + "Não foi possível fazer o download da mensagem" + "Zero" + "Um" + "Dois" + "Três" + "Quatro" + "Cinco" + "Seis" + "Sete" + "Oito" + "Nove" + "Não é possível enviar a mensagem com %1$s. Erro: %2$d" + "Não é possível enviar a mensagem com operadora desconhecida. Erro: %1$d" + "Enc: %s" + "Mensagem não enviada: serviço não ativado na rede" + "Mensagem não enviada: endereço de destino inválido" + "Mensagem não enviada: mensagem inválida" + "Mensagem não enviada: conteúdo não compatível" + "Mensagem não enviada: mensagem não compatível" + "Mensagem não enviada: muito grande" + "Nova mensagem" + "Visualizar" + "Imagem" + "Não foi possível encontrar um app adequado" + "Remover destinatário" + "Nova mensagem" + "Cancelar" + "Editar ponto de acesso" + "Não definido" + "Nome" + "APN" + "MMSC" + "Proxy MMS" + "Porta MMS" + "MCC" + "MNC" + "Tipo de APN" + "Excluir APN" + "Novo APN" + "Salvar" + "Descartar" + "O campo Nome não pode ficar vazio." + "O APN não pode estar vazio." + "O campo MCC deve ter 3 dígitos." + "O campo MNC deve ter 2 ou 3 dígitos." + "Restaurando as configurações APN padrão." + "Redefinir para o padrão" + "Redefinição das configurações padrão do APN concluída." + "Sem título" + "Nomes dos pontos de acesso" + "APNs" + "Novo APN" + "As configurações do Nome do ponto de acesso não estão disponíveis para este usuário" + "Copiar para área de transferência?" + "Copiar" + "para %s" + "Gerais" + "Avançadas" + "Configurações gerais" + "Configurações avançadas" + "SIM \"%s\"" + "Enviar mensagens SMS individuais para todos os destinatários. Só você receberá respostas" + "Enviar uma só mensagem MMS para todos os destinatários" + "Número desconhecido" + "Nova mensagem" + "Nova mensagem." + "Seletor de SIM" + "%1$s selecionado, seletor de SIM" + "Editar assunto" + "Selecione o SIM ou edite o assunto" + "Toque e segure para gravar áudio" + "Iniciar nova conversa" + "Mensagens" + "Lista de mensagens" + "Mensagens" + "Nova mensagem" + "Lista de conversas" + "Carregando conversas" + "Carregando mensagens" + "Ver mais conversas" + "Ver mais mensagens" + "Conversa excluída" + "Conversa excluída. Toque para mostrar outra conversa do app Mensagens" + "Contato bloqueado" + "Contato desbloqueado" + "O espaço de armazenamento está baixo. Alguns dados podem ser perdidos." + "Selecionar anexos" + "Confirmar seleção" + "%d selecionados" + "Remova um ou mais anexos e tente novamente." + "Tente enviar sua mensagem. Se ela não for entregue, remova um ou mais anexos." + "Você só pode enviar um vídeo por mensagem. Remova os vídeos adicionais e tente novamente." + "Ocorreu uma falha no app Mensagens ao carregar o anexo." + "Enviar mesmo assim" + "Não foi possível iniciar a conversa" + "%1$s (%2$s)" + "%s selecionado" + diff --git a/res/values-ro/arrays.xml b/res/values-ro/arrays.xml new file mode 100644 index 0000000..dc5e3fe --- /dev/null +++ b/res/values-ro/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "fără subiect" + "nosubject" + + + "Da" + "Nu" + "OK" + "Hehe" + "Mulțumesc" + "Sunt de acord" + "Frumos" + "Sunt pe drum" + "OK, revin mai târziu" + ":)" + ":(" + + diff --git a/res/values-ro/strings.xml b/res/values-ro/strings.xml new file mode 100644 index 0000000..25b2d8c --- /dev/null +++ b/res/values-ro/strings.xml @@ -0,0 +1,545 @@ + + + + + "Mesaje" + "Mesaje" + "Selectați conversația" + "Setări" + "Trimiteți mesajul" + "Adăugați un atașament" + "Ajutor" + "Bun venit" + "Omiteți" + "Înainte >" + "Înainte" + "Ieșiți" + "Setări >" + "Setări" + "Mesageria necesită permisiuni la SMS, Telefon și Agendă." + "Puteți să schimbați permisiunile din Setări > Aplicații > Mesagerie > Permisiuni." + "Puteți să schimbați permisiunile din Setări, Aplicații, Mesagerie, Permisiuni." + "Folosite frecvent" + "Toată agenda" + "Trimiteți la %s" + "Faceți fotografii sau înregistrați videoclipuri" + "Alegeți imagini de pe acest dispozitiv" + "Înregistrați conținut audio" + "Alegeți o fotografie" + "Conținutul media este selectat." + "Conținutul media este deselectat." + "Ați selectat %d" + "imagine din %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "imagine" + "Înregistrați conținut audio" + "Trimiteți" + "Adineauri" + "Acum" + + %d minute + %d de minute + %d minut + + + %d ore + %d de ore + %d oră + + + %d zile + %d de zile + %d zi + + + %d săptămâni + %d de săptămâni + o săptămână + + + %d luni + %d de luni + o lună + + + %d ani + %d de ani + un an + + "Mesaje clasa 0" + "Salvați" + "Spațiul de stocare pe dispozitiv este redus. Mesageria va șterge automat mesaje mai vechi pentru a elibera spațiul." + "Spațiu de stocare limitat" + "Este posibil ca aplicația Mesagerie să nu trimită și să nu primească mesaje până când nu este disponibil mai mult spațiu pe dispozitiv." + "Spațiu de stocare scăzut pentru SMS-uri. Poate fi necesar să ștergeți unele mesaje." + "Confirmați numărul de telefon" + "Prin acest pas efectuat o singură dată vă asigurați că Mesageria va livra în mod corespunzător mesajele de grup." + "Număr de telefon" + "Ștergeți toate mesajele multimedia" + "Ștergeți mesajele mai vechi de %s" + "Ștergeți automat mesajele mai vechi de %s" + "Ignorați" + "Ștergeți toate mesajele multimedia?" + "Ștergeți mesajele mai vechi de %s?" + "Ștergeți mesajele mai vechi de %s și activați ștergerea automată?" + "%s a spus" + "Ați spus" + "Mesaj de la %s" + "Ați trimis un mesaj" + "Se trimite..." + "Nu s-a trimis. Atingeți pentru a încerca din nou." + "Nu s-a trimis. Se încearcă din nou..." + "Trimiteți din nou sau ștergeți" + "Inițiați un apel vocal către serviciile de urgență. Momentan, mesajul text nu a putut fi livrat." + "Eroare" + "Mesaj MMS nou de descărcat" + "Mesaj MMS nou" + "Descărcarea nu a reușit" + "Atingeți pentru a reîncerca" + "Atingeți pentru a descărca" + "Descărcați sau ștergeți" + "Se descarcă..." + "Mesajul a expirat sau nu este disponibil" + "dimensiune: %1$s, expirare: %2$s" + "Nu se poate trimite. Destinatar nevalid." + "Serviciul nu este activat în rețea" + "Mesajul nu s-a putut trimite din cauza unei probleme de rețea" + "Mesajul a expirat sau nu este disponibil." + "(Fără subiect)" + "Expeditor necunoscut" + "Livrat" + "Nu s-a putut descărca mesajul %1$s de la %2$s." + "Nu s-a putut finaliza operațiunea în baza de date din cauza memoriei reduse" + "Mesajul nu a fost trimis." + "Unele mesaje nu au fost trimise în Mesagerie" + + %d (de) mesaje în %d conversații + %d (de) mesaje în %d de conversații + %d (de) mesaje într-o conversație + + "Mesajul nu a fost descărcat" + "Unele mesaje nu au fost descărcate în Mesagerie" + + %d (de) mesaje în %d conversații + %d (de) mesaje în %d de conversații + %d (de) mesaje într-o conversație + + "Mesajul către %1$s nu a fost trimis" + "Inițiați un apel vocal către serviciile de urgență. Momentan, mesajul text către %1$s nu a putut fi livrat." + + %d mesaje noi + %d de mesaje noi + Mesaj nou + + "Începeți" + "Camera foto nu este disponibilă" + "Camera foto nu este disponibilă" + "Filmarea video nu este disponibilă" + "Nu se poate salva fișierul multimedia" + "Nu se poate fotografia" + "Înapoi" + "Arhivate" + "Ștergeți" + "Arhivați" + "Dezarhivați" + "Dezactivați notificările" + "Activați notificările" + "Adăugați o persoană" + "Descărcați" + "Trimiteți" + "Ștergeți" + "Ștergeți acest mesaj?" + "Această acțiune nu poate fi anulată." + "Ștergeți" + + Ștergeți aceste conversații? + Ștergeți aceste conversații? + Ștergeți această conversație? + + "Ștergeți" + "Anulați" + "Către" + "Selectați mai multe imagini" + "Confirmați selecția" + "+%d" + "Mesajul audio nu poate fi înregistrat. Încercați din nou." + "Mesajul audio nu poate fi redat. Încercați din nou." + "Mesajul audio nu a putut fi salvat. Încercați din nou." + "Apăsați lung" + ", " + " " + ": " + " " + "Imagine" + "Clip audio" + "Videoclip" + "Carte de vizită" + "Descărcați" + "Răspundeți prin SMS" + "Răsp. prin MMS" + "Răspundeți" + + %d participanți + %d de participanți + %d participant + + "Eu" + "Persoana de contact este blocată și arhivată" + "Persoană de contact deblocată și dezarhivată" + "%d arhivate" + "%d dezarhivate" + "Notificări dezactivate" + "Notificări activate" + "Gata. Atingeți din nou Trimiteți." + "Mesageria a fost setată ca aplicație prestabilită pentru SMS-uri." + + Eliminați atașamentele + Eliminați atașamentele + Eliminați atașamentul + + "Atașament audio" + "Redați atașamentul audio" + "Pauză" + "Mesaj de la %s: %s." + "Mesaj neprimit de la %s: %s. Ora: %s." + "Mesaj de la %s: %s. Ora: %s." + "Mesaj netrimis către %s: %s. Ora: %s." + "Se trimite mesajul către %s: %s. Ora: %s." + "Mesaj netrimis către %s: %s. Ora: %s." + "Mesaj către %s: %s. Ora: %s." + "Mesaj neprimit de la %s: %s. Ora: %s. %s." + "Mesaj de la %s: %s. Ora: %s. %s." + "Mesaj netrimis către %s: %s. Ora: %s." + "Se trimite mesajul către %s: %s. Ora: %s." + "Mesaj netrimis către %s: %s. Ora: %s." + "Mesaj către %s: %s. Ora: %s." + "Mesaj nelivrat. Atingeți pentru a reîncerca." + "Conversație cu %s" + "Ștergeți subiectul" + "Înregistrați conținut video" + "Fotografiați o imagine statică" + "Fotografiaţi" + "Începeți să înregistrați conținut video" + "Comutați la camera pe ecran complet" + "Comutați între camera foto frontală și camera foto posterioară" + "Opriți înregistrarea și atașați videoclipul" + "Opriți înregistrarea video" + "Fotografii din Mesagerie" + + %d fotografii salvate în albumul „%s + %d de fotografii salvate în albumul „%s + %d fotografie salvată în albumul „%s + + + %d videoclipuri salvate în albumul „%s + %d de videoclipuri salvate în albumul „%s + %d videoclip salvat în albumul „%s + + + %d atașamente salvate în albumul „%s + %d de atașamente salvate în albumul „%s + %d atașament salvat în albumul „%s + + + %d atașamente salvate în „Descărcări” + %d de atașamente salvate în „Descărcări” + %d atașament salvat în „Descărcări” + + + %d atașamente au fost salvate + %d de atașamente au fost salvate + %d atașament a fost salvat + + + Nu s-au putut salva %d atașamente + Nu s-au putut salva %d de atașamente + Nu s-a putut salva %d atașament + + "Atașamentul MMS a fost salvat" + "Setări" + "Arhivate" + "Închideți" + "MMS" + "Avansate" + "Remedierea erorilor" + "Notificări" + "Sunet" + "Silențios" + "Vibrații" + "Blocat" + "Rapoarte de trimitere a SMS-urilor" + "Solicitați un raport de expediere pentru fiecare mesaj SMS trimis" + "Preluare automată" + "Preia automat mesajele MMS" + "Preluare automată în roaming" + "Preia automat mesajele MMS în roaming" + "Mesagerie de grup" + "Utilizați serviciul MMS pentru a trimite un singur mesaj, atunci când există mai mulți destinatari" + "Aplicație SMS prestabilită" + "Aplicație SMS prestabilită" + + + "Numărul dvs. de telefon" + "Necunoscut" + "Sunete pentru mesaje trimise" + "Copiați mesajele SMS" + "Copiați datele brute din mesajele SMS primite în fișierul de stocare externă" + "Copiați mesajele MMS" + "Copiați datele brute din mesajele MMS primite în fișierul de stocare externă" + "Alerte wireless" + "Opţiuni mesaj" + "Copiați textul" + "Vedeți detaliile" + "Ștergeți" + "Redirecționați" + "Detalii mesaj" + "Tip: " + "Mesaj text" + "Mesaj multimedia" + "De la: " + "Către: " + "Trimis: " + "Primit: " + "Subiect: " + "Dimensiune: " + "Prioritate: " + "SIM: " + "Ridicată" + "Normală" + "Scăzută" + "SIM %s" + "Adresa expeditorului este ascunsă" + "Nu se poate trimite mesajul în timp ce se încarcă atașamente." + "Nu se poate încărca atașamentul. Încercați din nou." + "Rețeaua nu este gata. Încercați din nou." + "Ștergeți textul" + "Comutați între introducere de text și numere" + "Adăugați mai mulți participanți" + "Confirmați participanții" + "Începeți o conversație nouă" + "Selectați acest articol" + "Redați videoclipul" + "Persoane și opțiuni" + "Remediați erorile" + "Persoane și opțiuni" + "Generale" + "Persoane participante la conversație" + "Efectuați un apel" + "Trimiteți mesajul" + "Trimiteți mesajul<br/><small>de pe cardul %s</small>" + + Trimiteți fotografiile + Trimiteți fotografiile + Trimiteți fotografia + + + Trimiteți fișierele audio + Trimiteți fișierele audio + Trimiteți fișierul audio + + + Trimiteți videoclipurile + Trimiteți videoclipurile + Trimiteți videoclipul + + + Trimiteți cărțile de vizită + Trimiteți cărțile de vizită + Trimiteți cartea de vizită + + + Trimiteți atașamentele + Trimiteți atașamentele + Trimiteți atașamentul + + + %d atașamente gata de trimis + %d de atașamente gata de trimis + Un atașament gata de trimis + + "Trimiteți feedback" + "Vedeți în Magazinul Google Play" + "Informații despre versiune" + "Versiunea %1$s" + "Licențe open source" + "Notificări" + "S-a atins limita pentru atașamente" + "Atașamentul nu a putut fi încărcat." + "Adăugați în Agendă?" + "Adăugați o persoană" + "Subiect" + "Subiect: " + "%s%s" + "Se încarcă cartea de vizită" + "Cartea de vizită nu a putut fi încărcată" + "Vedeți cartea de vizită" + + %d persoane de contact + %d de persoane de contact + %d persoană de contact + + "Cărți de vizită" + "Ziua de naștere" + "Note" + "Redirecționați mesajul" + "Răspundeți" + "+1" + "+%d" + "SMS-urile sunt dezactivate" + "Pentru a trimite mesaje, setați Mesageria ca aplicație prestabilită pentru SMS-uri" + "Setați Mesageria ca aplicație prestabilită pentru SMS-uri" + "Modificați" + "Pentru a primi mesaje, setați Mesageria ca aplicație prestabilită pentru SMS-uri" + "Pentru trimiterea mesajelor SMS nu este selectat niciun card SIM preferat" + "Această aplicație nu este acceptată de proprietarul dispozitivului." + "OK" + "Prea mulți participanți la o conversație" + + Persoane de contact nevalide + Persoane de contact nevalide + Persoană de contact nevalidă + + "Nu s-a putut încărca imaginea de la camera foto" + "Dvs.: " + "%s: " + "Mesaj nefinalizat" + "Când începeți o nouă conversație, o veți vedea aici" + "Conversațiile arhivate apar aici" + "Conversațiile sunt în curs de încărcare..." + "Imagine" + "Clip audio" + "Videoclip" + "Carte de vizită" + "MMS" + "Anulați" + "Reîncercați" + "Introduceți numele unei persoane de contact sau un număr de telefon pentru a începe un nou mesaj" + "Blocați" + "Blocați %s" + "Deblocați-l(o) pe %s" + "Blocați %s?" + "Veți primi, în continuare, mesaje de la acest număr, dar nu veți mai primi notificări. Această conversație va fi arhivată." + "Persoane din Agendă blocate" + "DEBLOCAȚI" + "Persoane din Agendă blocate" + "Alegeți imaginea din biblioteca de documente" + "Se trimite mesajul" + "Mesaj trimis" + "Datele mobile sunt dezactivate. Verificați setările." + "Nu se pot trimite mesaje în modul Avion" + "Mesajul nu a putut fi trimis" + "Mesaj descărcat" + "Datele mobile sunt dezactivate. Verificați setările." + "Nu se pot descărca mesaje în modul Avion" + "Mesajul nu a putut fi descărcat" + "Zero" + "Unu" + "Doi" + "Trei" + "Patru" + "Cinci" + "Șase" + "Șapte" + "Opt" + "Nouă" + "Nu se poate trimite mesaj prin %1$s, eroare %2$d" + "Nu se poate trimite mesaj printr-un operator necunoscut, eroare %1$d" + "Redir.: %s" + "Mesajul nu a fost trimis: serviciul nu este activat în rețea" + "Mesajul nu a fost trimis: adresă de destinație nevalidă" + "Mesajul nu a fost trimis: mesaj nevalid" + "Mesajul nu a fost trimis: conținut neacceptat" + "Mesajul nu a fost trimis: mesaj neacceptat" + "Mesajul nu a fost trimis: mesaj prea mare" + "Mesaj nou" + "Afișați" + "Imagine" + "Nu am găsit o aplicație adecvată" + "Eliminați un destinatar" + "Mesaj nou" + "Anulați" + "Modificaţi punctul de acces" + "Nesetat" + "Nume" + "APN" + "MMSC" + "Proxy MMS" + "Port MMS" + "MCC" + "MNC" + "Tip APN" + "Ștergeți APN" + "APN nou" + "Salvați" + "Renunțați" + "Câmpul Nume trebuie completat." + "APN-ul trebuie completat." + "Câmpul MCC trebuie să conțină 3 cifre." + "Câmpul MNC trebuie să conțină 2 sau 3 cifre." + "Se restabilesc setările APN prestabilite." + "Resetați la valorile prestabilite" + "Resetarea setărilor APN prestabilite a fost finalizată." + "Fără titlu" + "Numele punctelor de acces" + "APN-uri" + "APN nou" + "Setările pentru „Nume puncte de acces” nu sunt disponibile pentru acest utilizator" + "Copiați în clipboard?" + "Copiați" + "pe cardul %s" + "Generale" + "Avansate" + "Setări generale" + "Setări avansate" + "Cartela SIM „%s”" + "Trimiteți mesaje SMS individuale către toți destinatarii. Numai dvs. veți primi răspunsurile." + "Trimiteți un singur mesaj MMS către toți destinatarii" + "Număr necunoscut" + "Mesaj nou" + "Mesaj nou." + "Selector de SIM" + "%1$s a fost selectat, selector de SIM" + "Editați subiectul" + "Selectați cardul SIM sau editați subiectul" + "Atingeți lung pentru a înregistra conținutul audio" + "Începeți o conversație nouă" + "Mesagerie" + "Listă Mesagerie" + "Mesagerie" + "Mesaj nou" + "Lista de conversații" + "Se încarcă conversațiile" + "Se încarcă mesajele" + "Vedeți mai multe conversații" + "Vedeți mai multe mesaje" + "Conversație ștearsă" + "Conversația a fost ștearsă. Atingeți pentru a afișa altă conversație din Mesagerie" + "Blocat" + "Deblocat" + "Spațiul de stocare este scăzut. Se pot pierde unele date." + "Selectați atașamentele" + "Confirmați selecția" + "Ați selectat %d" + "Eliminați unul sau mai multe atașamente și încercați din nou." + "Puteți încerca să trimiteți mesajul, însă dacă nu eliminați unul sau mai multe atașamente, este posibil ca acesta să nu fie livrat." + "Puteți trimite doar un videoclip per mesaj. Eliminați videoclipurile suplimentare și încercați din nou." + "Mesageria nu a putut încărca atașamentul." + "Trimiteți oricum" + "Conversația nu a putut fi începută" + "%1$s (%2$s)" + "%s selectat" + diff --git a/res/values-ru/arrays.xml b/res/values-ru/arrays.xml new file mode 100644 index 0000000..8af4db2 --- /dev/null +++ b/res/values-ru/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "без темы" + "без темы" + + + "Да" + "Нет" + "ОК" + "Ха-ха" + "Спасибо!" + "Хорошо" + "Отлично" + "Скоро буду" + "ОК, отвечу позже" + ":)" + ":(" + + diff --git a/res/values-ru/strings.xml b/res/values-ru/strings.xml new file mode 100644 index 0000000..0683b2b --- /dev/null +++ b/res/values-ru/strings.xml @@ -0,0 +1,571 @@ + + + + + "SMS/MMS" + "SMS/MMS" + "Выберите чат" + "Настройки" + "Отправить сообщение" + "Прикрепить файл" + "Справка" + "Добро пожаловать!" + "Пропустить" + "Далее >" + "Далее" + "Закрыть" + "Настройки >" + "Настройки" + "Приложению SMS/MMS требуются разрешения категорий SMS, \"Телефон\" и \"Контакты\"." + "Чтобы изменить разрешения, нажмите \"Настройки > Приложения > SMS/MMS > Разрешения\"." + "Чтобы изменить разрешения, нажмите \"Настройки > Приложения > SMS/MMS > Разрешения\"." + "Частые" + "Все контакты" + "Отправить на номер %s" + "Снять фото или видео" + "Выбрать изображения на устройстве" + "Записать аудио" + "Выбрать фотографию" + "Медиафайл выбран" + "Выбор отменен" + "Выбрано: %d" + "изображение %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "изображение" + "Запись аудио" + "Поделиться" + "Только что" + "Только что" + + %d минута + %d минуты + %d минут + %d минуты + + + %d час + %d часа + %d часов + %d часа + + + %d день + %d дня + %d дней + %d дня + + + %d неделя + %d недели + %d недель + %d недели + + + %d месяц + %d месяца + %d месяцев + %d месяца + + + %d год + %d года + %d лет + %d года + + "Сообщение класса 0" + "Сохранить" + "Свободное место в памяти устройства заканчивается. Чтобы освободить его, приложение удалит старые сообщения." + "Недостаточно памяти" + "Недостаточно свободного места, чтобы отправлять и получать сообщения в SMS/MMS." + "Для хранения SMS осталось мало места. Удалите старые сообщения." + "Подтверждение номера телефона" + "Номер требуется для отправки групповых сообщений в SMS/MMS. Его нужно ввести только один раз." + "Номер телефона" + "Удалить сообщения с медиафайлами" + "Удалить сообщения старше %s" + "Автоматически удалять сообщения старше %s" + "Игнорировать" + "Удалить сообщения с медиафайлами?" + "Удалить сообщения старше %s?" + "Удалить сообщения старше %s и включить автоматическое удаление?" + "%s пишет:" + "Вы сказали:" + "Сообщение от пользователя %s" + "Вы отправили сообщение" + "Отправка…" + "Сообщение не отправлено. Нажмите, чтобы повторить попытку." + "Сообщение не отправлено. Повторная попытка…" + "Переслать или удалить" + "Не удалось доставить сообщение. Чтобы связаться с экстренными службами, позвоните по этому номеру." + "Не удалось отправить" + "Скачайте новое MMS-сообщение" + "Новое сообщение MMS" + "Не удалось скачать" + "Нажмите, чтобы повторить попытку" + "Нажмите, чтобы скачать" + "Скачать или удалить" + "Скачивание…" + "Срок действия сообщения истек, или оно недоступно" + "размер: %1$s, срок действия: %2$s" + "Не удалось отправить сообщение: укажите действительного получателя." + "Услуга не активирована в сети" + "Не удалось отправить сообщение из-за ошибки сети" + "Срок действия сообщения истек, или оно недоступно" + "Без темы" + "Неизвестный отправитель" + "Доставлено" + "Не удалось загрузить сообщение \"%1$s\" от пользователя %2$s" + "Не удалось выполнить операцию с базой данных: недостаточно памяти" + "Сообщение не отправлено" + "Некоторые сообщения в SMS/MMS не отправлены" + + Сообщений: %d, чатов: %d + Сообщений: %d, чатов: %d + Сообщений: %d, чатов: %d + Сообщений: %d, чатов: %d + + "Не удалось загрузить сообщение" + "Некоторые сообщения в SMS/MMS не скачаны" + + Сообщений: %d, чатов: %d + Сообщений: %d, чатов: %d + Сообщений: %d, чатов: %d + Сообщений: %d, чатов: %d + + "SMS на номер %1$s не отправлено" + "Не удалось доставить сообщение на номер %1$s. Позвоните в экстренную службу по телефону." + + %d новое сообщение + %d новых сообщения + %d новых сообщений + %d нового сообщения + + "Начать беседу" + "Камера недоступна" + "Камера недоступна" + "Видеозапись недоступна" + "Не удалось сохранить" + "Не удалось сфотографировать" + "Назад" + "Архив" + "Удалить" + "Архив" + "Вернуть из архива" + "Отключить оповещения" + "Включить оповещения" + "Добавить контакт" + "Скачать" + "Отправить" + "Удалить" + "Удалить сообщение?" + "Это действие нельзя будет отменить." + "Удалить" + + Удалить эти чаты? + Удалить эти чаты? + Удалить эти чаты? + Удалить эти чаты? + + "Удалить" + "Отмена" + "Кому" + "Выбрать несколько изображений" + "Подтверждение выбора" + "+%d" + "Не удалось записать. Повторите попытку." + "Не удалось воспроизвести. Повторите попытку." + "Не удалось сохранить. Повторите попытку." + "Нажмите и удерживайте" + ", " + " " + ": " + " " + "Изображение" + "Аудиоклип" + "Видео" + "Карточка контакта" + "Скачать" + "Отправить SMS" + "Ответить MMS" + "Ответить" + + %d участник + %d участника + %d участников + %d участника + + "Вы" + "Контакт заблокирован, переписка помещена в архив" + "Контакт разблокирован, чат возвращен из архива" + "Добавлено в архив: %d" + "Возвращено из архива: %d" + "Уведомления отключены" + "Уведомления включены" + "Все готово. Нажмите \"Отправить\" ещё раз." + "Вы назначили SMS/MMS приложением для SMS по умолчанию" + + Убрать прикрепленные файлы + Убрать прикрепленные файлы + Убрать прикрепленные файлы + Убрать прикрепленные файлы + + "Аудиофайл" + "Воспроизвести прикрепленный аудиофайл" + "Пауза" + "Сообщение от пользователя %s: %s." + "Не удалось отправить сообщение от пользователя %s: %s. Время: %s." + "Сообщение от пользователя %s: %s. Время: %s." + "Неотправленное сообщение для пользователя %s: %s. Время: %s." + "Отправляется сообщение для пользователя %s: %s. Время: %s." + "Не удалось отправить сообщение для пользователя %s: %s. Время: %s." + "Сообщение для пользователя %s: %s. Время: %s." + "Не удалось отправить сообщение от пользователя %s: %s. Время: %s. %s." + "Сообщение от пользователя %s: %s. Время: %s. %s." + "Неотправленное сообщение для группы %s: %s. Время: %s." + "Отправляется сообщение для группы %s: %s. Время: %s." + "Не удалось отправить сообщение для группы %s: %s. Время: %s." + "Сообщение для группы %s: %s. Время: %s." + "Не удалось отправить сообщение. Нажмите, чтобы повторить попытку." + "Чат с пользователями %s" + "Удалить тему." + "Снять видео" + "Сделать стоп-кадр" + "Сделать снимок" + "Начать запись видео" + "Перейти в полноэкранный режим" + "Переключение между основной и фронтальной камерами" + "Остановить запись и прикрепить видео" + "Остановить запись видео" + "Фото из SMS/MMS" + + %d фото сохранено в альбоме \"%s\" + %d фото сохранено в альбоме \"%s\" + %d фото сохранено в альбоме \"%s\" + %d фото сохранено в альбоме \"%s\" + + + %d видео сохранено в альбоме \"%s\" + %d видео сохранено в альбоме \"%s\" + %d видео сохранено в альбоме \"%s\" + %d видео сохранено в альбоме \"%s\" + + + %d прикрепленный файл сохранен в альбоме \"%s\" + %d прикрепленных файла сохранено в альбоме \"%s\" + %d прикрепленных файлов сохранено в альбоме \"%s\" + %d прикрепленных файла сохранено в альбоме \"%s\" + + + %d прикрепленный файл сохранен в Загрузках + %d прикрепленных файла сохранено в Загрузках + %d прикрепленных файлов сохранено в Загрузках + %d прикрепленных файла сохранено в Загрузках + + + %d прикрепленный файл сохранен + %d прикрепленных файла сохранено + %d прикрепленных файлов сохранено + %d прикрепленного файла сохранено + + + Не удалось сохранить %d прикрепленный файл + Не удалось сохранить %d прикрепленных файла + Не удалось сохранить %d прикрепленных файлов + Не удалось сохранить %d прикрепленного файла + + "Сохраненный файл из MMS" + "Настройки" + "Архив" + "Закрыть" + "MMS" + "Дополнительные настройки" + "Выполнить отладку" + "Уведомления" + "Звуковой сигнал" + "Без звука" + "Вибросигнал" + "Заблокировано" + "Отчеты о доставке SMS" + "Запрашивать отчет о доставке для всех отправляемых SMS" + "Автополучение" + "Автоматически загружать MMS-сообщения" + "Автополучение в роуминге" + "Автоматически загружать MMS-сообщения в роуминге" + "Групповые сообщения" + "Использовать MMS для отправки одного сообщения нескольким получателям" + "Приложение для обмена SMS" + "Приложение для обмена SMS" + + + "Ваш номер телефона" + "Неизвестный" + "Сигнал исходящего сообщения" + "Передача SMS" + "Передать необработанные данные полученных SMS во внешнее хранилище" + "Передача MMS" + "Передать необработанные данные полученных MMS во внешнее хранилище" + "Оповещение населения" + "Параметры сообщения" + "Копировать текст" + "Сведения" + "Удалить" + "Переслать" + "Сведения о сообщении" + "Тип: " + "SMS" + "MMS" + "От: " + "Кому: " + "Отправлено: " + "Получено: " + "Тема: " + "Размер: " + "Приоритет: " + "SIM-карта: " + "Высокий" + "Обычный" + "Низкий" + "SIM-карта %s" + "Адрес отправителя скрыт" + "Не удается отправить сообщение. Дождитесь завершения загрузки прикрепленных файлов." + "Не удалось загрузить прикрепленный файл. Повторите попытку." + "Сеть не готова. Повторите попытку." + "Удалить текст" + "Переключение между вводом букв и цифр" + "Добавить участников" + "Подтвердить участников" + "Начать чат" + "Выбрать этот вариант" + "Воспроизвести видео" + "Участники и параметры" + "Выполнить отладку" + "Участники и параметры" + "Общие" + "Участники чата" + "Позвонить" + "Отправить сообщение" + "Отправить сообщение<br/><small>с %s</small>" + + Отправить фото + Отправить фото + Отправить фото + Отправить фото + + + Отправить аудио + Отправить аудио + Отправить аудио + Отправить аудио + + + Отправить видео + Отправить видео + Отправить видео + Отправить видео + + + Отправить карточки контактов + Отправить карточки контактов + Отправить карточки контактов + Отправить карточки контактов + + + Отправить прикрепленные файлы + Отправить прикрепленные файлы + Отправить прикрепленные файлы + Отправить прикрепленные файлы + + + %d прикрепленный файл готов к отправке + %d прикрепленных файла готовы к отправке + %d прикрепленных файлов готовы к отправке + %d прикрепленного файла готовы к отправке + + "Отправить отзыв" + "Искать в Play Маркете" + "Версия" + "Версия %1$s" + "Лицензии открытого ПО" + "Уведомления" + "Прикреплено максимальное количество файлов" + "Не удалось загрузить прикрепленный файл" + "Добавить в контакты?" + "Добавить" + "Тема" + "Тема: " + "%s%s" + "Загрузка карточки контакта…" + "Не удалось загрузить карточку контакта" + "Посмотреть карточку контакта" + + %d контакт + %d контакта + %d контактов + %d контакта + + "Карточки контактов" + "День рождения" + "Примечания" + "Пересылка сообщения" + "Ответ" + "и ещё 1" + "и ещё %d" + "SMS-служба отключена" + "Чтобы отправлять сообщения, назначьте SMS/MMS приложением для SMS по умолчанию" + "Назначьте SMS/MMS приложением для SMS по умолчанию" + "Изменить" + "Чтобы получать сообщения, сделайте SMS/MMS приложением для SMS по умолчанию" + "Не выбрана SIM-карта для отправки SMS-сообщений" + "Владелец устройства запретил использовать это приложение." + "ОК" + "Слишком много участников" + + Контакты не найдены + Контакты не найдены + Контакты не найдены + Контакты не найдены + + "Не удалось загрузить изображение с камеры" + "Вы: " + "%s: " + "Черновик" + "Когда вы начнете переписку, она появится здесь" + "Здесь появятся архивированные чаты" + "Загрузка цепочек писем…" + "Изображение" + "Аудиоклип" + "Видео" + "Карточка контакта" + "MMS" + "Отменить" + "Повторить" + "Введите имя пользователя или номер телефона" + "Заблокировать" + "Заблокировать пользователя %s" + "Разблокировать пользователя %s" + "Заблокировать %s?" + "Вы по-прежнему будете получать сообщения с этого номера, но не увидите оповещений о них. Этот чат будет архивирован." + "Заблокированные контакты" + "РАЗБЛОКИРОВАТЬ" + "Заблокированные контакты" + "Выбрать изображение в библиотеке документов" + "Отправка сообщения" + "Сообщение отправлено" + "Передача данных по мобильной сети отключена в настройках устройства" + "В режиме полета нельзя отправлять сообщения" + "Не удалось отправить сообщение" + "Сообщение скачано" + "Передача данных по мобильной сети отключена в настройках устройства" + "Невозможно скачать сообщения в режиме полета" + "Невозможно скачать сообщение" + "Ноль" + "Один" + "Два" + "Три" + "Четыре" + "Пять" + "Шесть" + "Семь" + "Восемь" + "Девять" + "Не удалось отправить сообщение с помощью оператора \"%1$s\" (ошибка %2$d)" + "Не удалось отправить сообщение с помощью неизвестного оператора (ошибка %1$d)" + "Fwd: %s" + "Сообщение не отправлено: услуга не активирована в сети" + "Сообщение не отправлено: недопустимый адрес получателя" + "Ошибка: недопустимое сообщение" + "Сообщение не отправлено, поскольку содержит неподдерживаемый контент" + "Ошибка: неподдерживаемое сообщение" + "Не удалось отправить сообщение из-за его размера" + "Новое сообщение" + "Посмотреть" + "Изображение" + "Подходящее приложение не найдено" + "Удалить получателя." + "Новое сообщение" + "Отмена" + "Настройки точки доступа" + "Не настроено" + "Название" + "Точка доступа" + "MMSC" + "Прокси-сервер MMS" + "Порт MMS" + "MCC" + "MNC" + "Тип точки доступа" + "Удалить точку доступа" + "Создать точку доступа" + "Сохранить" + "Отменить" + "Укажите название." + "Укажите точку доступа." + "Поле MCC должно содержать 3 цифры." + "Поле MNC должно содержать 2 или 3 цифры." + "Восстановление настроек по умолчанию…" + "Восстановить настройки по умолчанию" + "Настройки по умолчанию восстановлены" + "Без названия" + "Точки доступа" + "Точки доступа" + "Создать точку доступа" + "Этот пользователь не может изменять настройки точки доступа" + "Скопировать в буфер обмена?" + "Да" + "На %s" + "Общие" + "Дополнительные настройки" + "Общие настройки" + "Расширенные настройки" + "SIM-карта \"%s\"" + "Отправлять всем получателям отдельные SMS-сообщения. Ответы на них будут отправлены только вам." + "Отправлять всем получателям одно MMS-сообщение" + "Неизвестный номер" + "Новое сообщение" + "Новое сообщение" + "Выбрать SIM-карту" + "Выбрана SIM-карта %1$s" + "Изменить тему" + "Выбрать SIM-карту или изменить тему" + "Нажмите и удерживайте, чтобы записать аудио" + "Начать чат" + "SMS/MMS" + "Чаты из SMS/MMS" + "SMS/MMS" + "Новое сообщение" + "Список чатов" + "Загрузка чатов" + "Загрузка сообщений…" + "Другие чаты" + "Другие сообщения" + "Чат удален" + "Чат удален. Нажмите здесь, чтобы перейти к другому." + "Заблокирован" + "Разблокирован" + "Заканчивается место в памяти устройства. Некоторые данные могут быть потеряны." + "Выбор прикрепленных файлов" + "Подтверждение выбора" + "Выбрано: %d" + "Удалите один или несколько прикрепленных файлов и повторите попытку." + "Если вы не удалите один или несколько прикрепленных файлов, сообщение может не дойти до адресата." + "Вы можете отправлять по одному видео за раз. Удалите лишние ролики и повторите попытку." + "Не удалось загрузить прикрепленные файлы." + "Отправить" + "Не удается начать разговор" + "%1$s (%2$s)" + "SIM-карта выбрана: %s" + diff --git a/res/values-si-rLK/arrays.xml b/res/values-si-rLK/arrays.xml new file mode 100644 index 0000000..89f6c6c --- /dev/null +++ b/res/values-si-rLK/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "කාරණාවක් නොමැත" + "කාරණාවක් නොමැත" + + + "ඔව්" + "නැත" + "හරි" + "හා හා" + "ස්තූතියි" + "මම එකඟයි" + "නයිස්" + "මම එන ගමන්" + "හරි, මම ඔබ වෙත පසුව පැමිණෙන්නම්" + ":)" + ":(" + + diff --git a/res/values-si-rLK/strings.xml b/res/values-si-rLK/strings.xml new file mode 100644 index 0000000..11a5943 --- /dev/null +++ b/res/values-si-rLK/strings.xml @@ -0,0 +1,519 @@ + + + + + "පණිවිඩ යැවීම" + "පණිවිඩ යැවීම" + "සංවාදය තෝරන්න" + "සැකසීම්" + "පණිවිඩය යවන්න" + "ඇමිණුමක් එක් කරන්න" + "උදවු" + "සාදරයෙන් පිළිගනිමු" + "මඟ හරින්න" + "ඊළඟ >" + "මීළඟ" + "පිටවන්න" + "සැකසීම් >" + "සැකසීම්" + "පණිවිඩ යැවීමට SMS, Phone සහ සම්බන්ධතා වෙත අවසර අවශ්‍යයි." + "ඔබට සැකසීම් > යෙදුම් > පණිවිඩ යැවීම > අවසරවල අවසර වෙනස් කළ හැකිය." + "ඔබට සැකසීම්, යෙදුම්, පණිවිඩ යැවීම, අවසරවල අවසර වෙනස් කළ හැකිය." + "නිතරම" + "සියලුම සම්බන්ධතා" + "%s වෙත යවන්න" + "පින්තූර හෝ වීඩියෝ ලබාගන්න" + "මෙම උපාංගයෙන් පින්තූර තෝරන්න" + "ශ්‍රව්‍ය තැටිගත කරන්න" + "ඡායාරූපය තෝරන්න" + "මාධ්‍යය තෝරාගෙන තිබේ." + "මාධ්‍යය තෝරාගෙන නොතිබේ." + "%d ක් තෝරාගන්නා ලදි" + "රූපය %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "රූපය" + "ශ්‍රව්‍ය තැටිගත කරන්න" + "බෙදාගන්න" + "මේ දැන්" + "දැන්" + + මිනිත්තු %d + මිනිත්තු %d + + + පැය %d + පැය %d + + + දින %d + දින %d + + + සති %d + සති %d + + + මාස %d + මාස %d + + + අවුරුදු %d + අවුරුදු %d + + "0 පන්තියේ පණිවිඩය" + "සුරකින්න" + "උපාංගයේ ඉඩ අඩුය. පණිවිඩකරණය ඉඩ හිස් කර ගැනීමට පැරණි පණිවිඩ ස්වයංක්‍රියව මකනු ඇත." + "ආචයනය ඉඩ ප්‍රමාණය අඩු වී ඇත" + "පණිවිඩ යැවීම ඔබේ උපාංගය මත වැඩි ඉඩක් ලබාගත හැකිවන තෙක් පණිවිඩ නොයැවිය හැකිය නැතහොත් ලබා නොගත හැකිය." + "අඩු SMS ආචයනය. ඇතැම් විට ඔබට පණිවිඩ මැකීමට සිදුවේ." + "ඔබගේ දුරකථන අංකය තහවුරු කරන්න" + "පණිවිඩ යැවීම ඔබේ සමූහ පණිවිඩ නිසි ලෙස බෙදා හරිනු ඇති බවට මෙම එක්-වරක් පියවර තහවුරු කරනු අැත." + "දුරකථන අංකය" + "මාධ්‍ය සමඟ ඇති සියලුම පණිවිඩ මකන්න" + "%s වඩා පැරණි පණිවිඩ මකන්න" + "%s වඩා පැරණි පණිවිඩ ස්වයංක්‍රීයව මකන්න" + "නොසලකා හරින්න" + "මාධ්‍ය සමඟ ඇති සියලුම පණිවිඩ මකන්නද?" + "%s වඩා පැරණි පණිවිඩ මකන්නද?" + "%s වඩා පැරණි පණිවිඩ ස්වයංක්‍රීයව මැකීම සක්‍රිය කරන්නද?" + "%s පැවසුවා" + "ඔබ පැවසුවා" + "%s වෙතින් පණිවිඩය:" + "ඔබ පණිවිඩයක් යැවුවා" + "යවමින්..." + "යැව්වේ නැත. නැවත උත්සාහ කිරීමට ස්පර්ශ කරන්න." + "යැව්වේ නැත. නැවත උත්සාහ කරමින්…" + "නැවත යවන්න නැතහොත් මකන්න" + "කරුණාකර හදිසි සේවාවන් සඳහා හඬ ඇමතුමක් ගන්න. මේ මොහොතේ ඔබගේ පෙළ පණිවිඩය යැවීමට නොහැක." + "අසාර්ථක වුණි" + "බාගැනීමට අලුත් MMS පණිවුඩයකි" + "අලුත් MMS පණිවුඩය" + "බාගැනීමට නොහැකි විය" + "නැවත උත්සාහයට ස්පර්ශ කරන්න" + "බාගැනීමට ස්පර්ශ කරන්න" + "බාගතකරන්න නැතහොත් මකන්න" + "බාගනිමින්…" + "පණිවිඩය කල් ඉකුත්වී ඇත හෝ දැනට නොමැත." + "ප්‍රමාණය: %1$s, කල් ඉකුත් වීම: %2$s" + "යැවිය නොහැක. ලබන්නා වලංගු නැත." + "ජාලය තුළ සේවාව සක්‍රිය කර නොමැත" + "ජාලයේ ගැටළුවක් හේතුවෙන් යැවීම කළ නොහැක" + "පණිවිඩය කල් ඉකුත්වී ඇත හෝ දැනට නොමැත." + "(කාරණාවක් නොමැත)" + "නොහඳුනන යවන්නෙක්" + "භාර දුණි" + "%1$s පණිවිඩය %2$s වෙතින් බාගත නොහැකි විය." + "අඩු මතකය නිසා දත්ත සමුදාය මත මෙහෙයුම සම්පූර්ණ කළ නොහැකි විය" + "පණිවිඩය යැවුවේ නැත" + "සමහර පණිවිඩ පණිවිඩ යැවීමේදී නොයවන ලදී" + + සංවාද %d ක් තුළ පණිවිඩ %d + සංවාද %d ක් තුළ පණිවිඩ %d + + "පණිවිඩය බානොගන්නා ලදී" + "සමහර පණිවිඩ පණිවිඩ යැවීමේදී බා නොගන්නා ලදී" + + සංවාද %d ක් තුළ පණිවිඩ %d + සංවාද %d ක් තුළ පණිවිඩ %d + + "%1$s වෙත පණිවිඩය ගියේ නැත" + "කරුණාකර හදිසි සේවාවන් සඳහා හඬ ඇමතුමක් ගන්න. මේ මොහොතේ %1$s වෙත ඔබගේ පෙළ පණිවිඩය යැවීමට නොහැක." + + නව පණිවිඩ %d + නව පණිවිඩ %d + + "ආරම්භ කරන්න" + "කැමරාව නොපවතී" + "කැමරාව නොපවතී" + "වීඩියෝව ගැනීම නොතිබේ" + "මාධ්‍ය සුරැකීම කළ නොහැක" + "පින්තූරය ලබාගත නොහැක" + "ආපසු" + "සංරක්ෂිත" + "මකන්න" + "සංරක්ෂිත කරන්න" + "සංරක්ෂණය නොකළ" + "දැනුම්දීම් අක්‍රිය කරන්න" + "දැනුම්දීම් සක්‍රිය කරන්න" + "සම්බන්ධතාවය එක් කරන්න" + "බාගන්න" + "යවන්න" + "මකන්න" + "මෙම පණිවිඩය මකන්න?" + "මෙම ක්‍රියාව අවසන් කිරීම කළ නොහැක." + "මකන්න" + + මෙම සංවාද මකන්නද? + මෙම සංවාද මකන්නද? + + "මකන්න" + "අවලංගු කරන්න" + "වෙත" + "බහු පින්තූර තෝරන්න" + "තේරීම සහතික කරන්න" + "+%d" + "ශ්‍රව්‍ය පටිගත කළ නොහැක. නැවත උත්සාහ කරන්න." + "ශ්‍රව්‍ය ධාවනය කළ නොහැක. නැවත උත්සාහ කරන්න." + "ශ්‍රව්‍ය සුරැකීම කළ නොහැක. නැවත උත්සාහ කරන්න." + "ස්පර්ශ කරන්න සහ අල්ලා සිටින්න" + ", " + " " + ": " + " " + "පින්තූරය" + "ශ්‍රව්‍ය පසුර" + "වීඩියෝව" + "සම්බන්ධතා කාඩ් පත" + "බාගන්න" + "SMS ඔස්සේ ප්‍රතිචාර දක්වන්න" + "MMS හරහා පිළිතුරු දෙන්න" + "පිළිතුරු දෙන්න" + + සහභාගිවන්නන් %d + සහභාගිවන්නන් %d + + "මම" + "සම්බන්ධතා අවහිර කරන ලද සහ සංරක්ෂණය කරන ලද" + "සම්බන්ධතාව අවහිර කිරීම ඉවත් කර සංරක්ෂණය නොකරන ලදී" + "%d ක් සංරක්ෂණය කරන ලදි" + "%d ක් සංරක්ෂණය නොකරන ලදි" + "දැනුම්දීම් ක්‍රියාවිරහිත කරන ලදී" + "දැනුම්දීම් ක්‍රියාත්මක කරන ලදී" + "සියල්ල සකසා ඇත. නැවත යැවීමට ස්පර්ශ කරන්න." + "පණිවිඩ යැවීම සාර්ථකව පෙරනිමි SMS යෙදුම ලෙස සකසන ලදී." + + ඇමුණුම් ඉවත ලන්න + ඇමුණුම් ඉවත ලන්න + + "ශ්‍රව්‍ය ඇමුණුම" + "ශ්‍රව්‍ය ඇමුණුම ධාවනය කරන්න" + "විරාමය" + "%s වෙතින් පණිවිඩය: %s." + "%s වෙතින් අසාර්ථක පණිවිඩයක්: %s. වේලාව: %s." + "%s වෙතින් පණිවිඩයක්: %s. වේලාව: %s." + "%s වෙත නොයැවූ පණිවිඩයක්: %s. වේලාව: %s." + "%s වෙත පණිවිඩයක් යවමින්: %s. වේලාව: %s." + "%s වෙත අසාර්ථක පණිවිඩයක්: %s. වේලාව: %s." + "%s වෙත පණිවිඩයක්: %s. වේලාව: %s." + "%s වෙතින් අසාර්ථක පණිවිඩයක්: %s. වේලාව: %s. %s." + "%s වෙතින් පණිවිඩයක්: %s. වේලාව: %s. %s." + "%s වෙත නොයැවූ පණිවිඩය: %s. වේලාව: %s." + "%s වෙත පණිවිඩයක් යවමින්: %s. වේලාව: %s." + "%s වෙත අසාර්ථක පණිවිඩයක්: %s. වේලාව: %s." + "%s වෙත පණිවිඩයක්: %s. වේලාව: %s." + "පණිවිඩය අසාර්ථක විය. නැවත උත්සාහ කිරීමට ස්පර්ශ කරන්න." + "%s සමඟ සංවාදය" + "කාරණය මකන්න" + "වීඩියෝව ග්‍රහණය කරගන්න" + "නිසල පින්තූරයක් ලබාගන්න" + "පින්තූරයක් ගන්න" + "වීඩියෝව පටිගත කිරීම ආරම්භ කරන්න" + "පූර්ණ තිර කැමරාව වෙත මාරු වෙන්න" + "ඉදිරිපස සහ පසුපස කාමර අතර මාරු නොවන්න" + "පටිගත කිරීම නවත්වා වීඩියෝව අමුණන්න" + "වීඩියෝ පටිගත කිරීම නැවත් වන්න" + "ඡායාරූප පණිවිඩ යැවීම" + + ඡායාරූප %d ක් \"%s\" ඇල්බමය වෙත සුරකින ලදී + ඡායාරූප %d ක් \"%s\" ඇල්බමය වෙත සුරකින ලදී + + + වීඩියෝ %d ක් \"%s\" ඇල්බමය වෙත සුරකින ලදී + වීඩියෝ %d ක් \"%s\" ඇල්බමය වෙත සුරකින ලදී + + + ඇමුණුම් %d ක් \"%s\" ඇල්බමය වෙත සුරකින ලදී + ඇමුණුම් %d ක් \"%s\" ඇල්බමය වෙත සුරකින ලදී + + + ඇමුණුම් %d ක් \"බාගැනීම්\" වෙත සුරකින ලදී + ඇමුණුම් %d ක් \"බාගැනීම්\" වෙත සුරකින ලදී + + + ඇමුණුම් %d ක් සුරකින ලදි + ඇමුණුම් %d ක් සුරකින ලදි + + + ඇමුණුම් %d ක් සුරැකීමට නොහැකි විය + ඇමුණුම් %d ක් සුරැකීමට නොහැකි විය + + "MMS ඇමුණුම සුරකින ලදි" + "සැකසීම්" + "සංරක්ෂිත" + "වසන්න" + "MMS" + "උසස්" + "නිදොස්කරණය" + "දැනුම්දීම්" + "ශබ්දය" + "නිහඬ" + "කම්පනය වන්න" + "අවහිර කරන ලදි" + "SMS යැවීමේ වාර්තාව" + "ඔබ යවන ලද සෑම පණිවිඩයකටම යැවීමේ වාර්තාවක් ඉල්ලන්න" + "ස්වයං-ලබාගැනීම" + "MMS පණිවිඩ ස්වයංක්‍රීයව ලබාගන්න" + "රෝමින් ස්වයං-ලබාගැනීම" + "රෝමිං විට MMS ස්වයංක්‍රියව යළි ලබා ගන්න" + "කණ්ඩායම් පණිවිඩ යැවීම්" + "ලබන්නන් කිහිපදෙනෙක් සිටින විට එක් පණිවිඩයක් යැවීමට MMS භාවිතා කරන්න" + "සුපුරුදු SMS යෙදුම" + "සුපුරුදු SMS යෙදුම" + + + "ඔබගේ දුරකථන අංකය" + "නොදනී" + "යන පණිවිඩ හඬ" + "SMS ගොඩ ගසන්න" + "ලැබුණු SMS අමු දත්ත බාහිර ආචයන ගොනුවකට ගොඩ ගසන්න" + "MMS ගොඩ ගසන්න" + "ලැබුණු MMS අමු දත්ත බාහිර ආචයන ගොනුවකට ගොඩ ගසන්න" + "නොරැහැන් ඇඟවීම්" + "පණිවිඩ විකල්ප" + "පෙළ පිටපත් කරන්න" + "විස්තර බලන්න" + "මකන්න" + "ඉදිරියට යවන්න" + "පණිවිඩ විස්තර" + "වර්ගය: " + "පෙළ පණිවුඩය" + "බහු මාධ්‍ය පණිවිඩය" + "වෙතින්: " + "වෙත: " + "යවන ලද: " + "ලැබිණි: " + "කාරණය: " + "ප්‍රමාණය: " + "ප්‍රමුඛතාව: " + "SIM: " + "ඉහළ" + "සාමාන්‍ය" + "පහළ" + "SIM %s" + "සැඟවුණු යවන්නාගේ ලිපිනය" + "ඇමුණුම් පුරණය කිරීමේදී පණිවිඩය යැවිය නොහැක." + "ඇමුණුම පුර්ණය කළ නොහැක. නැවත උත්සාහ කරන්න." + "ජාලය සූදානම් නැත. නැවත උත්සාහ කරන්න." + "පෙළ මකන්න" + "පෙළ සහ අංක ඇතුළු කිරීම අතර මාරුවන්න" + "සහභාගිවන්නන් තව එකතු කරන්න" + "සහභාගිවන්නන් සහතික කරන්න" + "අළුත් සංවාදයක් ආරම්භ කරන්න" + "මෙම අයිතමය තෝරන්න" + "වීඩියෝව ධාවනය කරන්න" + "පුද්ගලයින් සහ විකල්ප" + "නිදොස්කරණය" + "පුද්ගලයින් සහ විකල්ප" + "පොදු" + "මෙම සංවාදයේ සිටින පුද්ගලයින්" + "ඇමතුමක් ලබාගන්න" + "පණිවිඩය යවන්න" + "<small>%s සිට</small><br/> පණිවිඩ යවන්න" + + ඡායාරූප යවන්න + ඡායාරූප යවන්න + + + ශ්‍රව්‍යය යවන්න + ශ්‍රව්‍යය යවන්න + + + වීඩියෝ යවන්න + වීඩියෝ යවන්න + + + සම්බන්ධතා කාඩ්පත් යවන්න + සම්බන්ධතා කාඩ්පත් යවන්න + + + ඇමුණුම් යවන්න + ඇමුණුම් යවන්න + + + ඇමුණුම් %d ක් යැවීමට සුදානම් + ඇමුණුම් %d ක් යැවීමට සුදානම් + + "ප්‍රතිපෝෂණය යවන්න" + "Google Play වෙළඳසැල තුළ බලන්න" + "අනුවාදයේ තොරතුරු" + "අනුවාදය %1$s" + "විවෘත මූලාශ්‍ර බලපත්‍ර" + "දැනුම්දීම්" + "ඇමුණුම් සීමාව ළඟා විය" + "ඇමුණුම ප්‍රවේශනය කිරීම අසාර්ථක විය." + "සම්බන්ධතාවෙත එකතු කරනද?" + "සම්බන්ධතාවය එකතු කරන්න" + "මාතෘකාව" + "මාතෘකාව " + "%s%s" + "සම්බන්ධතා කාඩ් පත් පුර්ණ කරමින්" + "සම්බන්ධතා කාඩ්පත පුර්ණය කළ නොහැක" + "සම්බන්ධ්තා කාඩ්පත පෙන්වන්න" + + සම්බන්ධතා %d + සම්බන්ධතා %d + + "සම්බන්ධතා කාඩ්පත්" + "උපන්දිනය" + "සටහන්" + "ඉදිරියට යන පණිවිඩය" + "පිළිතුරු දෙන්න" + "+1" + "+%d" + "SMS අබල කරන ලදි" + "යැවීමට, පණිවිඩ යැවීම පෙරනිමි SMS යෙදුම ලෙස සකසන්න" + "පණිවිඩ යැවීම පෙරනිමි SMS යෙදුම ලෙස සකසන්න" + "වෙනස් කරන්න" + "පණිවිඩ ලබා ගැනීම සඳහා, පණිවිඩ යැවීම පෙරනිමි SMS යෙදුම ලෙස සකසන්න" + "SMS පණිවිඩ යැවීමට සඳහා මනාප SIM එකක් තෝරාගෙන නොමැත" + "උපාංගයේ හිමිකරු මෙම යෙදුමට ඉඩ නොදෙන ලදි." + "හරි" + "සංවාදයේ සහභාගීවන්නන් ගොඩක් සිටි" + + වලංගු නොවන සම්බන්ධතා + වලංගු නොවන සම්බන්ධතා + + "කැමරා පින්තූරය පුර්ණය කළ නොහැක" + "ඔබ: " + "%s: " + "කටු සටහන" + "අලුත් සංවාදක් ඔබ ආරම්භ කළ පසු, එය මෙතැන ලැයිස්තු කර ඇති බව ඔබ දකීවි" + "සංරක්ෂණය කළ සංවාද මෙහි දිස් වේ" + "සංවාද පුර්ණය වෙමින්..." + "පින්තූරය" + "ශ්‍රව්‍ය පසුර" + "වීඩියෝව" + "සම්බන්ධතා කාඩ් පත" + "MMS" + "අස් කරන්න" + "නැවත උත්සාහ කරන්න" + "අලුත් පණිවිඩයක් ආරම්භ කිරීමට සම්බන්ධතාවේ නම හෝ දුරකථන අංකය ඇතුළු කරන්න" + "අවහිර කරන්න" + "%s අවහිර කරන ලද" + "%s අවහිර නොකරන්න" + "%s අවහිර කරලද?" + "මෙම අංකයෙන් දිගටම පණිවිඩ ලැබීමට ඔබ ඉදිරියට යන ලදි නමුත් තවදුරත් දැනුම් දෙන්න එපා. මෙම සංවාදය සංරක්ෂණය කරවී." + "සම්බන්ධතා අවහිර කරන ලදි" + "අවහිර නොකරන්න" + "සම්බන්ධතා අවහිර කරන ලදි" + "ලේඛන පුස්ථකාලයෙන් පින්තූර තෝරන්න" + "පණිවිඩය යවමින්" + "පණිවිඩය යවන ලදී" + "සෙල්‍යුලර් දත්ත අක්‍රිය කර තිබේ. ඔබගේ සැකසීම් පරික්ෂා කරන්න." + "අහස්යානා ආකාරය තුළ පණිවිඩ යැවිය නොහැක" + "පණිවිඩය යැවිය නොහැකි විය" + "පණිවිඩය බාගන්නා ලදි" + "සෙල්‍යුලර් දත්ත අක්‍රිය කර තිබේ. ඔබගේ සැකසීම් පරික්ෂා කරන්න." + "ගුවන්යානා ප්‍රකාරයේදී පණිවිඩ බාගැනීමට නොහැකිය" + "පණිවිඩය බාගැනීමට නොහැකි විය" + "බිංදුව" + "එක" + "දෙක" + "තුන" + "හතර" + "පහ" + "හය" + "හත" + "අට" + "නවය" + "%1$s සමඟ පණිවිඩය යැවිය නොහැක, දෝෂය %2$d" + "නොදන්නා වාහකයක් සමඟ පණිවිඩය යැවිය නොහැක, දෝෂය %1$d" + "ඉදිරි: %s" + "පණිවිඩ නොයවන ලදි: ජාලය තුළ සක්‍රිය නොකළ සේවාවකි" + "පණිවිඩ නොයවන ලදි: ගමනාන්ත ලිපිනය වලංගු නැත" + "පණිවිඩ නොයවන ලදි: පණිවිඩය වලංගු නැත" + "පණිවිඩ නොයවන ලදි: අන්තර්ගතය සහාය නොදක්වයි" + "පණිවිඩ නොයවන ලදි: පණිවිඩ සහාය නොදක්වයි" + "පණිවිඩය නොයවන ලදි: ඉතාම විශාලයි" + "අලුත් පණිවිඩය" + "බලන්න" + "පින්තුරය" + "අදාළ යෙදුම සොයාගත නොහැක" + "ලබන්නා ඉවත් කරන්න" + "අලුත් පණිවිඩය" + "අවලංගු කරන්න" + "ප්‍රවේශ ස්ථානය සංස්කරණය" + "සකසා නැත" + "නම‍" + "APN" + "MMSC" + "MMS ප්‍රොක්සිය" + "MMS තොට" + "MCC" + "MNC" + "APN වර්ගය" + "APN මකන්න" + "නව APN" + "සුරකින්න" + "ඉවතලන්න" + "නම ක්ෂේත්‍රය හිස් විය නොහැක." + "APN හිස් විය නොහැක." + "MCC ක්ෂේත්‍රය සංඛ්‍යා 3 ක් විය යුතුය." + "MNC ක්ෂේත්‍රය ඉලක්කම් 2 ක් හෝ 3 ක් විය යුතුය." + "සුපුරුදු APN සැකසුම් යළි පිහිටුවමින්." + "සුපුරුදු වෙත යළි සකසන්න" + "සුපුරුදු APN සැකසීම නැවත සැකසීම සම්පූර්ණ කරන ලදි." + "නම් යොදා නැත" + "ප්‍රවේශ ලක්ෂ්‍ය නම්" + "APN" + "නව APN" + "මෙම පරිශීලකයා සඳහා ප්‍රවේශ ලක්ෂ්‍යයේ නමේ සැකසීම් නොතිබේ" + "පසුරු පුවරුව වෙත පිටපත් කරන්නද?" + "පිටපත්කරණය" + "%s වෙත" + "පොදු" + "උසස්" + "පොදු සැකසීම්" + "උසස් සැකසුම්" + "\"%s\" SIM" + "සියළුම ලබන්නන් වෙත තනි SMS පණිවිඩ යවන්න. ඕනෑම පිළිතුරක් පමණක් ඔබට ලැබේ" + "සියළුම ලබන්නන් වෙත තනි MMS යවන්න" + "නොදන්නා අංකය" + "අලුත් පණිවිඩය" + "අලුත් පණිවිඩය." + "SIM තෝරාගැනීම" + "%1$s තෝරාගත්තේය, SIM තෝරාගැනීම" + "මාතෘකාව සකසන්න" + "SIM තෝරන්න හෝ මාතෘකාව සකසන්න" + "ශ්‍රව්‍ය පටිගත කිරීමට තට්ටු කර අල්ලා සිටින්න" + "අළුත් සංවාදයක් ආරම්භ කරන්න" + "පණිවිඩ යැවීම" + "පණිවිඩ යැවීමේ ලැයිස්තුව" + "පණිවිඩ යැවීම" + "අලුත් පණිවිඩය" + "සංවාද ලැයිස්තුව" + "සංවාද පුරණය වෙමින්" + "පණිවුඩ පූරණය කරමින්" + "තවත් සංවාද පෙන්වන්න" + "View more messages" + "සංවාදය මකාදැමුණි" + "සංවාදය මකන ලදී. වෙනස් පණිවිඩ යැවීමේ සංවාදයක් පෙන්වීමට ස්පර්ශ කරන්න" + "අවහිර කරන ලදි" + "අවහුර නොකළ" + "ගබඩා ඉඩ අවමයි. ඇතැම් දත්ත අහිමි වනු ඇත." + "ඇමුණුම් තෝරන්න" + "තේරීම සහතික කරන්න" + "%d ක් තෝරන ලදි" + "කරුණාකර ඇමුණුම් එකක් හෝ වැඩි සංඛ්‍යාවක් ඉවත් කර නැවත උත්සාහ කරන්න." + "ඔබට ඔබේ පණිවිඩය යැවීමට උත්සාහ කළ හැකි වුවත්, ඔබ ඇමුණුම් එකක් හෝ වැඩි සංඛ්‍යාවක් ඉවත් කරන්නේ නම් හැර එය බෙදාගැනීම නොහැකි විය හැකිය." + "ඔබට පණිවිඩයකට එක් වීඩියෝවක් පමණක් යැවිය හැකිය. කරුණාකර අමතර වීඩියෝ ඉවත් කර නැවත උත්සාහ කරන්න." + "පණිවිඩ යැවීම ඇමුණුම පූරණය කිරීමට අසමත් විය." + "කෙසේ වුවත් යවන්න" + "සංවාදය ඇරඹීමට නොහැකි විය" + "%1$s (%2$s)" + "%s තෝරාගත්තේය" + diff --git a/res/values-sk/arrays.xml b/res/values-sk/arrays.xml new file mode 100644 index 0000000..0e48259 --- /dev/null +++ b/res/values-sk/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "bez predmetu" + "žiadny predmet" + + + "Áno" + "Nie" + "OK" + "Haha" + "Ďakujeme" + "Súhlasím" + "Pekné" + "Som na ceste" + "OK, odpíšem neskôr" + ":)" + ":(" + + diff --git a/res/values-sk/strings.xml b/res/values-sk/strings.xml new file mode 100644 index 0000000..32ec96b --- /dev/null +++ b/res/values-sk/strings.xml @@ -0,0 +1,571 @@ + + + + + "SMS a MMS" + "SMS a MMS" + "Vybrať konverzáciu" + "Nastavenia" + "Odoslať správu" + "Pridať prílohu" + "Pomocník" + "Vitajte" + "Preskočiť" + "Ďalej >" + "Ďalej" + "Ukončiť" + "Nastavenia >" + "Nastavenia" + "Aplikácia SMS a MMS vyžaduje povolenie na prístup k správam SMS, telefónu a kontaktom." + "Povolenia môžete zmeniť v časti Nastavenia > Aplikácie > SMS a MMS > Povolenia." + "Povolenia môžete zmeniť v časti Nastavenia, Aplikácie, SMS a MMS, Povolenia." + "Často kontaktované osoby" + "Všetky kontakty" + "Odoslať na číslo %s" + "Snímať obrázky alebo videá" + "Vybrať obrázky z tohto zariadenia" + "Zaznamenať zvuk" + "Vybrať fotku" + "Je vybrané médium." + "Výber média bol zrušený." + "Vybrané: %d" + "obrázok – %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "obrázok" + "Zaznamenanie zvuku" + "Zdieľať" + "Práve teraz" + "Teraz" + + %d min + %d min + %d min + %d min + + + %d hodiny + %d hodiny + %d hodín + %d hodina + + + %d dni + %d dňa + %d dní + %d deň + + + %d týždne + %d týždňa + %d týždňov + týždeň + + + %d mesiace + %d mesiaca + %d mesiacov + mesiac + + + %d roky + %d roka + %d rokov + rok + + "Správa triedy 0" + "Uložiť" + "V zariadení dochádza voľné miesto. Služba SMS a MMS bude automaticky odstraňovať staršie správy, aby uvoľnila miesto." + "Nedostatok miesta na úložisku" + "Aplikácia SMS a MMS nebude môcť posielať ani prijímať správy, pokiaľ v zariadení nebude k dispozícii viac miesta." + "V úložisku pre správy SMS nie je dostatok voľného miesta. Je možné, že budete musieť správy odstrániť." + "Potvrďte svoje telefónne číslo" + "Tento jednorazový krok zaistí, že aplikácia SMS a MMS bude správne doručovať vaše skupinové správy." + "Telefónne číslo" + "Odstrániť všetky správy s médiami" + "Odstrániť správy staršie ako %s" + "Automaticky odstrániť správy staršie ako %s" + "Ignorovať" + "Odstrániť všetky správy s médiami?" + "Odstrániť správy staršie ako %s?" + "Chcete odstrániť správy staršie ako %s a zapnúť automatické odstraňovanie?" + "%s povedal(a)" + "Povedali ste" + "Správa od používateľa %s:" + "Odoslali ste správu" + "Odosiela sa…" + "Správa nebola odoslaná. Klepnutím to skúste znova." + "Správa nebola odoslaná. Skúšame to znova…" + "Znova odoslať alebo odstrániť" + "Zavolajte na pohotovostné služby. Vašu textovú správu sa v tejto chvíli nepodarilo doručiť." + "Nepodarilo sa" + "Nová správa MMS na stiahnutie" + "Nová správa MMS" + "Nepodarilo sa stiahnuť" + "Klepnutím to skúste znova" + "Stiahnite klepnutím" + "Stiahnuť alebo odstrániť" + "Sťahuje sa..." + "Platnosť správy vypršala alebo správa nie je k dispozícii" + "veľkosť: %1$s, vypršanie platnosti: %2$s" + "Správu nie je možné odoslať. Nie je uvedený platný príjemca." + "Služba nie je v sieti aktivovaná" + "Správu sa nepodarilo odoslať, pretože sa vyskytol problém so sieťou" + "Platnosť správy vypršala alebo správa nie je k dispozícii" + "(Bez predmetu)" + "Neznámy odosielateľ" + "Doručené" + "Nepodarilo sa stiahnuť správu %1$s od odosielateľa %2$s" + "Operáciu v databáze sa nepodarilo dokončiť z dôvodu nedostatku pamäte" + "Správu sa nepodarilo odoslať" + "Niektoré správy neboli odoslané v aplikácii SMS a MMS" + + %d Počet správ v %d konverzáciách + %d Počet správ v %d konverzácie + %d Počet správ v %d konverzáciách + %d Počet správ v jednej konverzácii + + "Správu sa nepodarilo stiahnuť" + "Niektoré správy neboli stiahnuté v aplikácii SMS a MMS" + + %d Počet správ v %d konverzáciách + %d Počet správ v %d konverzácie + %d Počet správ v %d konverzáciách + %d Počet správ v jednej konverzácii + + "Správa na číslo %1$s nebola odoslaná" + "Zavolajte na pohotovostné služby. Vašu textovú správu na číslo %1$s sa v tejto chvíli nepodarilo doručiť." + + %d nové správy + %d novej správy + %d nových správ + Nová správa + + "Spustiť" + "Fotoaparát nie je k dispozícii" + "Fotoaparát nie je k dispozícii" + "Snímanie videa nie je k dispozícii" + "Média nie je možné uložiť" + "Snímku nie je možné odfotiť" + "Späť" + "Archivované" + "Odstrániť" + "Archivovať" + "Vybrať z archívu" + "Vypnúť upozornenia" + "Zapnúť upozornenia" + "Pridať kontakt" + "Stiahnuť" + "Odoslať" + "Odstrániť" + "Chcete odstrániť túto správu?" + "Táto akcia sa nedá vrátiť späť." + "Odstrániť" + + Odstrániť tieto konverzácie? + Odstrániť tieto konverzácie? + Odstrániť tieto konverzácie? + Odstrániť túto konverzáciu? + + "Odstrániť" + "Zrušiť" + "Komu" + "Vybrať viaceré obrázky" + "Potvrdiť výber" + "+ %d" + "Zvuk sa nedá zaznamenať. Skúste to znova." + "Zvuk sa nedá prehrať. Skúste to znova." + "Zvuk sa nepodarilo uložiť. Skúste to znova." + "Klepnutie a podržanie" + ", " + " " + ": " + " " + "Fotografia" + "Zvukový klip" + "Video" + "Vizitka" + "Stiahnuť" + "Odpovedať pomocou správy SMS" + "Odpovedať správou MMS" + "Odpovedať" + + %d účastníci + %d účastníka + %d účastníkov + %d účastník + + "Ja" + "Kontakt bol zablokovaný a archivovaný" + "Kontakt bol odblokovaný a jeho archivácia bola zrušená" + "Archivované: %d" + "Zrušená archivácia: %d" + "Upozornenia sú vypnuté" + "Upozornenia sú zapnuté" + "Všetko je pripravené. Klepnite znova na tlačidlo Odoslať." + "Aplikácia SMS a MMS bola úspešne nastavená ako predvolená aplikácia pre SMS" + + Zahodiť prílohy + Zahodiť prílohy + Zahodiť prílohy + Zahodiť prílohu + + "Zvuková príloha" + "Prehrať zvukovú prílohu" + "Pozastaviť" + "Správa od používateľa %s: %s" + "Správa od používateľa %s bola neúspešná: %s. Čas: %s." + "Správa od používateľa %s: %s. Čas: %s." + "Správa nebola odoslaná používateľovi %s: %s. Čas: %s." + "Odosiela sa správa používateľovi %s: %s. Čas: %s." + "Správa používateľovi %s bola neúspešná: %s. Čas: %s." + "Správa používateľovi %s: %s. Čas: %s." + "Neúspešná správa od používateľa %s: %s. Čas: %s. %s." + "Správa od používateľa %s: %s. Čas: %s. %s." + "Správa nebola odoslaná skupine %s: %s. Čas: %s." + "Odosiela sa správa skupine %s: %s. Čas: %s." + "Správa skupine %s bola neúspešná: %s. Čas: %s." + "Správa skupine %s: %s. Čas: %s." + "Neúspešná správa. Skúste to znova klepnutím." + "Rozhovor s používateľmi %s" + "Odstrániť predmet" + "Natočiť video" + "Urobiť statickú snímku" + "Vyfotiť" + "Spustiť natáčanie videa" + "Prepnúť na fotoaparát na celej obrazovke" + "Prepnúť medzi predným a zadným fotoaparátom" + "Zastaviť zaznamenávanie a pripojiť video" + "Zastaviť zaznamenávanie videa" + "Fotky v aplikácii SMS a MMS" + + %d fotky boli uložené do albumu %s + %d fotky bolo uloženej do albumu %s + %d fotiek bolo uložených do albumu %s + %d fotka bola uložená do albumu %s + + + %d videá boli uložené do albumu %s + %d videa bolo uloženého do albumu %s + %d videí bolo uložených do albumu %s + %d video bolo uložené do albumu %s + + + %d prílohy boli uložené do albumu %s + %d prílohy bolo uloženej do albumu %s + %d príloh bolo uložených do albumu %s + %d príloha bola uložená do albumu %s + + + %d prílohy boli uložené do priečinka so stiahnutými súbormi + %d prílohy bolo uloženej do priečinka so stiahnutými súbormi + %d príloh bolo uložených do priečinka so stiahnutými súbormi + %d príloha bola uložená do priečinka so stiahnutými súbormi + + + Boli uložené %d prílohy + Bolo uloženej %d prílohy + Bolo uložených %d príloh + Bola uložená %d príloha + + + Nepodarilo sa uložiť %d prílohy + Nepodarilo sa uložiť %d prílohy + Nepodarilo sa uložiť %d príloh + Nepodarilo sa uložiť %d prílohu + + "Uložené prílohy správ MMS" + "Nastavenia" + "Archivované" + "Zavrieť" + "MMS" + "Rozšírené" + "Ladiť" + "Upozornenia" + "Zvuk" + "Tichý režim" + "Vibrovanie" + "Blokované" + "Prehľady doručovania SMS" + "Vyžadovať potvrdenie o doručení každej odoslanej SMS" + "Automatické načítanie" + "Automaticky načítať správy MMS" + "Automatické načítanie pri roamingu" + "Automaticky načítať správy MMS počas roamingu" + "Skupinové správy" + "Použiť MMS na odoslanie jednej správy, ak má niekoľko príjemcov" + "Predvolená aplikácia SMS" + "Predvolená aplikácia SMS" + + + "Vaše telefónne číslo" + "Neznáme" + "Zvuky odosielanej správy" + "Zálohovať správy SMS" + "Zálohovať nespracované údaje prijatých správ SMS do súboru v externom úložisku" + "Zálohovať správy MMS" + "Zálohovať nespracované údaje prijatých správ MMS do súboru v externom úložisku" + "Bezdrôtové núdzové upozornenia" + "Možnosti správy" + "Kopírovať text" + "Zobraziť podrobnosti" + "Odstrániť" + "Poslať ďalej" + "Podrobnosti správy" + "Typ: " + "Textová správa" + "Multimediálna správa" + "Od: " + "Komu: " + "Odoslané: " + "Prijaté: " + "Predmet: " + "Veľkosť: " + "Priorita: " + "SIM karta: " + "Vysoká" + "Normálna" + "Nízka" + "Slot SIM %s" + "Skrytá adresa odosielateľa" + "Počas načítavania príloh nie je možné odoslať správu." + "Nie je možné načítať prílohu. Skúste to znova." + "Sieť nie je pripravená. Skúste to znova." + "Odstrániť text" + "Prepnúť medzi zadávaním textu a číslic" + "Pridať ďalších účastníkov" + "Potvrdiť účastníkov" + "Začať novú konverzáciu" + "Vybrať túto položku" + "Prehrať video" + "Ľudia a možnosti" + "Ladiť" + "Ľudia a možnosti" + "Všeobecné" + "Ľudia v tejto konverzácii" + "Zavolať" + "Odoslať správu" + "Odošlite správu<br/><small>zo SIM karty %s</small>" + + Odoslať fotky + Odoslať fotky + Odoslať fotky + Odoslať fotku + + + Odoslať zvukové súbory + Odoslať zvukové súbory + Odoslať zvukové súbory + Odoslať zvukový súbor + + + Odoslať videá + Odoslať videá + Odoslať videá + Odoslať video + + + Odoslať vizitky + Odoslať vizitky + Odoslať vizitky + Odoslať vizitku + + + Odoslať prílohy + Odoslať prílohy + Odoslať prílohy + Odoslať prílohu + + + %d prílohy sú pripravené na odoslanie + %d prílohy je pripravenej na odoslanie + %d príloh je pripravených na odoslanie + Jedna príloha je pripravená na odoslanie + + "Odoslať spätnú väzbu" + "Zobraziť v Obchode Google Play" + "Informácie o verzii" + "Verzia %1$s" + "Licencie open source" + "Upozornenia" + "Dosiahli ste limit pre počet príloh" + "Prílohu sa nepodarilo načítať." + "Pridať medzi kontakty?" + "Pridať kontakt" + "Predmet" + "Predmet: " + "%s%s" + "Načítava sa vizitka" + "Nepodarilo sa načítať vizitku" + "Zobraziť vizitku" + + %d kontakty + %d kontaktu + %d kontaktov + %d kontakt + + "Vizitky" + "Narodeniny" + "Poznámky" + "Poslanie správy ďalej" + "Odpovedanie" + "+1" + "+%d" + "Správy SMS sú zakázané" + "Ak chcete odosielať správy, nastavte SMS a MMS ako predvolenú aplikáciu pre SMS" + "Nastavte SMS a MMS ako predvolenú aplikáciu pre SMS" + "Zmeniť" + "Ak chcete prijímať správy, nastavte SMS a MMS ako predvolenú aplikáciu pre SMS" + "Nie je vybraná preferovaná SIM karta na odosielanie správ SMS." + "Túto aplikáciu vlastník zariadenia nepovolil." + "OK" + "V konverzácii je príliš mnoho účastníkov" + + Neplatné kontakty + Neplatné kontakty + Neplatné kontakty + Neplatný kontakt + + "Obrázok z fotoaparátu sa nepodarilo načítať" + "Vy: " + "%s: " + "Koncept" + "Tu uvidíte svoje konverzácie" + "Tu sa zobrazujú archivované konverzácie" + "Načítavajú sa konverzácie..." + "Obrázok" + "Zvukový klip" + "Video" + "Vizitka" + "MMS" + "Späť" + "Skúsiť znova" + "Zadajte meno kontaktu alebo telefónne číslo a začnite písať novú správu" + "Blokovať" + "Blokovať kontakt %s" + "Odblokovať kontakt %s" + "Blokovať %s?" + "Z tohto čísla budete aj naďalej dostávať správy. Nebudete už však dostávať upozornenia. Táto konverzácia bude archivovaná." + "Blokované kontakty" + "ODBLOKOVAŤ" + "Blokované kontakty" + "Vyberte obrázok z knižnice dokumentov" + "Správa sa odosiela" + "Správa bola odoslaná" + "Prenos mobilných údajov je vypnutý. Skontrolujte svoje nastavenia." + "V režime V lietadle nie je možné odosielať správy" + "Odoslanie správy zlyhalo" + "Správa bola stiahnutá" + "Prenos mobilných dát je vypnutý. Skontrolujte svoje nastavenia." + "V režime v lietadle nie je možné sťahovať správy" + "Správu nie je možné stiahnuť" + "Nula" + "Jedna" + "Dva" + "Tri" + "Štyri" + "Päť" + "Šesť" + "Sedem" + "Osem" + "Deväť" + "Správu prostredníctvom operátora %1$s nie je možné odoslať. Chyba: %2$d" + "Správu prostredníctvom neznámeho operátora nie je možné odoslať. Chyba: %1$d" + "Poslať ďalej: %s" + "Správa nebola odoslaná: služba nie je aktivovaná v sieti" + "Správa nebola odoslaná: neplatná cieľová adresa" + "Správa nebola odoslaná: neplatná správa" + "Správa nebola odoslaná: nepodporovaný obsah" + "Správa nebola odoslaná: nepodporovaná správa" + "Správa nebola odoslaná: veľkosť prekročila limit" + "Nová správa" + "Zobraziť" + "Obrázok" + "Nenašla sa vhodná aplikácia" + "Odstrániť príjemcu" + "Nová správa" + "Zrušiť" + "Úprava prístupového bodu" + "Nenastavené" + "Názov" + "Názov prístupového bodu" + "MMSC" + "Proxy server systému MMS" + "Port systému MMS" + "MCC" + "MNC" + "Typ prístupového bodu" + "Odstrániť názov prístupového bodu (APN)" + "Nový prístupový bod" + "Uložiť" + "Zrušiť" + "Pole Meno nemôže byť prázdne." + "Názov prístupového bodu (APN) nemôže byť prázdny." + "Pole MCC musí obsahovať 3 číslice." + "Pole MNC musí obsahovať 2 alebo 3 číslice." + "Obnovujú sa predvolené nastavenia názvu prístupového bodu (APN)." + "Obnoviť predvolené" + "Predvolené nastavenia prístupových bodov boli obnovené" + "Bez názvu" + "Názvy prístupových bodov" + "Prístupové body" + "Nový prístupový bod" + "Nastavenia názvu prístupového bodu (APN) nie sú dostupné pre tohto používateľa" + "Kopírovať do schránky?" + "Kopírovať" + "na SIM kartu %s" + "Všeobecné" + "Rozšírené" + "Všeobecné nastavenia" + "Rozšírené nastavenia" + "SIM karta %s" + "Odoslať osobitné správy SMS všetkým príjemcom. Všetky odpovede dostanete iba vy." + "Odoslať jednu správu MMS všetkým príjemcom" + "Neznáme číslo" + "Nová správa" + "Nová správa" + "Výber SIM karty" + "Výber SIM karty (vybraná SIM karta %1$s)" + "Upraviť predmet" + "Vybrať SIM kartu alebo upraviť predmet" + "Zvuk zaznamenáte klepnutím a podržaním" + "Začať novú konverzáciu" + "SMS a MMS" + "Zoznam v aplikácii SMS a MMS" + "SMS a MMS" + "Nová správa" + "Zoznam konverzácií" + "Načítavajú sa konverzácie..." + "Načítavajú sa správy" + "Zobraziť ďalšie konverzácie" + "Zobraziť viac správ" + "Konverzácia bola odstránená" + "Konverzácia bola odstránená. Klepnutím si zobrazte inú konverzáciu aplikácie SMS a MMS." + "Zablokované" + "Odblokované" + "V úložisku je málo miesta. Niektoré údaje môžu byť stratené." + "Vybrať prílohy" + "Potvrďte výber" + "Vybrané: %d" + "Odstráňte jednu alebo viac príloh a skúste to znova." + "Svoju správu sa môžete pokúsiť odoslať, ale pravdepodobne bude doručená až vtedy, keď odstránite jednu alebo viac príloh." + "Môžete odoslať iba jedno video na správu. Odstráňte ďalšie videá a skúste to znova." + "Aplikácii SMS a MMS sa nepodarilo načítať prílohu." + "Aj tak odoslať" + "Rozhovor nie je možné spustiť" + "%1$s (%2$s)" + "Vybrané: %s" + diff --git a/res/values-sl/arrays.xml b/res/values-sl/arrays.xml new file mode 100644 index 0000000..0cd69b7 --- /dev/null +++ b/res/values-sl/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "brez zadeve" + "nizadeve" + + + "Da" + "Ne" + "V redu" + "Hehe" + "Hvala" + "Strinjam se" + "Lepo" + "Prihajam" + "V redu, oglasim se pozneje" + ":)" + ":(" + + diff --git a/res/values-sl/strings.xml b/res/values-sl/strings.xml new file mode 100644 index 0000000..2352632 --- /dev/null +++ b/res/values-sl/strings.xml @@ -0,0 +1,571 @@ + + + + + "Sporočila" + "Sporočila" + "Izbira pogovora" + "Nastavitve" + "Pošlji sporočilo" + "Dodajte prilogo" + "Pomoč" + "Pozdravljeni" + "Preskoči" + "Naprej >" + "Naprej" + "Izhod" + "Nastavitve >" + "Nastavitve" + "Sporočila zahtevajo dovoljenje za dostop do sporočil SMS, telefona in stikov." + "Dovoljenja lahko spreminjate v »Nastavitve > Aplikacije > Sporočila > Dovoljenja«." + "Dovoljenja lahko spreminjate v »Nastavitve, Aplikacije, Sporočila, Dovoljenja«." + "Pogosti" + "Vsi stiki" + "Pošlji na številko %s" + "Snemanje slik ali videoposnetkov" + "Izbira slik iz te naprave" + "Snemanje zvoka" + "Izbira fotografije" + "Predstavnost je izbrana." + "Izbor predstavnosti je preklican." + "Št. izbranih: %d" + "slika %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "slika" + "Snemanje zvoka" + "Skupna raba" + "Pravkar" + "Zdaj" + + %d min + %d min + %d min + %d min + + + %d ura + %d uri + %d ure + %d ur + + + %d dan + %d dneva + %d dni + %d dni + + + %d teden + %d tedna + %d tedne + %d tednov + + + %d mesec + %d meseca + %d mesece + %d mesecev + + + %d leto + %d leti + %d leta + %d let + + "Sporočilo razreda 0" + "Shrani" + "V napravi primanjkuje prostora za shranjevanje. Sporočila bodo zaradi sprostitve prostora samodejno izbrisala starejša sporočila." + "Zmanjkalo bo prostora za shranjevanje" + "Sporočila morda ne bodo pošiljala ali sprejemala sporočil, dokler v napravi ne bo na voljo več prostora." + "Prostora za SMS-je je malo. Morda boste morali izbrisati sporočila." + "Potrditev telefonske številke" + "S tem enkratnim korakom boste zagotovili, da bodo Sporočila pravilno dostavljala skupinska sporočila." + "Telefonska številka" + "Izbriši vsa sporočila s predstavnostjo" + "Izbriši sporočila, starejša od %s" + "Samodejno brisanje sporočil, starejših od %s" + "Prezri" + "Želite izbrisati vsa sporočila s predstavnostjo?" + "Želite izbrisati sporočila, starejša od %s?" + "Želite izbrisati sporočila, starejša od %s, in vklopiti samodejno brisanje?" + "Oseba %s sporoča" + "Sporočili ste" + "Sporočilo osebe %s" + "Poslali ste sporočilo" + "Pošiljanje …" + "Ni bilo poslano. Dotaknite se za vnovični poskus." + "Ni bilo poslano. Vnovični poskus …" + "Znova pošlji ali izbriši" + "Pokličite nujno pomoč. SMS-ja ni bilo mogoče poslati." + "Ni uspelo" + "Novo sporočilo MMS za prenos" + "Novo sporočilo MMS" + "Prenos ni uspel" + "Dotaknite se za vnovični poskus" + "Dotaknite se za prenos" + "Prenesi ali izbriši" + "Prenašanje …" + "Sporočilo je poteklo ali ni na voljo" + "velikost: %1$s, poteče: %2$s" + "Pošiljanje ni mogoče. Prejemnik ni veljaven." + "Storitev ni aktivirana v omrežju" + "Pošiljanje zaradi težave v omrežju ni uspelo" + "Sporočilo je poteklo ali ni na voljo" + "(Ni zadeve)" + "Neznan pošiljatelj" + "Dostavljeno" + "Sporočila %1$s osebe %2$s ni bilo mogoče prenesti." + "Postopka v zbirki podatkov ni bilo mogoče dokončati, ker primanjkuje pomnilnika" + "Sporočilo ni bilo poslano" + "Nekatera sporočila niso bila poslana v Sporočilih" + + %d sporočilo v %d pogovoru + %d sporočili v %d pogovorih + %d sporočila v %d pogovorih + %d sporočil v %d pogovorih + + "Sporočilo ni bilo preneseno" + "Nekatera sporočila niso bila prenesena v Sporočilih" + + %d sporočilo v %d pogovoru + %d sporočili v %d pogovorih + %d sporočila v %d pogovorih + %d sporočil v %d pogovorih + + "Sporočilo na številko %1$s ni bilo poslano" + "Pokličite nujno pomoč. SMS-ja na %1$s ni bilo mogoče poslati." + + %d novo sporočilo + %d novi sporočili + %d nova sporočila + %d novih sporočil + + "Začni" + "Fotoaparat ni na voljo" + "Fotoaparat ni na voljo" + "Snemanje videoposnetkov ni na voljo" + "Predstavnosti ni mogoče shraniti" + "Ni mogoče posneti fotografije" + "Nazaj" + "Arhivirano" + "Izbriši" + "Arhiviraj" + "Odstrani iz arhiva" + "Izklop obvestil" + "Vklop obvestil" + "Dodaj stik" + "Prenos" + "Pošlji" + "Izbriši" + "Želite izbrisati sporočilo?" + "Tega dejanja ni mogoče razveljaviti." + "Izbriši" + + Želite izbrisati te pogovore? + Želite izbrisati te pogovore? + Želite izbrisati te pogovore? + Želite izbrisati te pogovore? + + "Izbriši" + "Prekliči" + "Za" + "Izbira več slik" + "Potrditev izbora" + "+%d" + "Zvoka ni mogoče posneti. Poskusite znova." + "Zvoka ni mogoče predvajati. Poskusite znova." + "Zvoka ni bilo mogoče shraniti. Poskusite znova." + "Dotaknite se in pridržite" + ", " + " " + ": " + " " + "Slika" + "Zvočni posnetek" + "Videoposnetek" + "Osebna vizitka" + "Prenos" + "Odgovor prek sporočila SMS" + "Odg. z MMS-jem" + "Odgovori" + + %d udeleženec + %d udeleženca + %d udeleženci + %d udeležencev + + "Jaz" + "Blokirani in arhivirani stiki" + "Stik odblokiran in odstranjen iz arhiva" + "Št. arhiviranih: %d" + "Št. povrnjenih iz arhiva: %d" + "Obvestila izklopljena" + "Obvestila vklopljena" + "Vse je nastavljeno. Znova se dotaknite »Pošlji«." + "Sporočila so uspešno nastavljena kot privzeta aplikacija za sporočila SMS." + + Zavrzi priloge + Zavrzi priloge + Zavrzi priloge + Zavrzi priloge + + "Zvočna priloga" + "Predvajanje zvočne priloge" + "Začasna zaustavitev" + "Sporočilo pošiljatelja %s: %s." + "Neuspelo sporočilo osebe %s: %s. Čas: %s." + "Sporočilo osebe %s: %s. Čas: %s." + "Neposlano sporočilo osebi %s: %s. Čas: %s." + "Pošiljanje sporočila osebi %s: %s. Čas: %s." + "Neuspelo sporočilo osebi %s: %s. Čas: %s." + "Sporočilo osebi %s: %s. Čas: %s." + "Neuspelo sporočilo osebe %s: %s. Čas: %s. %s." + "Sporočilo osebe %s: %s. Čas: %s. %s." + "Neposlano sporočilo skupini %s: %s. Čas: %s." + "Pošiljanje sporočila skupini %s: %s. Čas: %s." + "Neuspelo sporočilo skupini %s: %s. Čas: %s." + "Sporočilo skupini %s: %s. Čas: %s." + "Neuspelo sporočilo. Dotaknite se za vnovičen poskus." + "Pogovor z udeleženci %s" + "Izbris zadeve" + "Posnemi video" + "Posnemi fotografijo" + "Fotografiraj" + "Začni snemanje videa" + "Preklopi v celozaslonski način fotoaparata" + "Preklop med sprednjim in zadnjim fotoaparatom" + "Ustavi snemanje in pripni videoposnetek" + "Ustavi snemanje videoposnetka" + "Fotografije v Sporočilih" + + %d fotografija shranjena v album »%s« + %d fotografiji shranjeni v album »%s« + %d fotografije shranjene v album »%s« + %d fotografij shranjenih v album »%s« + + + %d videoposnetek shranjen v album »%s« + %d videoposnetka shranjena v album »%s« + %d videoposnetki shranjeni v album »%s« + %d videoposnetkov shranjenih v album »%s« + + + %d priloga shranjena v album »%s« + %d prilogi shranjeni v album »%s« + %d priloge shranjene v album »%s« + %d prilog shranjenih v album »%s« + + + %d priloga shranjena v »Prenosi« + %d prilogi shranjeni v »Prenosi« + %d priloge shranjene v »Prenosi« + %d prilog shranjenih v »Prenosi« + + + %d priloga je bila shranjena + %d prilogi sta bili shranjeni + %d priloge so bile shranjene + %d prilog je bilo shranjenih + + + %d priloge ni bilo mogoče shraniti + %d prilog ni bilo mogoče shraniti + %d prilog ni bilo mogoče shraniti + %d prilog ni bilo mogoče shraniti + + "Shranjena priloga sporočila MMS" + "Nastavitve" + "Arhivirano" + "Zapri" + "MMS" + "Dodatno" + "Odpravljanje napak" + "Obvestila" + "Zvok" + "Tiho" + "Vibriranje" + "Blokirano" + "Poročila o dostavi sporočil SMS" + "Zahtevanje poročila o dostavi za vsak poslan SMS" + "Samodejni prenos" + "Samodejni prenos MMS-jev" + "Samodejni prenos med gostovanjem" + "Samodejni prenos sporočil MMS med gostovanjem" + "Skupinsko pošiljanje sporočil" + "Če želite eno sporočilo poslati več prejemnikom, uporabite MMS" + "Privzeta aplikacija za sporočila SMS" + "Privzeta aplikacija za sporočila SMS" + + + "Vaša telefonska številka" + "Neznano" + "Zvoki pri odhodnih sporočilih" + "Izvoz SMS-jev" + "Izvoz neobdelanih podatkov iz prejetih SMS-jev v datoteko v zunanji napravi za shranjevanje" + "Izvoz MMS-jev" + "Izvoz neobdelanih podatkov iz prejetih MMS-jev v datoteko v zunanji napravi za shranjevanje" + "Brezžična opozorila" + "Možnosti sporočila" + "Kopiraj besedilo" + "Ogled podrobnosti" + "Brisanje" + "Posreduj" + "Podrobnosti sporočila" + "Vrsta: " + "SMS" + "MMS" + "Od: " + "Za: " + "Poslano: " + "Prejeto: " + "Zadeva: " + "Velikost: " + "Prednost: " + "SIM: " + "Visoka" + "Običajna" + "Nizka" + "SIM %s" + "Skrit naslov pošiljatelja" + "Med nalaganjem priponk ni mogoče poslati sporočila." + "Priloge ni mogoče naložiti. Poskusite znova." + "Omrežje ni pripravljeno. Poskusite znova." + "Izbriši besedilo" + "Preklop med vnašanjem besedila in številk" + "Dodaj več udeležencev" + "Potrditev udeležencev" + "Začenjanje novega pogovora" + "Izbira tega elementa" + "Predvajanje videoposnetka" + "Osebe in možnosti" + "Odpravljanje napak" + "Osebe in možnosti" + "Splošno" + "Osebe v tem pogovoru" + "Klicanje" + "Pošlji sporočilo" + "Pošiljanje sporočila<br/><small>s kartico %s</small>" + + Pošljite fotografije + Pošljite fotografije + Pošljite fotografije + Pošljite fotografije + + + Pošljite zvočne posnetke + Pošljite zvočne posnetke + Pošljite zvočne posnetke + Pošljite zvočne posnetke + + + Pošljite videoposnetke + Pošljite videoposnetke + Pošljite videoposnetke + Pošljite videoposnetke + + + Pošljite osebne vizitke + Pošljite osebne vizitke + Pošljite osebne vizitke + Pošljite osebne vizitke + + + Pošljite priloge + Pošljite priloge + Pošljite priloge + Pošljite priloge + + + %d priloga je pripravljena za pošiljanje + %d prilogi sta pripravljeni za pošiljanje + %d priloge so pripravljene za pošiljanje + %d prilog je pripravljenih za pošiljanje + + "Pošljite povratne informacije" + "Ogled v Trgovini Google Play" + "Podatki o različici" + "Različica %1$s" + "Odprtokodne licence" + "Obvestila" + "Dosežena je omejitev za priloge" + "Nalaganje priloge ni uspelo." + "Ali želite dodati med stike?" + "Dodaj stik" + "Zadeva" + "Zadeva: " + "%s%s" + "Nalaganje osebne vizitke" + "Vizitke ni bilo mogoče naložiti" + "Ogled osebne vizitke" + + %d stik + %d stika + %d stiki + %d stikov + + "Vizitke" + "Rojstni dan" + "Zapiski" + "Posredovanje sporočila" + "Odgovor" + "še 1" + "več kot %d" + "SMS-ji so onemogočeni" + "Če želite pošiljati, nastavite Sporočila kot privzeto aplikacijo za sporočila SMS" + "Nastavite Sporočila kot privzeto aplikacijo za sporočila SMS" + "Spremeni" + "Če želite prejemati sporočila, nastavite Sporočila kot privzeto aplikacijo za sporočila SMS" + "Ni izbrane prednostne kartice SIM za pošiljanje sporočil SMS" + "Lastnik naprave ne dovoli uporabe te aplikacije." + "V redu" + "Preveč udeležencev v pogovoru" + + Neveljavni stiki + Neveljavni stiki + Neveljavni stiki + Neveljavni stiki + + "Slike fotoaparata ni bilo mogoče naložiti" + "Vi: " + "%s: " + "Osnutek" + "Ko začnete nov pogovor, bo naveden tukaj" + "Tu so prikazani arhivirani pogovori" + "Nalaganje pogovorov ..." + "Slika" + "Zvočni posnetek" + "Videoklic" + "Osebna vizitka" + "MMS" + "Razveljavi" + "Poskusi znova" + "Če želite začeti novo sporočilo, vnesite ime stika ali telefonsko številko" + "Blokiraj" + "Blokiraj stik %s" + "Odblokiraj stik %s" + "Želite blokirati %s?" + "Še vedno boste prejemali sporočila s te številke, vendar ne boste več obveščeni. Ta pogovor bo arhiviran." + "Blokirani stiki" + "ODBLOKIRAJ" + "Blokirani stiki" + "Izbira slike iz knjižnice dokumentov" + "Pošiljanje sporočila" + "Sporočilo poslano" + "Prenos podatkov v mobilnem omrežju je izklopljen. Preverite nastavitve." + "V načinu za letalo ni mogoče pošiljati sporočil" + "Sporočila ni bilo mogoče poslati." + "Sporočilo preneseno" + "Prenos podatkov v mobilnem omrežju je izklopljen. Preverite nastavitve." + "V načinu za letalo ni mogoče prenašati sporočil" + "Sporočila ni bilo mogoče prenesti" + "Nič" + "Ena" + "Dva" + "Tri" + "Štiri" + "Pet" + "Šest" + "Sedem" + "Osem" + "Devet" + "Sporočila prek operaterja %1$s ni mogoče poslati, napaka %2$d" + "Sporočila prek neznanega operaterja ni mogoče poslati, napaka %1$d" + "Fwd: %s" + "Sporočilo ni bilo poslano: storitev ni aktivirana v omrežju" + "Sporočilo ni poslano: neveljaven naslov prejemnika" + "Sporočilo ni poslano: neveljavno sporočilo" + "Sporočilo ni poslano: nepodprta vsebina" + "Sporočilo ni poslano: nepodprto sporočilo" + "Sporočilo ni bilo poslano: prevelika velikost" + "Novo sporočilo" + "Pogled" + "Slika" + "Ni bilo mogoče najti primerne aplikacije" + "Odstrani prejemnika" + "Novo sporočilo" + "Prekliči" + "Urejanje dostopne točke" + "Ni nastavljeno" + "Ime" + "APN" + "Storitveni center za sporočila MMS" + "Strežnik proxy za sporočila MMS" + "Vrata za sporočila MMS" + "Mobilna koda države" + "Koda mobilnega omrežja" + "Vrsta APN-ja" + "Izbriši APN" + "Nov APN" + "Shrani" + "Zavrzi" + "Polje z imenom ne sme biti prazno." + "APN ne sme biti prazen." + "Polje z mobilno kodo države mora vsebovati 3 števke." + "Polje s kodo mobilnega omrežja mora vsebovati 2 ali 3 števke." + "Obnavljanje privzetih nastavitev APN-ja." + "Ponastavi na privzeto" + "Ponastavitev privzetih nastavitev APN-ja je končana." + "Neimenovano" + "Imena dostopnih točk" + "APN-ji" + "Nov APN" + "Nastavitve imena dostopne točke niso na voljo za tega uporabnika" + "Želite kopirati v odložišče?" + "Kopiraj" + "v kartico %s" + "Splošno" + "Dodatno" + "Splošne nastavitve" + "Dodatne nastavitve" + "Kartica SIM »%s«" + "Pošiljanje posameznih sporočil SMS vsem prejemnikom. Morebitne odgovore boste prejeli samo vi." + "Pošiljanje enega sporočila MMS vsem prejemnikom" + "Neznana številka" + "Novo sporočilo" + "Novo sporočilo." + "Izbirnik kartice SIM" + "Izbrana kartica SIM %1$s, izbirnik kartic SIM" + "Uredi zadevo" + "Izberi kartico SIM ali uredi zadevo" + "Dotakni se in pridrži za snemanje zvoka" + "Začenjanje novega pogovora" + "Sporočila" + "Seznam sporočil" + "Sporočila" + "Novo sporočilo" + "Seznam pogovorov" + "Nalaganje pogovorov" + "Nalaganje sporočil" + "Več pogovorov" + "Prikaz več sporočil" + "Pogovor je izbrisan" + "Pogovor je izbrisan. Dotaknite se, če želite prikazati drug pogovor v Sporočilih." + "Blokiran(-a)" + "Odblokiran(-a)" + "Primanjkuje prostora za shranjevanje. Nekateri podatki bodo morda izgubljeni." + "Izbira prilog" + "Potrditev izbora" + "Št. izbranih: %d" + "Odstranite eno ali več prilog in poskusite znova." + "Lahko poskusite poslati sporočilo, vendar morda ne bo dostavljeno, razen če odstranite eno ali več prilog." + "Pošljete lahko samo en videoposnetek na sporočilo. Odstranite druge videoposnetke in poskusite znova." + "Sporočila niso uspela naložiti priloge." + "Vseeno pošlji" + "Pogovora ni bilo mogoče začeti" + "%1$s (%2$s)" + "Izbrana kartica SIM %s" + diff --git a/res/values-sq-rAL/arrays.xml b/res/values-sq-rAL/arrays.xml new file mode 100644 index 0000000..2744180 --- /dev/null +++ b/res/values-sq-rAL/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "pa subjekt" + "pa subjekt" + + + "Po" + "Jo" + "Në rregull!" + "Hehe" + "Faleminderit" + "Pranoj" + "Këndshëm" + "Jam rrugës" + "Në rregull, më lejo të të marr më vonë" + ":)" + ":(" + + diff --git a/res/values-sq-rAL/strings.xml b/res/values-sq-rAL/strings.xml new file mode 100644 index 0000000..b98ea92 --- /dev/null +++ b/res/values-sq-rAL/strings.xml @@ -0,0 +1,519 @@ + + + + + "Mesazhet" + "Mesazhet" + "Përzgjidh bisedën" + "Cilësimet" + "Dërgo mesazh" + "Shto një bashkëngjitje" + "Ndihma" + "Mirë se erdhe" + "Kapërce" + "Përpara >" + "Përpara" + "Dil" + "Cilësimet >" + "Cilësimet" + "Mesazhet kanë nevojë për leje për në SMS, Telefon dhe Kontakte." + "Lejet mund t\'i ndryshosh te Cilësimet, Aplikacionet, Mesazhet, Lejet." + "Lejet mund t\'i ndryshosh te Cilësimet, Aplikacionet, Mesazhet, Lejet." + "Të shpeshta" + "Të gjitha kontaktet" + "Dërgoje te %s" + "Regjistro fotografi ose video" + "Zgjidh imazhe nga kjo pajisje" + "Regjistro audio" + "Zgjidh foto" + "Artikulli \"media\" u zgjodh." + "Artikulli \"media\" nuk u zgjodh." + "U përzgjodhën %d" + "imazhi %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "imazhi" + "Regjistro audio" + "Shpërnda" + "Pikërisht tani" + "Tani" + + %d minuta + %d minutë + + + %d orë + %d orë + + + %d ditë + %d ditë + + + %d javë + një javë + + + %d muaj + një muaj + + + %d vite + një vit + + "Mesazh i kategorisë 0" + "Ruaj" + "Në pajisje ka mbetur pak hapësirë e lirë. Mesazhet do të fshijnë automatikisht mesazhet e vjetra për të liruar hapësirë." + "Hapësira ruajtëse po mbaron" + "\"Mesazhet\" mund të mos dërgojë ose pranojë mesazhe derisa të ketë hapësirë tjetër të lirë në pajisjen tënde." + "Hapësirë e paktë ruajtëse për SMS-tw. Mund të të duhet të fshish mesazhe." + "Konfirmo numrin tënd të telefonit" + "Ky hap që kryhet vetëm një herë do të garantojë që Mesazhet t\'i dorëzojë siç duhet mesazhet e tua në grup." + "Numri i telefonit" + "Fshiji të gjitha mesazhet me klipe \"media\"" + "Fshi mesazhe më të vjetra se %s" + "Fshi automatikisht mesazhe më të vjetra se %s" + "Shpërfill" + "Të fshihen të gjitha mesazhet me klipe \"media\"?" + "Të fshihen mesazhet më të vjetra se %s?" + "Të fshihen mesazhet më të vjetra se %s dhe të aktivizohet fshirja automatike?" + "%s tha" + "Ti the" + "Mesazh nga %s" + "Dërgove një mesazh" + "Po dërgon..." + "Nuk u dërgua. Prek për të provuar sërish." + "Nuk u dërgua. Po provon sërish…" + "Dërgo sërish ose fshi" + "Bëj një telefonatë me zë drejt shërbimeve të urgjencës. Mesazhi yt me tekst nuk arriti të dërgohej këtë herë." + "Dështoi" + "Mesazh i ri MMS-je për të shkarkuar" + "Mesazh i ri MMS-je" + "Nuk arriti ta shkarkonte" + "Prek për të provuar sërish" + "Prek për të shkarkuar" + "Shkarko ose fshi" + "Po shkarkon…" + "Mesazhi skadoi ose nuk mund të përdoret" + "madhësia: %1$s, skadimi: %2$s" + "Nuk mund ta dërgojë. Marrësi është i pavlefshëm." + "Shërbimi nuk është i aktivizuar në rrjet" + "Nuk arriti ta dërgonte për shkak të një problemit në rrjet" + "Mesazhi skadoi ose nuk mundësohet" + "(Pa subjekt)" + "Dërgues i panjohur" + "Dorëzuar" + "Nuk arriti të shkarkonte mesazhin %1$s nga %2$s." + "Nuk arriti të përfundonte operacionin e bazës së të dhënave për shkak të kujtesës së pakët" + "Mesazhi nuk është dërguar" + "Disa mesazhe nuk janë dërguar te Mesazhet" + + %d mesazhe në %d bashkëbisedime + %d mesazhe në një bashkëbisedim + + "Mesazhi nuk u shkarkua" + "Disa mesazhe nuk janë shkarkuar te Mesazhet" + + %d mesazhe në %d bashkëbisedime + %d mesazhe në një bashkëbisedim + + "Mesazhi për %1$s nuk u dërgua" + "Bëj një telefonatë me zë drejt shërbimeve të urgjencës. Mesazhi yt me tekst për %1$s nuk arriti të dërgohej këtë herë." + + %d mesazhe të reja + Mesazh i ri + + "Fillo" + "Kamera nuk mund të përdoret" + "Kamera nuk mund të përdoret" + "Regjistrimi i videos nuk mundësohet" + "Nuk mund të ruajë materiale \"media\"" + "Nuk mund të nxjerrë fotografi" + "Prapa" + "Arkivuar" + "Fshi" + "Arkivo" + "Hiq nga arkiva" + "Çaktivizo njoftimet" + "Aktivizo njoftimet" + "Shto kontakt" + "Shkarko" + "Dërgo" + "Fshi" + "Të fshihet ky mesazh?" + "Ky veprim nuk mund të zhbëhet." + "Fshi" + + Fshiji këto biseda? + Fshije këtë bisedë? + + "Fshi" + "Anulo" + "Për" + "Përzgjidh shumë imazhe" + "Konfirmo përzgjedhjen" + "+%d" + "Nuk mund të regjistrojë audio. Provo sërish." + "Nuk mund të luajë audio. Provo sërish." + "Nuk arriti ta ruante audion. Provo sërish." + "Prek dhe mbaj shtypur" + ", " + " " + ": " + " " + "Fotografi" + "Klip audioje" + "Video" + "Kartë kontakti" + "Shkarko" + "Përgjigju përmes SMS-së" + "Përgjigju përmes MMS-së" + "Përgjigju" + + %d pjesëmarrës + %d pjesëmarrës + + "Unë" + "Kontakt i bllokuar dhe i arkivuar" + "Kontakti u zhbllokua dhe arkivimi i tij u anulua" + "%d të arkivuara" + "%d të paarkivuara" + "Njoftimet janë të çaktivizuara" + "Njoftimet janë të aktivizuara" + "Gjithçka gati. Prek sërish \"Dërgo\"." + "\"Mesazhet\" u caktua me sukses si aplikacioni me parazgjedhje i SMS-ve." + + Hiq bashkëngjitjet + Hiq bashkëngjitjen + + "Bashkëngjitja audio" + "Luaj bashkëngjitjen në audio" + "Pauzë" + "Mesazh nga %s: %s." + "Mesazh i dështuar nga %s: %s. Ora: %s." + "Mesazh nga %s: %s. Ora: %s." + "Mesazh i padërguar për %s: %s. Ora: %s." + "Po dërgon mesazh te %s: %s. Ora: %s." + "Mesazh i dështuar për %s: %s. Ora: %s." + "Mesazh për %s: %s. Ora: %s." + "Mesazh i dështuar nga %s: %s. Ora: %s. %s." + "Mesazh nga %s: %s. Ora: %s. %s." + "Mesazh i padërguar për %s: %s. Ora: %s." + "Po dërgon mesazh te %s: %s. Ora: %s." + "Mesazh i dështuar për %s: %s. Ora: %s." + "Mesazh për %s: %s. Ora: %s." + "Mesazhi dështoi. Prek për ta provuar sërish." + "Bisedë me %s" + "Fshi subjektin" + "Regjistro video" + "Regjistro një pamje të palëvizshme" + "Nxirr fotografi" + "Fillo regjistrimin në video" + "Kalo në kamerën me ekran të plotë" + "Ndërro mes kamerës para dhe mbrapa" + "Ndalo regjistrimin dhe bashkëngjit video" + "Ndalo regjistrimin e videos" + "Foto me mesazhe" + + %d fotografi u ruajtën tek albumi \"%s\" + %d fotografi u ruajt tek albumi \"%s\" + + + %d video u ruajtën tek albumi \"%s\" + %d video u ruajt tek albumi \"%s\" + + + %d bashkëngjitje u ruajtën tek albumi\"%s\" + %d bashkëngjitje u ruajt tek albumi \"%s\" + + + %d bashkëngjitje u ruajtën tek \"Shkarkimet\" + %d bashkëngjitje u ruajt tek \"Shkarkimet\" + + + %d bashkëngjitje u ruajtën + %d bashkëngjitje u ruajt + + + %d bashkëngjitje nuk mund të ruheshin + %d bashkëngjitje nuk mund të ruhej + + "Bashkëgjitje MMS e ruajtur" + "Cilësimet" + "Arkivuar" + "Mbyll" + "MMS" + "Të përparuara" + "Korrigjo" + "Njoftime" + "Zëri" + "Hesht" + "Dridh" + "I bllokuar" + "Raportet e dorëzimit të SMS" + "Kërko një raport dorëzimi për çdo SMS që dërgon" + "Gjej automatikisht" + "Gjej automatikisht mesazhe MMS" + "Gjetje automatike në roaming" + "Merr automatikisht mesazhin MMS kur je në roaming" + "Mesazhe në grup" + "Përdor \"MMS\" për të dërguar një mesazh të vetëm kur ka shumë marrës" + "Aplikacioni me parazgjedhje i SMS-ve" + "Aplikacioni me parazgjedhje i SMS-ve" + + + "Numri yt i telefonit" + "E panjohur" + "Tingujt e mesazheve dalëse" + "Hidh SMS-të" + "Hidhi të dhënat SMS të papërpunuara në skedarin e hapësirës së jashtme ruajtëse" + "Hidh MMS-të" + "Hidhi të dhënat MMS të papërpunuara në skedarin e hapësirës së jashtme ruajtëse" + "Sinjalizimi pa tel" + "Opsionet e mesazhit" + "Kopjo tekstin" + "Shiko detajet" + "Fshi" + "Përpara" + "Detajet e mesazhit" + "Shkruaj: " + "Mesazh me tekst" + "Mesazh multimedial" + "Nga: " + "Për: " + "Dërguar: " + "Marrë: " + "Subjekti: " + "Madhësia: " + "Përparësia: " + "Karta SIM: " + "E lartë" + "Normale" + "E ulët" + "Numri i kartës SIM: %s" + "Adresë e fshehur e dërguesit" + "Nuk mund të dërgojë mesazh kur ngarkon bashkëngjitje." + "Nuk mund ta ngarkojë bashkëngjitjen. Provo sërish." + "Rrjeti nuk është gati. Provo përsëri." + "Fshi tekstin" + "Kalo mes futjes së tekstit dhe numrave" + "Shto më shumë pjesëmarrës" + "Konfirmo pjesëmarrësit" + "Fillo bisedë të re" + "Përzgjidh këtë artikull" + "Luaj video" + "Kontaktet dhe opsionet" + "Korrigjo" + "Kontaktet dhe opsionet" + "Të përgjithshme" + "Kontakte në këtë bisedë" + "Bëj telefonatë" + "Dërgo mesazh" + "Dërgo mesazh<br/><small>nga %s</small>" + + Dërgo fotografitë + Dërgo fotografinë + + + Dërgo audiot + Dërgo audion + + + Dërgo videot + Dërgo videon + + + Dërgo kartat e kontaktit + Dërgo kartën e kontaktit + + + Dërgo bashkëngjitjet + Dërgo bashkëngjitjen + + + %d bashkëngjitje gati për dërgim + Një bashkëngjitje gati për dërgim + + "Dërgo përshtypjet" + "Shiko në Dyqanin \"Luaj me Google\"" + "Informacion mbi versionin" + "Versioni %1$s" + "Licenca me burim informacioni të hapur" + "Njoftime" + "U arrit kufiri i bashkëngjitjeve" + "Dështoi në ngarkimin e bashkëngjitjes." + "Shto te kontaktet?" + "Shto kontakt" + "Subjekti" + "Subjekti: " + "%s%s" + "Po ngarkon kartën e kontaktit" + "Nuk arriti të ngarkonte kartën e kontakteve" + "Shiko kartën e kontaktit" + + %d kontakte + %d kontakt + + "Kartat e kontakteve" + "Ditëlindje" + "Shënime" + "Transfero mesazhin" + "Përgjigju" + "+1" + "+%d" + "Funksioni \"SMS\" u çaktivizua" + "Për të dërguar, caktoje \"Mesazhet\" si aplikacionin me parazgjedhje të SMS-ve" + "Caktoje \"Mesazhet\" si aplikacionin me parazgjedhje të SMS-ve" + "Ndrysho" + "Për të pranuar mesazhe, caktoje \"Mesazhet\" si aplikacionin me parazgjedhje të SMS-ve" + "Nuk u përzgjodh kartë e preferuar SIM për dërgimin e mesazheve SMS" + "Ky aplikacion nuk lejohet nga zotëruesi i pajisjes." + "Në rregull!" + "Shumë pjesëmarrës në një bisedë" + + Kontakte të pavlefshme + Kontakt i pavlefshëm + + "Nuk arriti të ngarkonte pamjen e kamerës" + "Ti: " + "%s: " + "Draft" + "Pasi të fillosh një bisedë të re, do ta shikosh të listuar këtu" + "Bisedat e arkivuara shfaqen këtu" + "Po ngarkon bisedat…" + "Fotografi" + "Klip audioje" + "Video" + "Kartë kontakti" + "MMS" + "Zhbëj" + "Provo përsëri" + "Fut një emër kontakti ose numër telefoni për të nisur një mesazh të ri" + "Blloko" + "Blloko %s" + "Zhblloko %s" + "Të bllokohet %s?" + "Do të vazhdosh të marrësh mesazhe nga ky numër, por nuk do të njoftohesh më. Kjo bisedë do të arkivohet." + "Kontaktet e bllokuara" + "ZHBLLOKO" + "Kontaktet e bllokuara" + "Zgjidh imazh nga biblioteka e dokumenteve" + "Po dërgon mesazhin" + "Mesazhi u dërgua" + "Të dhënat celulare janë të çaktivizuara. Kontrollo cilësimet." + "Nuk mund të dërgojë mesazhe në modalitetin \"në aeroplan\"" + "Mesazhi nuk u arrit të dërgohej" + "Mesazhi u shkarkua" + "Të dhënat celulare janë të çaktivizuara. Kontrollo cilësimet." + "Nuk mund të shkarkojë mesazhe në modalitetin \"në aeroplan\"" + "Mesazhi nuk u arrit të shkarkohej" + "Zero" + "Një" + "Dy" + "Tre" + "Katër" + "Pesë" + "Gjashtë" + "Shtatë" + "Tetë" + "Nëntë" + "Mesazhi nuk mund të dërgohet me %1$s - gabimi %2$d" + "Mesazhi nuk mund të dërgohet me një operator celular të panjohur - gabimi %1$d" + "Transfero: %s" + "Mesazhi nuk u dërgua - shërbimi nuk është i aktivizuar në rrjet" + "Mesazhi nuk u dërgua - adresë e pavlefshme vendmbërritje" + "Mesazhi nuk u dërgua - mesazh i pavlefshëm" + "Mesazhi nuk u dërgua - përmbajtje e pambështetur" + "Mesazhi nuk u dërgua - mesazh i pambështetur" + "Mesazhi nuk u dërgua: shumë i madh" + "Mesazh i ri" + "Shiko" + "Imazh" + "Nuk arriti të gjente një aplikacion të përshtatshëm" + "Hiq marrësin" + "Mesazh i ri" + "Anulo" + "Redakto pikën e qasjes" + "Nuk është vendosur" + "Emri" + "APN-ja" + "MMSC-ja" + "Përfaqësuesi i MMS-ve" + "Porta e MMS-ve" + "MCC-ja" + "MNC" + "Lloji i APN-së" + "Fshi APN-në" + "APN e re" + "Ruaj" + "Injoro" + "Fusha \"Emri\" nuk të lihet bosh." + "APN-ja nuk mund të lihet bosh." + "Fusha e MCC-së duhet të ketë 3 shifra." + "Fusha e MNC-së duhet të ketë 2 ose 3 shifra." + "Po restauron cilësimet e APN-së me parazgjedhje." + "Rivendos në parazgjedhje" + "Rivendosja e cilësimeve të APN-së me parazgjedhje përfundoi." + "Pa titull" + "Emrat e Pikës së Qasjes" + "APN-të" + "APN e re" + "Cilësimet e Emrit të Pikës së Qasjes nuk mund të përdoren për këtë përdorues" + "Të kopjohet në kujtesën e fragmenteve" + "Kopjo" + "për %s" + "Të përgjithshme" + "Të përparuara" + "Cilësimet e përgjithshme" + "Cilësimet e përparuara" + "Emri i kartës SIM: \"%s\"" + "Dërgoju mesazhe SMS të gjithë marrësve. Vetëm ti do të marrësh përgjigje" + "Dërgoju një MMS të vetme, të gjithë marrësve" + "Numër i panjohur" + "Mesazh i ri." + "Mesazh i ri." + "Përzgjedhësi i kartës SIM" + "%1$s i përzgjedhur, përzgjedhësi i kartës SIM" + "Redakto subjektin" + "Përzgjidh kartën SIM ose redakto subjektin" + "Prek dhe mbaj shtypur për të regjistruar audio" + "Fillo bisedë të re" + "Mesazhet" + "Lista e Mesazheve" + "Mesazhet" + "Mesazh i ri" + "Lista e bisedave" + "Po ngarkon bisedat" + "Po ngarkon mesazhet" + "Shiko më shumë biseda" + "Shiko më shumë mesazhe" + "Biseda është fshirë" + "Biseda u fshi. Prek për të shfaqur një bisedë tjetër te \"Mesazhet\"" + "I bllokuar" + "I zhbllokuar" + "Hapësira ruajtëse është e vogël. Disa të dhëna mund të humbin." + "Përzgjidh bashkëngjitje" + "Konfirmo përzgjedhjen" + "U përzgjodhën %d" + "Hiq një ose më shumë bashkëngjitje dhe provo sërish." + "Mund të provosh ta dërgosh mesazhin, por mund të mos dërgohet pa hequr një ose disa bashkëngjitje." + "Mund të dërgosh vetëm një video për çdo mesazh. Hiqi videot shtesë dhe provo përsëri." + "Mesazhet dështuan në ngarkimin e bashkëngjitjes." + "Dërgoje gjithsesi" + "Nuk arriti të fillonte bisedën" + "%1$s (%2$s)" + "%s u zgjodh" + diff --git a/res/values-sr/arrays.xml b/res/values-sr/arrays.xml new file mode 100644 index 0000000..f099488 --- /dev/null +++ b/res/values-sr/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "без наслова" + "нема наслова" + + + "Да" + "Не" + "Потврди" + "Хе хе" + "Хвала" + "Слажем се" + "Фино" + "Стижем ускоро" + "Добро, јавићу ти се касније" + ":)" + ":(" + + diff --git a/res/values-sr/strings.xml b/res/values-sr/strings.xml new file mode 100644 index 0000000..7155cd9 --- /dev/null +++ b/res/values-sr/strings.xml @@ -0,0 +1,545 @@ + + + + + "Размена порука" + "Размена порука" + "Изаберите преписку" + "Подешавања" + "Пошаљи поруку" + "Додај прилог" + "Помоћ" + "Добро дошли" + "Прескочи" + "Следеће >" + "Даље" + "Изађи" + "Подешавања >" + "Подешавања" + "Размена порука тражи дозволу за SMS, телефон и контакте." + "Можете да мењате дозволе у одељку Подешавања > Апликације > Размена порука > Дозволе." + "Можете да мењате дозволе у одељку Подешавања, Апликације, Размена порука, Дозволе." + "Често контактирани" + "Сви контакти" + "Пошаљи на %s" + "Сними слику или видео снимак" + "Избор слика са овог уређаја" + "Сними звук" + "Изаберите слику" + "Изабрали сте медијски садржај." + "Опозвали сте избор медијског садржаја." + "Изабраних: %d" + "слика %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "слика" + "Снимање звука" + "Дели" + "Управо сад" + "Сада" + + %d мин + %d мин + %d мин + + + %d сат + %d сата + %d сати + + + %d дан + %d дана + %d дана + + + %d недеље + %d недеље + %d недеља + + + %d месеца + %d месеца + %d месеци + + + %d године + %d године + %d година + + "Порука класе 0" + "Сачувај" + "Понестаје простора на уређају. Апликација за размену порука ће аутоматски обрисати старије поруке да би ослободила простор." + "Понестаје складишног простора" + "Размена порука можда неће моћи да шаље нити прима поруке док још простора не буде доступно на уређају." + "Понестаје меморије за SMS. Можда ћете морати да избришете поруке." + "Потврдите број телефона" + "Овим једнократним кораком обезбеђујете да Размена порука правилно испоручује групне поруке." + "Број телефона" + "Избриши све поруке са медијским садржајем" + "Избриши поруке старије од %s" + "Аутоматски бриши поруке старије од %s" + "Игнориши" + "Желите ли да избришете све поруке са медијским садржајем?" + "Желите ли да избришете поруке старије од %s?" + "Желите ли да избришете поруке старије од %s и да укључите аутоматско брисање?" + "%s каже" + "Рекли сте" + "Порука од корисника %s" + "Послали сте поруку" + "Шаље се..." + "Слање није успело. Додирните да бисте покушали поново." + "Слање није успело. Покушавамо поново…" + "Пошаљите поново или избришите" + "Упутите гласовни позив службама за хитне случајеве. Слање SMS-а тренутно није успело." + "Није успело" + "Нова MMS порука за преузимање" + "Нова MMS порука" + "Преузимање није успело" + "Додирните да бисте покушали поново" + "Додирните да бисте преузели" + "Преузмите или избришите" + "Преузима се..." + "Порука је истекла или није доступна" + "величина: %1$s, истиче: %2$s" + "Слање није могуће. Прималац није важећи." + "Услуга није активирана у мрежи" + "Слање није могуће због проблема са мрежом" + "Порука је истекла или није доступна" + "(Без наслова)" + "Непознати пошиљалац" + "Испоручено" + "Није могуће преузети поруку %1$s пошиљаоца %2$s." + "Није могуће довршити операцију у вези са базом података због недостатка меморије" + "Порука није послата" + "Неке поруке нису послате у Размени порука" + + %d поруке(а) у %d конверзацији + %d поруке(а) у %d конверзације + %d поруке(а) у %d конверзација + + "Порука није преузета" + "Неке поруке нису преузете у Размени порука" + + %d поруке(а) у %d конверзацији + %d поруке(а) у %d конверзације + %d поруке(а) у %d конверзација + + "Порука на %1$s није послата" + "Упутите гласовни позив службама за хитне случајеве. Слање SMS-а на %1$s тренутно није успело." + + %d нова порука + %d нове поруке + %d нових порука + + "Покрени" + "Камера није доступна" + "Камера није доступна" + "Снимање видео снимака није доступно" + "Чување медија није могуће" + "Није успело снимање слике" + "Назад" + "Архивирано" + "Избриши" + "Архивирај" + "Опозови архивирање" + "Искључи обавештења" + "Укључи обавештења" + "Додај контакт" + "Преузми" + "Пошаљи" + "Избриши" + "Желите да избришете ову поруку?" + "Ова радња не може да се опозове." + "Избриши" + + Желите ли да избришете ове конверзације? + Желите ли да избришете ове конверзације? + Желите ли да избришете ове конверзације? + + "Избриши" + "Откажи" + "Прима" + "Изаберите више слика" + "Потврди избор" + "+%d" + "Снимање звука није успело. Покушајте поново." + "Пуштање звука није успело. Покушајте поново." + "Чување звука није успело. Покушајте поново." + "Додирните и задржите" + ", " + " " + ": " + " " + "Слика" + "Аудио исечак" + "Видео" + "Картица контакта" + "Преузми" + "Одговори преко SMS-а" + "Одговори MMS-ом" + "Одговорите" + + %d учесник + %d учесника + %d учесника + + "Ја" + "Контакт је блокиран и архивиран" + "Контакт је деблокиран и архивирање је опозвано" + "Архивирано: %d" + "Опозвано је архивирање за: %d" + "Обавештења су искључена" + "Обавештења су укључена" + "Све је спремно. Додирните Пошаљи поново." + "Размена порука је подешена као подразумевана апликација за SMS." + + Одбаци прилоге + Одбаци прилоге + Одбаци прилоге + + "Аудио прилог" + "Пусти аудио прилог" + "Пауза" + "%s вам шаље нову поруку: %s" + "Није примљена порука од корисника %s: %s. Време: %s." + "Порука од корисника %s: %s. Време: %s." + "Порука није послата кориснику %s: %s. Време: %s." + "Шаље се порука кориснику %s: %s. Време: %s." + "Није успело слање поруке кориснику %s: %s. Време: %s." + "Порука кориснику %s: %s. Време: %s." + "Није примљена порука од корисника %s: %s. Време: %s. %s." + "Порука од корисника %s: %s. Време: %s. %s." + "Порука која није послата групи %s: %s. Време: %s." + "Шаље се порука групи %s: %s. Време: %s." + "Није успело слање поруке групи %s: %s. Време: %s." + "Порука групи %s: %s. Време: %s." + "Слање поруке није успело. Додирните да бисте поново покушали." + "Конверзација са учесницима %s" + "Брисање наслова" + "Снимите видео" + "Снимите слику" + "Снимите слику" + "Почните да снимате видео" + "Пређите на камеру целог екрана" + "Пребацујте са предње на задњу камеру и обрнуто" + "Заустави снимање и приложи видео" + "Заустави снимање звука" + "Слике из Размене порука" + + %d слика је сачувана у албуму „%s + %d слике су сачуване у албуму „%s + %d слика је сачувано у албуму „%s + + + %d видео је сачуван у албуму „%s + %d видео снимка су сачувана у албуму „%s + %d видео снимака је сачувано у албуму „%s + + + %d прилог је сачуван у албуму „%s + %d прилога су сачувана у албуму „%s + %d прилога је сачувано у албуму „%s + + + %d прилог је сачуван у директоријуму „Преузимања“ + %d прилога су сачувана у директоријуму „Преузимања“ + %d прилога је сачувано у директоријуму „Преузимања“ + + + %d прилог је сачуван + %d прилога су сачувана + %d прилога је сачувано + + + Нисмо успели да сачувамо %d прилог + Нисмо успели да сачувамо %d прилога + Нисмо успели да сачувамо %d прилога + + "Сачувани прилог MMS-а" + "Подешавања" + "Архивирано" + "Затвори" + "MMS" + "Напредно" + "Отклањање грешака" + "Обавештења" + "Звук" + "Нечујно" + "Вибрација" + "Блокирано" + "Извештаји о испоруци SMS-ова" + "Захтевајте извештај о испоруци за сваки послати SMS" + "Аутоматско преузимање" + "Аутоматско преузимање MMS порука" + "Аутоматски преузимај у ромингу" + "Аутоматски преузимај MMS у ромингу" + "Групна размена порука" + "Користите MMS за слање једне поруке када постоји више прималаца" + "Подразумевана апликација за SMS" + "Подразумевана апликација за SMS" + + + "Број телефона" + "Непознато" + "Звукови за одлазне поруке" + "Направи сирову копију SMS-а" + "Направи сирову копију необрађених података примљеног SMS-а у датотеци спољног складишног простора" + "Направи сирову копију MMS-а" + "Направи сирову копију необрађених података примљеног MMS-а у датотеци спољног складишног простора" + "Обавештења преко мобилне мреже" + "Опције поруке" + "Копирај текст" + "Погледајте детаље" + "Избриши" + "Проследи" + "Детаљи поруке" + "Тип: " + "SMS" + "Мултимедијална порука" + "Од: " + "Коме: " + "Послато: " + "Примљено: " + "Наслов: " + "Величина: " + "Приоритет: " + "SIM картица: " + "Висок" + "Нормалан" + "Низак" + "Отвор за SIM картицу %s" + "Скривена адреса пошиљаоца" + "Не можете да пошаљете поруку док се прилози учитавају." + "Учитавање прилога није успело. Покушајте поново." + "Мрежа није спремна. Покушајте поново." + "Избриши текст" + "Пребацивање између уноса текста и бројева" + "Додај још учесника" + "Потврди учеснике" + "Започни нову конверзацију" + "Изаберите ову ставку" + "Пусти видео" + "Људи и опције" + "Отклони грешке" + "Људи и опције" + "Опште" + "Људи у овој конверзацији" + "Позови" + "Пошаљите поруку" + "Пошаљите поруку<br/><small>са картице %s</small>" + + Пошаљите слике + Пошаљите слике + Пошаљите слике + + + Пошаљите аудио снимке + Пошаљите аудио снимке + Пошаљите аудио снимке + + + Пошаљите видео снимке + Пошаљите видео снимке + Пошаљите видео снимке + + + Пошаљите картице контаката + Пошаљите картице контаката + Пошаљите картице контаката + + + Пошаљите прилоге + Пошаљите прилоге + Пошаљите прилоге + + + %d прилог је спреман за слање + %d прилога су спремна за слање + %d прилога је спремно за слање + + "Пошаљи повратне информације" + "Прикажи у Google Play продавници" + "Информације о верзији" + "Верзија %1$s" + "Лиценце отвореног кода" + "Обавештења" + "Ограничење за прилоге је достигнуто" + "Учитавање прилога није успело." + "Желите ли да додате у контакте?" + "Додај контакт" + "Наслов" + "Наслов: " + "%s%s" + "Учитава се картица контакта" + "Учитавање картице контакта није успело" + "Погледајте картицу контакта" + + %d контакт + %d контакта + %d контаката + + "Картице контаката" + "Рођендан" + "Белешке" + "Проследите поруку" + "Одговорите" + "+1" + "+%d" + "SMS је онемогућен" + "Да бисте слали поруке, подесите Размену порука као подразумевану апликацију за SMS" + "Подесите Размену порука као подразумевану апликацију за SMS" + "Промени" + "Да бисте примали поруке, подесите Размену порука као подразумевану апликацију за SMS" + "Није изабран жељени SIM за слање SMS-ова" + "Власник уређаја не дозвољава ову апликацију." + "Потврди" + "Превише учесника у конверзацији" + + Неважећи контакти + Неважећи контакти + Неважећи контакти + + "Учитавање слике са камере није успело" + "Ви: " + "%s: " + "Недовршена порука" + "Када започнете нову конверзацију, видећете је наведену овде" + "Овде се појављују архивиране преписке" + "Преписке се учитавају..." + "Слика" + "Аудио клип" + "Видео" + "Картица контакта" + "MMS" + "Опозови" + "Покушај поново" + "Унесите име или број телефона контакта да бисте започели нову поруку" + "Блокирај" + "Блокирај %s" + "Деблокирај %s" + "Желите ли да блокирате %s?" + "И даље ћете примати поруке са овог броја, али нећете више добијати обавештења. Ова преписка ће бити архивирана." + "Блокирани контакти" + "ДЕБЛОКИРАЈ" + "Блокирани контакти" + "Изаберите слику из библиотеке докумената" + "Порука се шаље" + "Порука је послата" + "Подаци за мобилне уређаје су искључени. Проверите подешавања." + "Слање порука није могуће у Режиму рада у авиону" + "Порука није послата" + "Порука је преузета" + "Подаци за мобилне уређаје су искључени. Проверите подешавања." + "Није могуће преузимати поруке у режиму рада у авиону" + "Преузимање поруке није успело" + "Нула" + "Један" + "Два" + "Три" + "Четири" + "Пет" + "Шест" + "Седам" + "Осам" + "Девет" + "Није могуће послати поруку помоћу мобилног оператера %1$s, грешка %2$d" + "Није могуће послати поруку помоћу непознатог мобилног оператера, грешка %1$d" + "Fwd: %s" + "Порука није послата: услуга није активирана на мрежи" + "Порука није послата: неважећа одредишна адреса" + "Порука није послата: неважећа порука" + "Порука није послата: неподржани садржај" + "Порука није послата: неподржана порука" + "Порука није послата: превелика је" + "Нова порука" + "Прикажи" + "Слика" + "Проналажење одговарајуће апликације није успело" + "Уклањање примаоца" + "Нова порука" + "Откажи" + "Измените приступну тачку" + "Није подешено" + "Назив" + "Назив приступне тачке" + "MMSC" + "MMS прокси" + "MMS порт" + "MCC" + "MNC" + "Тип назива приступне тачке" + "Избриши назив приступне тачке" + "Нов назив приступне тачке" + "Сачувај" + "Одбаци" + "Поље Назив не сме да буде празно." + "Поље Назив приступне тачке не сме да буде празно." + "Поље MCC мора да садржи 3 цифре." + "Поље MNC мора да садржи 2 или 3 цифре." + "Враћање подразумеваних подешавања назива приступне тачке." + "Ресетуј подразумеване вредности" + "Ресетовање подразумеваних подешавања назива приступне тачке је довршено." + "Ненасловљено" + "Називи приступних тачака" + "Називи приступних тачака" + "Нов назив приступне тачке" + "Подешавања назива приступне тачке нису доступна за овог корисника" + "Желите ли да копирате у привремену меморију?" + "Копирај" + "на картицу %s" + "Опште" + "Напредно" + "Општа подешавања" + "Напредна подешавања" + "„%s“ SIM" + "Шаљите појединачне SMS поруке свим примаоцима. Само ви ћете добијати одговоре" + "Пошаљите један MMS свим примаоцима" + "Непознат број" + "Нова порука" + "Нова порука." + "Бирач SIM картице" + "Изабран је %1$s, бирач SIM картица" + "Измени наслов" + "Изабери SIM или измени наслов" + "Додирните и задржите за снимање звука" + "Започни нову конверзацију" + "Размена порука" + "Листа из Размене порука" + "Размена порука" + "Нова порука" + "Листа преписки" + "Учитавају се конверзације" + "Учитавају се поруке" + "Прикажи још конверзација" + "Прикажи још порука" + "Конверзација је избрисана" + "Конверзација је избрисана. Додирните да бисте приказали неку другу конверзацију Размене порука" + "Блокирано" + "Деблокирано" + "Нема довољно меморијског простора. Неки подаци ће можда бити изгубљени." + "Изабери прилоге" + "Потврди избор" + "Изабраних: %d" + "Уклоните један прилог или више њих и покушајте поново." + "Можете да покушате да пошаљете поруку, али можда неће бити испоручена ако не уклоните један прилог или више њих." + "Можете да пошаљете само један видео по поруци. Уклоните друге видео снимке и покушајте поново." + "Размена порука није успела да учита прилог." + "Ипак пошаљи" + "Започињање преписке није успело" + "%1$s (%2$s)" + "Изабрали сте %s" + diff --git a/res/values-sv/arrays.xml b/res/values-sv/arrays.xml new file mode 100644 index 0000000..8f03e4b --- /dev/null +++ b/res/values-sv/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "inget ämne" + "inget ämne" + + + "Ja" + "Nej" + "OK" + "Hehe" + "Tack" + "Jag accepterar" + "Nice" + "På väg" + "OK, jag återkommer senare" + ":)" + ":(" + + diff --git a/res/values-sv/strings.xml b/res/values-sv/strings.xml new file mode 100644 index 0000000..36ad85c --- /dev/null +++ b/res/values-sv/strings.xml @@ -0,0 +1,519 @@ + + + + + "Sms/mms" + "Sms/mms" + "Välj konversation" + "Inställningar" + "Skicka meddelande" + "Lägg till en bilaga" + "Hjälp" + "Välkommen" + "Hoppa över" + "Nästa >" + "Nästa" + "Stäng" + "Inställningar >" + "Inställningar" + "Meddelanden behöver ha åtkomst till Sms, Telefon och Kontakter." + "Du kan ändra behörigheterna i Inställningar > Appar > Meddelanden > Behörigheter." + "Du kan ändra behörigheterna i Inställningar, Appar, Meddelanden, Behörigheter." + "Frekventa" + "Alla kontakter" + "Skicka till %s" + "Ta bilder eller spela in video" + "Välj bilder från den här enheten" + "Spela in ljud" + "Välj foto" + "Video-/bildobjektet har markerats." + "Video-/bildobjektet har avmarkerats." + "%d har valts" + "bild %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "bild" + "Spela in ljud" + "Dela" + "Nyss" + "Nu" + + %d min + %d min + + + %d timmar + %d timme + + + %d dagar + %d dag + + + %d veckor + en vecka + + + %d månader + en månad + + + %d år + ett år + + "Meddelande, klass 0" + "Spara" + "Det finns ont om utrymme på enheten. Om du skickar och tar emot sms kommer äldre sms att raderas för att göra plats." + "Lagringsutrymmet börjar ta slut" + "Du kan behöva frigöra utrymme på enheten för att kunna skicka eller ta emot sms med Meddelanden." + "Lagringsutrymmet för sms är fullt. Du kan behöva ta bort sms." + "Bekräfta ditt telefonnummer" + "Genom detta engångssteg säkerställer du att dina gruppmeddelanden levereras som de ska med Meddelanden." + "Telefonnummer" + "Ta bort alla meddelanden med mediefiler" + "Ta bort meddelanden som är äldre än %s" + "Ta automatiskt bort meddelanden som är äldre än %s" + "Ignorera" + "Vill du ta bort alla meddelanden med media?" + "Vill du ta bort meddelanden som är äldre än %s?" + "Vill du ta bort meddelanden som är äldre än %s och aktivera automatisk borttagning?" + "%s sade" + "Du sade" + "Meddelande från %s" + "Du har skickat ett meddelande" + "Skickar …" + "Det gick inte att skicka. Tryck om du vill försöka igen." + "Det gick inte att skicka. Försöker igen ..." + "Skicka igen eller ta bort" + "Ring larmcentralen. Det gick inte att skicka ett sms just nu." + "Misslyckades" + "Nya MMS att ladda ned" + "Nytt MMS" + "Nedladdningen genomfördes inte" + "Tryck om du vill försöka igen" + "Ladda ned genom att trycka" + "Ladda ned eller radera" + "Hämtar ..." + "Meddelandet har upphört att gälla eller är inte tillgängligt" + "storlek: %1$s, finns kvar till: %2$s" + "Det gick inte att skicka. Mottagaren är inte giltig." + "Tjänsten är inte aktiverad i nätverket" + "Det gick inte att skicka på grund av nätverksproblem" + "Meddelandet har upphört att gälla eller är inte tillgängligt." + "(Inget ämne)" + "Okänd avsändare" + "Levererat" + "Det gick inte att ladda ned meddelandet %1$s från %2$s." + "Det gick inte att slutföra åtgärden i databasen på grund av för låg minneskapacitet" + "Meddelandet har inte skickats" + "Vissa meddelanden skickades inte i Meddelanden" + + %d meddelanden i %d konversationer + %d meddelanden i en konversation + + "Meddelandet har inte laddats ned" + "Vissa meddelanden laddades inte ned i Meddelanden" + + %d meddelanden i %d konversationer + %d meddelanden i en konversation + + "Meddelandet till %1$s skickades inte" + "Ring larmcentralen. Det gick inte att skicka ett sms till %1$s just nu." + + %d nya meddelanden + Nytt meddelande + + "Börja" + "Kameran är inte tillgänglig" + "Kameran är inte tillgänglig" + "Det går inte att spela in video" + "Det går inte att spara media" + "Det går inte att fota" + "Föregående" + "Arkiverat" + "Ta bort" + "Arkivera" + "Ångra arkivering" + "Inaktivera aviseringar" + "Aktivera aviseringar" + "Lägg till kontakt" + "Ladda ned" + "Skicka" + "Ta bort" + "Vill du ta bort meddelandet?" + "Åtgärden kan inte ångras." + "Ta bort" + + Vill du ta bort dessa konversationer? + Vill du ta bort konversationen? + + "Ta bort" + "Avbryt" + "Till" + "Markera flera bilder" + "Bekräfta ditt val" + "+%d" + "Det gick inte att spela in ljud. Försök igen." + "Det gick inte att spela upp ljudet. Försök igen." + "Det gick inte att spara ljud. Försök igen." + "Tryck länge" + ", " + " " + ": " + " " + "Bild" + "Ljudklipp" + "Video" + "Visitkort" + "Ladda ned" + "Svara via sms" + "Svara via mms" + "Svara" + + %d deltagare + %d deltagare + + "Jag" + "Kontakten har blockerats och arkiverats" + "Kontakten har återaktiverats och hämtats från arkivet" + "%d har arkiverats" + "%d arkiveringar har ångrats" + "Aviseringar har inaktiverats" + "Aviseringar har aktiverats" + "Allt klart. Tryck på Skicka igen." + "Meddelanden har angetts som standardapp för sms." + + Släng bilagor + Släng bilaga + + "Ljudbilaga" + "Spela upp ljudbilaga" + "Pausa" + "Meddelande från %s: %s." + "Meddelande som inte kunde skickas från %s: %s. Tidpunkt: %s." + "Meddelande från %s: %s. Tidpunkt: %s." + "Meddelande som inte har skickats till %s: %s. Tidpunkt: %s." + "Skickar meddelande till %s: %s. Tidpunkt: %s." + "Meddelande som inte kunde skickas till %s: %s. Tidpunkt: %s." + "Meddelande till %s: %s. Tidpunkt: %s." + "Meddelande som inte kunde skickas från %s: %s. Tidpunkt: %s. %s." + "Meddelande från %s: %s. Tidpunkt: %s. %s." + "Meddelande som inte har skickats till %s: %s. Tidpunkt: %s." + "Skickar meddelande till %s: %s. Tidpunkt: %s." + "Meddelande som inte kunde skickas till %s: %s. Tidpunkt: %s." + "Meddelande till %s: %s. Tidpunkt: %s." + "Det här meddelandet gick inte att skicka. Tryck om du vill försöka igen." + "Samtal med %s" + "Radera ämne" + "Spela in video" + "Ta en stillbild" + "Ta bild" + "Börja spela in video" + "Byt till fullskärmskamera" + "Växla mellan främre och bakre kamera" + "Stoppa inspelningen och bifoga en video" + "Sluta spela in video" + "Skicka foton med Meddelanden" + + %d foton har sparats i albumet %s + %d foto har sparats i albumet %s + + + %d videor har sparats i albumet %s + %d video har sparats i albumet %s + + + %d bilagor har sparats i albumet %s + %d bilaga har sparats i albumet %s + + + %d bilagor har sparats i Nedladdningar + %d bilaga har sparats i Nedladdningar + + + %d bilagor sparas + %d bilaga har sparats + + + Det gick inte att spara %d bilagor + Det gick inte att spara %d bilaga + + "Mms-bilaga har sparats" + "Inställningar" + "Arkiverat" + "Stäng" + "Mms" + "Avancerat" + "Felsökning" + "Aviseringar" + "Ljud" + "Tyst" + "Vibration" + "Blockerad" + "Sms-leveransrapporter" + "Begär en leveransrapport för varje sms du skickar" + "Automatisk hämtning" + "Hämta mms-meddelanden automatiskt" + "Autohämtning vid roaming" + "Hämta mms automatiskt vid roaming" + "Gruppmeddelanden" + "Använd mms för att skicka ett meddelande när det finns flera mottagare" + "Standardapp för sms" + "Standardapp för sms" + + + "Ditt telefonnummer" + "Okänt" + "Ljud vid utgående meddelande" + "Dumpa sms" + "Dumpa rådata för mottagna sms i en extern lagringsfil" + "Dumpa mms" + "Dumpa rådata för mottagna mms i en extern lagringsfil" + "Trådlösa varningar" + "Meddelandealternativ" + "Kopiera text" + "Visa mer information" + "Ta bort" + "Vidarebefordra" + "Meddelandeinformation" + "Typ: " + "Sms" + "Mms" + "Från: " + "Till: " + "Skickat: " + "Mottaget: " + "Ämne: " + "Storlek: " + "Prioritet: " + "SIM: " + "Hög" + "Normal" + "Låg" + "SIM %s" + "Avsändaradress dold" + "Kan inte skicka meddelandet samtidigt som bilagor läses in." + "Det gick inte att läsa in bilagan. Försök igen." + "Nätverket är inte klart. Försök igen." + "Ta bort text" + "Växla mellan att ange text och siffror" + "Lägg till fler deltagare" + "Bekräfta deltagare" + "Starta ny konversation" + "Välj det här objektet" + "Spela upp video" + "Personer och alternativ" + "Felsökning" + "Personer och alternativ" + "Allmänt" + "Personer i den här konversationen" + "Ring ett samtal" + "Skicka meddelande" + "Skicka meddelandet<br/><small>från %s</small>" + + Skicka foton + Skicka foto + + + Skicka ljud + Skicka ljud + + + Skicka videor + Skicka video + + + Skicka kontaktkort + Skicka kontaktkort + + + Skicka bilagor + Skicka bilaga + + + %d bilagor är klara att skicka + En bilaga är klar att skicka + + "Skicka feedback" + "Visa i Google Play Butik" + "Version" + "Version %1$s" + "Licenser, öppen källkod" + "Aviseringar" + "Gränsen för bilagor har uppnåtts" + "Det gick inte att läsa in bilagan." + "Vill du lägga till detta i Kontakter?" + "Lägg till kontakt" + "Ämne" + "Ämne: " + "%s%s" + "Läser in visitkort" + "Det gick inte att läsa in kontaktkortet" + "Visa visitkort" + + %d kontakter + %d kontakt + + "Kontaktkort" + "Födelsedatum" + "Anteckningar" + "Vidarebefordra meddelande" + "Svara" + "+ 1" + "+%d" + "Sms har inaktiverats" + "Ange Meddelanden som standardapp för sms om du vill skicka" + "Ange Meddelanden som standardapp för sms" + "Ändra" + "Ställ in Meddelanden som standardapp för sms om du vill ta emot meddelanden" + "Inget SIM-kort har angetts som primärt för att skicka sms" + "Appen tillåts inte av enhetsägaren." + "OK" + "För många deltagare i en konversation" + + Ogiltiga kontakter + Ogiltig kontakt + + "Det gick inte att läsa in kamerabild" + "Du: " + "%s: " + "Utkast" + "När du startar en ny konversation listas den här" + "Arkiverade konversationer visas här" + "Läser in konversationer …" + "Bild" + "Ljudklipp" + "Video" + "Visitkort" + "Mms" + "Ångra" + "Försök igen" + "Ange namnet på en kontakt eller ett telefonnummer för att starta ett nytt meddelande" + "Blockera" + "Blockera %s" + "Återaktivera %s" + "Vill du blockera %s?" + "Du kan fortfarande få meddelanden från det här numret, men du får inga fler aviseringar. Den här konversationen arkiveras." + "Blockerade kontakter" + "ÅTERAKTIVERA" + "Blockerade kontakter" + "Välj en bild från dokumentbiblioteket" + "Skickar meddelande" + "Meddelandet har skickats" + "Mobildata har inaktiverats. Kontrollera inställningarna." + "Det går inte att skicka meddelanden i flygplansläge" + "Meddelandet kunde inte skickas" + "Meddelandet har laddats ned" + "Mobildata har inaktiverats. Kontrollera inställningarna." + "Det går inte att ladda ned meddelanden i flygplansläge" + "Det gick inte att ladda ned meddelandet" + "Noll" + "Ett" + "Två" + "Tre" + "Fyra" + "Fem" + "Sex" + "Sju" + "Åtta" + "Nio" + "Det gick inte att skicka meddelandet med %1$s, fel %2$d" + "Det gick inte att skicka meddelandet med okänd operatör, fel %1$d" + "Vidarebefordra: %s" + "Meddelandet har inte skickats: tjänsten är inte aktiverad i nätverket" + "Meddelandet skickades inte: ogiltig e-postadress" + "Meddelandet skickades inte: ogiltigt meddelande" + "Meddelandet skickades inte: inget stöd för innehållet" + "Meddelandet skickades inte: inget stöd för meddelandet" + "Meddelandet skickades inte: för stort" + "Nytt meddelande" + "Visa" + "Bild" + "Ingen app för detta hittades" + "Ta bort mottagare" + "Nytt meddelande" + "Avbryt" + "Redigera åtkomstpunkt" + "Har inte angetts" + "Namn" + "APN" + "MMSC" + "MMS-proxy" + "MMS-port" + "MCC" + "MNC" + "APN-typ" + "Ta bort APN" + "Nytt APN" + "Spara" + "Ignorera" + "Fältet Namn får inte vara tomt." + "APN får inte vara tomt." + "MCC-fältet måste bestå av 3 siffror." + "MNC-fältet måste bestå av 2 eller 3 siffror." + "Återställer standardinställningar för APN." + "Återställ standardinställningarna" + "Standardinställningar för APN har återställts." + "Namnlös" + "Åtkomstpunktens namn (APN)" + "APN:er" + "Nytt APN" + "Inställningarna för åtkomstpunktens namn är inte tillgängliga för den här användaren" + "Vill du kopiera till urklipp?" + "Kopiera" + "till %s" + "Allmänt" + "Avancerat" + "Allmänna inställningar" + "Avancerade inställningar" + "SIM-kortet %s" + "Skicka enskilda sms till alla mottagare. Det är endast du som får de eventuella svaren." + "Skicka samma mms till alla mottagare." + "Okänt nummer" + "Nytt meddelande" + "Nytt meddelande." + "SIM-kortsväljaren" + "%1$s har valts, SIM-kortsväljare" + "Redigera ämne" + "Välj SIM-kort eller redigera ämne" + "Tryck länge för att spela in ljud" + "Starta ny konversation" + "Meddelanden" + "Meddelandelista" + "Meddelanden" + "Nytt meddelande" + "Konversationslista" + "Konversationer hämtas" + "Läser in meddelanden" + "Visa fler konversationer" + "Visa fler meddelanden" + "Konversationen har raderats" + "Konversationen raderades. Tryck här om du vill visa en annan konversation i Meddelanden." + "Blockerad" + "Återaktiverad" + "Lagringsutrymmet är nästan fullt. All data kanske inte sparas." + "Välj bilagor" + "Bekräfta ditt val" + "%d har valts" + "Ta bort en eller flera av bilagorna och försök igen." + "Du kan testa att skicka meddelandet, men det kanske inte kommer fram om du inte tar bort en eller flera av bilagorna först." + "Det går bara att skicka en video per meddelande. Ta bort ytterligare videor och försök igen." + "Det gick inte att läsa in bilagan i Meddelanden." + "Skicka ändå" + "Det gick inte att starta en konversation" + "%1$s (%2$s)" + "%s har valts" + diff --git a/res/values-sw/arrays.xml b/res/values-sw/arrays.xml new file mode 100644 index 0000000..1641f55 --- /dev/null +++ b/res/values-sw/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "kichwa hakijaongezwa" + "kichwa hakijaongezwa" + + + "Ndiyo" + "Hapana" + "Sawa" + "Hehe" + "Asante" + "Nakubali" + "Shwari" + "Niko njiani" + "Sawa, nitawasiliana na wewe baadaye" + ":)" + ":(" + + diff --git a/res/values-sw/strings.xml b/res/values-sw/strings.xml new file mode 100644 index 0000000..b415367 --- /dev/null +++ b/res/values-sw/strings.xml @@ -0,0 +1,519 @@ + + + + + "Utumaji ujumbe" + "Utumaji ujumbe" + "Chagua mazungumzo" + "Mipangilio" + "Tuma Ujumbe" + "Ongeza kiambatisho" + "Usaidizi" + "Karibu" + "Ruka" + "Inayofuata >" + "Inayofuata" + "Ondoka" + "Mipangilio >" + "Mipangilio" + "Programu ya Ujumbe inahitaji ruhusa za SMS, Simu na Anwani." + "Unaweza kubadilisha ruhusa katika Mipangilio> Programu> Ujumbe> Ruhusa." + "Unaweza kubadilisha ruhusa katika Mipangilio, Programu, Ujumbe, Ruhusa." + "Mara kwa mara" + "Anwani zote" + "Tuma kwa %s" + "Piga picha au urekodi video" + "Chagua picha kutoka kifaa hiki" + "Rekodi sauti" + "Chagua picha" + "Maudhui yameteuliwa." + "Maudhui yameondolewa uteuzi." + "%d zimechaguliwa" + "picha %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "picha" + "Rekodi sauti" + "Shiriki" + "Sasa hivi tu" + "Sasa" + + Dakika %d + Dakika %d + + + Saa %d + Saa %d + + + Siku %d + Siku %d + + + wiki %d + wiki + + + miezi %d + mwezi + + + miaka %d + mwaka + + "Ujumbe wa tabaka 0" + "Hifadhi" + "Nafasi haitoshi kwenye kifaa. Programu ya Ujumbe itafuta kiotomatiki ujumbe wa zamani ili kuacha nafasi kwenye kifaa." + "Nafasi ya kuhafadhi inakaribia kujaa" + "Huenda programu ya Ujumbe isitume au kupokea ujumbe hadi nafasi zaidi ipatikane katika kifaa chako." + "Hifadhi ya SMS ni ndogo. Itakubidi ufute ujumbe." + "Thibitisha nambari yako ya simu" + "Hatua hii ya kufanywa mara moja itahakikisha kuwa programu ya Ujumbe itawasilisha ujumbe wako wa kikundi bila tatizo." + "Nambari ya simu" + "Futa ujumbe wote ulio na picha, sauti au video" + "Futa ujumbe uliokaa zaidi ya %s" + "Futa kiotomatiki ujumbe uliokaa zaidi ya %s" + "Puuza" + "Je, ungependa kufuta ujumbe wote ulio na maudhui?" + "Je, ungependa kufuta ujumbe uliokaa zaidi ya %s?" + "Je, ungependa kufuta ujumbe uliokaa zaidi ya %s na uwashe kufuta kiotomatiki?" + "%s alisema" + "Ulisema" + "Ujumbe kutoka %s" + "Ulituma ujumbe" + "Inatuma…" + "Haikutumwa. Gusa ili ujaribu tena." + "Haikutumwa. Jaribu tena..." + "Tuma tena au ufute" + "Tafadhali piga simu ya sauti kwa huduma za dharura. Ujumbe wako haukuweza kupokelewa kwa wakati huu." + "Imeshindikana" + "Ujumbe mpya wa MMS wa kupakua" + "Ujumbe mpya wa MMS" + "Haikuweza kupakua" + "Gusa ili ujaribu tena" + "Gusa ili upakue" + "Pakua au ufute" + "Inapakua…" + "Ujumbe umepitwa na wakati au haupo" + "ukubwa: %1$s, mwisho: %2$s" + "Haiwezi kutuma. Mpokeaji si sahihi." + "Huduma haijaanza kutumika kwenye mtandao." + "Haikutuma kwa sababu ya hitilafu ya mtandao" + "Ujumbe umepitwa na wakati au haupo" + "(Kichwa hakijaongezwa)" + "Mtumaji asiyejulikana" + "Umewasilishwa" + "Haikuweza kupakua ujumbe %1$s kutoka %2$s." + "Haikuweza kukamilisha utendaji wa hifadhidata kwa sababu ya kumbukumbu ndogo" + "Ujumbe haujatumwa" + "Baadhi ya ujumbe haukutumwa katika programu ya Ujumbe" + + Ujumbe %d katika mazungumzo %d + Ujumbe %d katika mazungumzo moja + + "Ujumbe haujapakuliwa" + "Baadhi ya ujumbe haukupakuliwa katika programu ya Ujumbe" + + Ujumbe %dkatika mazungumzo %d + Ujumbe %d katika mazungumzo moja + + "Ujumbe kwa %1$s haukutumwa" + "Tafadhali piga simu ya sauti kwa huduma za dharura. Ujumbe wako kwa %1$s haukuweza kupokelewa kwa wakati huu." + + Ujumbe %d mpya + Ujumbe mpya + + "Anza" + "Kamera haipatikani" + "Kamera haipatikani" + "Kurekodi video hakupatikani" + "Haiwezi kuhifadhi maudhui ya picha au video" + "Haiwezi kupiga picha" + "Nyuma" + "Imewekwa kwenye kumbukumbu" + "Futa" + "Kumbukumbu" + "Ondoa kwenye kumbukumbu" + "Zima arifa" + "Washa arifa" + "Ongeza anwani" + "Pakua" + "Tuma" + "Futa" + "Je, ungependa kufuta ujumbe huu?" + "Kitendo hiki hakiwezi kutenduliwa." + "Futa" + + Ungependa kufuta mazungumzo haya? + Ungependa kufuta mazungumzo haya? + + "Futa" + "Ghairi" + "Kwa" + "Chagua picha nyingi" + "Thibitisha uteuzi" + "%d+" + "Haiwezi kurekodi sauti. Jaribu tena." + "Haiwezi kucheza sauti. Jaribu tena." + "Haikuweza kuhifadhi sauti. Jaribu tena." + "Gusa na ushikilie" + ", " + " " + ": " + " " + "Picha" + "Klipu ya sauti" + "Video" + "Kadi ya anwani" + "Pakua" + "Jibu kupitia SMS" + "Jibu kupitia MMS" + "Jibu" + + Washiriki %d + Mshiriki %d + + "Mimi" + "Anwani imezuiwa na imewekwa kwenye kumbukumbu" + "Anwani imeacha kuzuiliwa na imetolewa kwenye kumbukumbu" + "%d yamewekwa kwenye kumbukumbu" + "%d yameondolewa kwenye kumbukumbu" + "Arifa zimezimwa" + "Arifa zimewashwa" + "Imewekwa. Gusa Tuma tena." + "Umefaulu kuweka Programu ya Ujumbe kuwa programu ya chaguo-msingi ya SMS." + + Tupa viambatisho + Tupa kiambatisho + + "Kiambatisho cha sauti" + "Cheza kiambatisho cha sauti" + "Sitisha" + "Ujumbe kutoka kwa %s: %s." + "Ujumbe ulioshindikana kutoka kwa %s: %s. Saa: %s." + "Ujumbe kutoka %s: %s. Saa: %s." + "Ujumbe ambao haukutumwa kwa %s: %s. Saa: %s." + "Inatuma ujumbe kwa %s: %s. Saa: %s." + "Ujumbe ulioshindikana kwa %s: %s. Saa: %s." + "Ujumbe kwa %s: %s. Saa: %s." + "Ujumbe ulioshindikana kutoka kwa %s: %s. Saa: %s. %s." + "Ujumbe kutoka %s: %s. Saa: %s. %s." + "Ujumbe ambao haukutumwa kwa %s: %s. Saa: %s." + "Inatuma ujumbe kwa %s: %s. Saa: %s." + "Ujumbe ulioshindikana kwenda kwa %s: %s. Saa: %s." + "Ujumbe kwa %s: %s. Saa: %s." + "Ujumbe ulishindikana. Gusa ili ujaribu tena." + "Mazungumzo na %s" + "Futa kichwa" + "Rekodi video" + "Piga picha ya mnato" + "Piga picha" + "Anza kurekodi video" + "Badili utumie kamera ya skrini nzima" + "Badili kati ya kamera ya nyuma na mbele" + "Acha kurekodi na kuambatisha video" + "Acha kurekodi video" + "Picha za programu ya Ujumbe" + + Picha %d zimehifadhiwa kwenye albamu ya \"%s\" + Picha %d imehifadhiwa kwenye albamu ya \"%s\" + + + Video %d zimehifadhiwa kwenye albamu ya \"%s\" + Video %d imehifadhiwa kwenye albamu ya \"%s\" + + + Viambatisho %d vimehifadhiwa kwenye albamu ya \"%s\" + Kiambatisho %d kimehifadhiwa kwenye albamu ya \"%s\" + + + Viambatisho %d vimehifadhiwa kwenye \"Vipakuliwa\" + Kiambatisho %d kimehifadhiwa kwenye \"Vipakuliwa\" + + + Imehifadhi viambatisho %d + Imehifadhi kiambatisho %d + + + Haikuweza kuhifadhi viambatisho %d + Haikuweza kuhifadhi kiambatisho %d + + "Kiambatisho cha MMS kimehifadhiwa" + "Mipangilio" + "Imewekwa kwenye kumbukumbu" + "Funga" + "MMS" + "Mahiri" + "Tatua" + "Arifa" + "Mlio" + "Kimya" + "Tetema" + "Imezuiwa" + "Ripoti za kupokelewa kwa SMS" + "Omba ripoti ya kuthibitisha kuwa kila SMS unayotuma imewasilishwa" + "Pakua kiotomatiki" + "Rejesha ujumbe wa MMS kiotomatiki" + "Rejesha kiotomatiki wakati wa kutumia mitandao mingine" + "Rejesha MMS kiotomatiki unapotumia mitandao mingine" + "Utumaji ujumbe kwa kikundi" + "Tumia MMS kutuma ujumbe mmoja wakati kuna wapokeaji wengi" + "Programu chaguo-msingi ya SMS" + "Programu chaguo-msingi ya SMS" + + + "Nambari yako ya simu" + "Isiyojulikana" + "Sauti za ujumbe unaotumwa" + "Tupa SMS" + "Tupa data ghafi za SMS zilizopokelewa kwenye faili ya hifadhi ya nje" + "Tupa ujumbe wa MMS" + "Tupa data ghafi za ujumbe wa MMS uliopokelewa kwenye faili ya hifadhi ya nje" + "Arifa ya dharura" + "Chaguo za ujumbe" + "Nakili maandishi" + "Tazama maelezo" + "Futa" + "Sambaza" + "Maelezo ya ujumbe" + "Aina: " + "Ujumbe wa maandishi" + "Ujumbe anuai" + "Kutoka: " + "Kwa: " + "Imetumwa: " + "Imepokelewa: " + "Kichwa: " + "Ukubwa: " + "Kipaumbele: " + "SIM: " + "Juu" + "Kawaida" + "Chini" + "SIM %s" + "Anwani ya mtumaji iliyofichwa" + "Haiwezi kutuma ujumbe wakati inapakiwa viambatisho." + "Haiwezi kupakia kiambatisho. Jaribu tena." + "Mtandao hauko tayari. Jaribu tena." + "Futa ujumbe" + "Badili kati ya kuweka maandishi na nambari" + "Ongeza washiriki zaidi" + "Thibitisha washiriki" + "Anzisha mazungumzo mapya" + "Chagua bidhaa hii" + "Cheza video" + "Watu na chaguo" + "Tatua" + "Watu na chaguo" + "Jumla" + "Watu katika mazungumzo haya" + "Piga simu" + "Tuma ujumbe" + "Tuma ujumbe<br/><small>kutoka %s</small>" + + Tuma picha + Tuma picha + + + Tuma sauti + Tuma sauti + + + Tuma video + Tuma video + + + Tuma kadi za anwani + Tuma kadi ya anwani + + + Tuma viambatisho + Tuma kiambatisho + + + Viambatisho %d viko tayari kutumwa + Kiambatisho kimoja kiko tayari kutumwa + + "Tuma maoni" + "Angalia katika Duka la Google Play" + "Maelezo kuhusu toleo hili" + "Toleo la %1$s" + "Leseni za programu huria" + "Arifa" + "Kikomo cha kiambatisho kilifikiwa" + "Imeshindwa kupakia kiambatisho." + "Ungependa kuongeza kwenye Anwani?" + "Ongeza Anwani" + "Kichwa" + "Kichwa: " + "%s%s" + "Inapakia kadi ya anwani" + "Haikuweza kupakia kadi ya anwani" + "Angalia kadi ya anwani" + + Anwani %d + Anwani %d + + "Kadi za mawasiliano" + "Siku ya kuzaliwa" + "Madokezo" + "Sambaza ujumbe" + "Jibu" + "+1" + "+%d" + "SMS imezimwa" + "Ili utume ujumbe, weka programu ya Ujumbe kuwa programu ya chaguo-msingi ya SMS" + "Weka programu ya Ujumbe kuwa programu ya chaguo-msingi ya SMS" + "Badilisha" + "Ili kupokea ujumbe, weka programu ya Ujumbe iwe programu ya chaguo-msingi ya SMS" + "Hakuna SIM inayopendelewa kwa kutuma ujumbe wa SMS iliyochaguliwa" + "Programu hii hairuhusiwi na mmiliki kifaa." + "SAWA" + "Washiriki wengi mno katika mazungumzo" + + Anwani zisizo sahihi + Anwani isiyo sahihi + + "Haikuweza kupakia picha ya kamera" + "Wewe: " + "%s: " + "Rasimu" + "Utakapoanzisha mazungumzo mapya, utayaona yameorodheshwa hapa" + "Mazungumzo yaliyowekwa kwenye kumbukumbu huonekana hapa" + "Inapakia mazungumzo..." + "Picha" + "Klipu ya sauti" + "Video" + "Kadi ya anwani" + "MMS" + "Tendua" + "Jaribu tena" + "Andika jina la anwani au nambari ya simu ili uanze ujumbe mpya" + "Zuia" + "Zuia %s" + "Ondoa kizuizi kwa %s" + "Ungependa kuzuia %s?" + "Utaendelea kupokea ujumbe kutoka nambari hii ila hutapewa arifa tena. Mazungumzo haya yatawekwa kwenye kumbukumbu." + "Anwani zilizozuiwa" + "ONDOA KIZUIZI" + "Anwani zilizozuiwa" + "Chagua picha kutoka maktaba ya hati" + "Inatuma ujumbe" + "Ujumbe umetumwa" + "Data ya simu za mkononi imezimwa. Angalia mipangilio yako." + "Haiwezi kutuma ujumbe katika Hali ya ndegeni" + "Ujumbe haukutumwa" + "Ujumbe umepakuliwa" + "Data ya simu za mkononi imezimwa. Angalia mipangilio yako." + "Haiwezi kupakua ujumbe katika Hali ya ndegeni" + "Ujumbe haukuweza kupakuliwa" + "Sifuri" + "Moja" + "Mbili" + "Tatu" + "Nne" + "Tano" + "Sita" + "Saba" + "Nane" + "Tisa" + "Huwezi kutuma ujumbe ukitumia %1$s, hitilafu %2$d" + "Huwezi kutuma ujumbe ukitumia mtoa huduma asiyejulikana, hitilafu %1$d" + "Fwd: %s" + "Ujumbe haukutumwa: huduma haijaanza kutumika kwenye mtandao." + "Ujumbe haujatumwa: anwani ya unakotuma si sahihi" + "Ujumbe haujatumwa: ujumbe usio sahihi" + "Ujumbe haujatumwa: maudhui yasiyoweza kutumika" + "Ujumbe haujatumwa: ujumbe usioweza kutumika" + "Ujumbe haujatumwa: ni mkubwa mno" + "Ujumbe mpya" + "Tazama" + "Picha" + "Haikuweza kupata programu inayofaa" + "Ondoa mpokeaji" + "Ujumbe mpya" + "Ghairi" + "Badilisha mahali pa kufikia" + "Haijawekwa" + "Jina" + "APN" + "MMSC" + "Proksi ya MMS" + "Mlango wa MMS" + "MCC" + "MNC" + "Aina ya APN" + "Futa APN" + "APN Mpya" + "Hifadhi" + "Tupa" + "Sehemu hii haiwezi kuachwa ikiwa haijajazwa." + "APN haiwezi kuwa tupu." + "Sehemu ya MCC lazima iwe na nambari 3." + "Sehemu ya MNC lazima iwe na nambari 2 au 3." + "Inarejesha mipangilio mbadala ya APN" + "Rudisha kwenye chaguo-msingi" + "Kuweka upya kwa mipangilio mbadala ya APN kumekamilika" + "Haina Kichwa" + "Majina ya Lango la Mtandao" + "APN" + "APN Mpya" + "Mipangilio ya Jina la Lango la Mtandao (APN) haipatikani kwa mtumiaji huyu" + "Je, ungependa kunakili kwenye ubao klipu?" + "Nakili" + "kwa %s" + "Jumla" + "Mahiri" + "Mipangilio ya jumla" + "Mipangilio ya kina" + "SIM \"%s\"" + "Tuma ujumbe binafsi wa SMS kwa wapokeaji wote. Wewe tu ndiye utakayepata majibu yoyote" + "Tuma MMS moja kwa wapokeaji wote" + "Nambari isiyojulikana" + "Ujumbe mpya" + "Ujumbe mpya." + "Kichagua SIM" + "%1$s imechaguliwa, Kichagua SIM" + "Badilisha kichwa" + "Chagua SIM au ubadilishe kichwa" + "Gusa na ushikilie ili urekodi sauti" + "Anzisha mazungumzo mapya" + "Ujumbe" + "Orodha ya Ujumbe" + "Ujumbe" + "Ujumbe mpya" + "Orodha ya mazungumzo" + "Inapakia mazungumzo" + "Inapakia ujumbe" + "Tazama mazungumzo zaidi" + "Angalia ujumbe zaidi" + "Mazungumzo yamefutwa" + "Mazungumzo yamefutwa. Gusa ili uonyeshe mazungumzo tofauti ya programu ya Ujumbe" + "Amezuiwa" + "Ameondolewa kizuizi" + "Nafasi ya hifadhi ni ndogo. Unaweza kupoteza baadhi ya data." + "Chagua viambatisho" + "Thibitisha uteuzi" + "%d vimechaguliwa" + "Tafadhali ondoa moja au zaidi ya viambatisho na ujaribu tena." + "Unaweza kujaribu kutuma ujumbe wako, lakini huenda usitumwe hadi uondoe kiambatisho kimoja au zaidi." + "Unaweza kutuma video moja pekee kwa kila ujumbe. Tafadhali ondoa video za ziada na ujaribu tena." + "Programu ya Ujumbe haikupakia kiambatisho." + "Tuma tu" + "Haikuweza kuanza mazungumzo" + "%1$s (%2$s)" + "%s imechaguliwa" + diff --git a/res/values-ta-rIN/arrays.xml b/res/values-ta-rIN/arrays.xml new file mode 100644 index 0000000..9953b60 --- /dev/null +++ b/res/values-ta-rIN/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "தலைப்பு இல்லை" + "தலைப்பு இல்லை" + + + "ஆம்" + "இல்லை" + "சரி" + "ஹிஹி" + "நன்றி" + "ஆம்" + "அருமை" + "வந்துகொண்டிருக்கிறேன்" + "பின்னர் அழைக்கிறேன்" + ":)" + ":(" + + diff --git a/res/values-ta-rIN/strings.xml b/res/values-ta-rIN/strings.xml new file mode 100644 index 0000000..e107ffe --- /dev/null +++ b/res/values-ta-rIN/strings.xml @@ -0,0 +1,519 @@ + + + + + "மெசேஜ்" + "மெசேஜ்" + "உரையாடலைத் தேர்ந்தெடுக்கவும்" + "அமைப்பு" + "செய்தி அனுப்பு" + "இணைப்பைச் சேர்" + "உதவி" + "நல்வரவு" + "தவிர்" + "அடுத்து >" + "அடுத்து" + "வெளியேறு" + "அமைப்புகள் >" + "அமைப்புகள்" + "செய்தியிடலுக்கு SMS, ஃபோன் மற்றும் தொடர்புகள் ஆகியவற்றுக்கான அனுமதி தேவை." + "அமைப்புகள் > பயன்பாடுகள் > செய்தியிடல் > அனுமதிகள் என்பதில் அனுமதிகளை மாற்றலாம்." + "அமைப்புகள், பயன்பாடுகள், செய்தியிடல், அனுமதிகள் என்பதில் அனுமதிகளை மாற்றலாம்." + "அடிக்கடி தொடர்புகொண்டவர்கள்" + "எல்லா தொடர்புகளும்" + "%s க்கு அனுப்பு" + "படங்கள் அல்லது வீடியோவை எடுக்கவும்" + "இந்தச் சாதனத்தில் இருந்து படங்களைத் தேர்வு செய்யவும்" + "ஆடியோவைப் பதிவுசெய்" + "படத்தைத் தேர்வுசெய்" + "மீடியா தேர்ந்தெடுக்கப்பட்டது." + "மீடியா தேர்வுநீக்கப்பட்டது." + "%d தேர்ந்தெடுக்கப்பட்டது" + "படம் %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "படம்" + "ஆடியோவைப் பதிவுசெய்" + "பகிர்" + "சற்றுமுன்" + "இப்போது" + + %d நிமிடங்கள் + %d நிமிடம் + + + %d மணிநேரம் + %d மணிநேரம் + + + %d நாட்கள் + %d நாள் + + + %d வாரங்கள் + ஒரு வாரம் + + + %d மாதங்கள் + ஒரு மாதம் + + + %d ஆண்டுகள் + ஒரு ஆண்டு + + "கிளாஸ் 0 செய்தி" + "சேமி" + "சாதனத்தில் இடம் குறைவாக உள்ளது. இடத்தைக் காலியாக்க, செய்தியிடலானது பழைய உரைச் செய்திகளை தானாகவே நீக்கும்." + "சேமிப்பிடம் குறைகிறது" + "உங்கள் சாதனத்தில் கூடுதல் இடம் கிடைக்கும் வரை செய்தியிடல் செய்திகளை அனுப்பாமல் அல்லது பெறாமல் இருக்கக் கூடும்." + "SMS சேமிப்பகம் குறைவாக உள்ளது. நீங்கள் செய்திகளை நீக்க வேண்டியிருக்கலாம்." + "உங்கள் ஃபோன் எண்ணை உறுதிசெய்யவும்" + "இந்த ஒரு முறை செயல்பாடானது, செய்தியிடல் உங்கள் குழுச் செய்திகளைச் சரியாக வழங்குவதை உறுதிப்படுத்தும்." + "ஃபோன் எண்" + "மீடியாவுடனான செய்திகளை நீக்கு" + "%s க்கு முந்தைய செய்திகளை நீக்கு" + "%s காலத்திற்கு முந்தைய செய்திகளைத் தானாகவே நீக்கு" + "புறக்கணி" + "மீடியாவுடனான எல்லா செய்திகளையும் நீக்க வேண்டுமா?" + "%s காலத்திற்கு முந்தைய செய்திகளை நீக்க வேண்டுமா?" + "%s காலத்திற்கு முந்தைய செய்திகளை நீக்கி, தானாக நீக்கும் அம்சத்தை இயக்கவா?" + "%s கூறியது" + "நீங்கள் கூறியது" + "%s இடமிருந்து செய்தி" + "செய்தி அனுப்பியுள்ளீர்கள்" + "அனுப்புகிறது…" + "அனுப்பவில்லை. மீண்டும் முயல, தொடவும்." + "அனுப்பவில்லை. மீண்டும் முயல்கிறது…" + "மீண்டும் அனுப்பு அல்லது நீக்கு" + "அவசரகால சேவைகளை அழைக்கவும். உங்கள் உரைச் செய்தியை தற்போது வழங்க முடியவில்லை." + "தோல்வி" + "இறக்க, புதிய MMS செய்தி உள்ளது" + "புதிய MMS செய்தி" + "இறக்க முடியவில்லை" + "மீண்டும் முயல, தொடவும்" + "இறக்க, தொடவும்" + "இறக்கு அல்லது நீக்கு" + "இறக்குகிறது…" + "செய்தி காலாவதியானது அல்லது இல்லை" + "அளவு: %1$s, காலாவதியாகும் தேதி: %2$s" + "அனுப்ப முடியவில்லை. பெறுநர் விவரம் தவறாக உள்ளது." + "நெட்வொர்க்கில் சேவை இயக்கப்படவில்லை" + "நெட்வொர்க் சிக்கல் காரணமாக அனுப்ப முடியவில்லை" + "செய்தி காலாவதியானது அல்லது இல்லை" + "(தலைப்பு இல்லை)" + "அறியப்படாத அனுப்புநர்" + "டெலிவரி செய்யப்பட்டது" + "%2$s இலிருந்து %1$s செய்தியைப் பதிவிறக்க முடியவில்லை." + "குறைந்த நினைவகம் காரணமாகத் தரவுத்தளச் செயல்பாட்டை முடிக்க முடியவில்லை" + "செய்தி அனுப்பப்படவில்லை" + "செய்தியிடலில் சில செய்திகள் அனுப்பப்படவில்லை" + + %d உரையாடல்களில் %d செய்திகள் உள்ளன + ஒரு உரையாடலில் %d செய்திகள் உள்ளன + + "செய்தி இறக்கப்படவில்லை" + "செய்தியிடலில் சில செய்திகள் இறக்கப்படவில்லை" + + %d உரையாடல்களில் %d செய்திகள் உள்ளன + ஒரு உரையாடலில் %d செய்திகள் உள்ளன + + "%1$sக்குச் செய்தி அனுப்பப்படவில்லை" + "அவசரகால சேவைகளை அழைக்கவும். %1$s_1க்கு நீங்கள் அனுப்பிய செய்தியை தற்போது வழங்க முடியவில்லை." + + %d புதிய செய்திகள் + புதிய செய்தி + + "தொடங்கு" + "கேமரா இல்லை" + "கேமரா இல்லை" + "வீடியோ படமெடுக்க முடியாது" + "மீடியாவைச் சேமிக்க முடியவில்லை" + "படமெடுக்க முடியாது" + "முந்தையது" + "காப்பகப்படுத்தப்பட்டன" + "நீக்கு" + "காப்பிடு" + "காப்பிட வேண்டாம்" + "அறிவிப்புகளை முடக்கு" + "அறிவிப்புகளை இயக்கு" + "தொடர்பைச் சேர்" + "இறக்கு" + "அனுப்பு" + "நீக்கு" + "இந்தச் செய்தியை நீக்கவா?" + "இந்தச் செயலை பின்னர் செயல்தவிர்க்க முடியாது." + "நீக்கு" + + உரையாடல்களை நீக்கவா? + உரையாடலை நீக்கவா? + + "நீக்கு" + "ரத்துசெய்" + "பெறுநர்" + "பல படங்களைத் தேர்ந்தெடு" + "தேர்வை உறுதிப்படுத்து" + "+%d" + "ஆடியோவைப் பதிவு செய்ய இயலவில்லை. மீண்டும் முயலவும்." + "ஆடியோவை இயக்க முடியவில்லை. மீண்டும் முயலவும்." + "ஆடியோவைச் சேமிக்க முடியவில்லை. மீண்டும் முயலவும்." + "தொட்டுப் பிடித்திருக்கவும்" + ", " + " " + ": " + " " + "படம்" + "ஆடியோ கிளிப்" + "வீடியோ" + "தொடர்பு கார்டு" + "இறக்கு" + "SMS மூலம் பதிலளி" + "MMS மூலம் பதிலளி" + "பதிலளி" + + %d பங்கேற்பாளர்கள் + %d பங்கேற்பாளர் + + "எனக்கு" + "தொடர்பு தடுக்கப்பட்டு காப்பகப்படுத்தப்பட்டது" + "தொடர்பு தடைநீக்கப்பட்டுள்ளது & பட்டியலில் தோன்றும்" + "%d காப்பகப்படுத்தப்பட்டன" + "%d பட்டியலில் தோன்றும்" + "அறிவிப்புகள் முடக்கப்பட்டுள்ளன" + "அறிவிப்புகள் இயக்கப்பட்டுள்ளன" + "எல்லாம் முடிந்தது. மீண்டும் அனுப்பு என்பதைத் தொடவும்." + "செய்தியிடல் இயல்பு SMS பயன்பாடாக அமைக்கப்பட்டது." + + இணைப்புகளை நிராகரி + இணைப்பை நிராகரி + + "ஆடியோ இணைப்பு" + "ஆடியோ இணைப்பை இயக்கவும்" + "இடைநிறுத்து" + "செய்தி அனுப்பியவர்: %s - %s." + "%sக்கு அனுப்பிய செய்தி தோல்வி: %s. நேரம்: %s." + "%s அனுப்பிய செய்தி: %s. நேரம்: %s." + "%sக்கு அனுப்பப்படாத செய்தி: %s. நேரம்: %s." + "%sக்குச் செய்தி அனுப்புகிறது: %s. நேரம்: %s." + "%sக்கு அனுப்பிய செய்தி தோல்வி: %s. நேரம்: %s." + "%sக்கான செய்தி: %s. நேரம்: %s." + "%sக்கு அனுப்பிய செய்தி தோல்வி: %s. நேரம்: %s. %s." + "%s இடமிருந்து செய்தி: %s. நேரம்: %s. %s." + "%sக்கு அனுப்பப்படாத செய்தி: %s. நேரம்: %s." + "%sக்குச் செய்தி அனுப்புகிறது: %s. நேரம்: %s." + "%sக்கு அனுப்பிய செய்தி தோல்வி: %s. நேரம்: %s." + "%sக்கான செய்தி: %s. நேரம்: %s." + "செய்தி அனுப்புவதில் தோல்வி. மீண்டும் முயற்சிக்க, தொடவும்." + "%s உடனான உரையாடல்" + "தலைப்பை நீக்கு" + "வீடியோ எடு" + "ஸ்டில் படமெடு" + "படமெடு" + "வீடியோவைப் படமெடு" + "முழுத் திரை கேமராவிற்கு மாறு" + "முன் மற்றும் பின்பக்க கேமராவிற்கு இடையே மாறு" + "பதிவுசெய்வதை நிறுத்தி வீடியோவை இணைக்கவும்" + "வீடியோ பதிவு செய்வதை நிறுத்து" + "செய்தியிடல் படங்கள்" + + %d படங்கள், \"%s\" ஆல்பத்தில் சேமிக்கப்பட்டன + %d படம், \"%s\" ஆல்பத்தில் சேமிக்கப்பட்டது + + + %d வீடியோக்கள், \"%s\" ஆல்பத்தில் சேமிக்கப்பட்டன + %d வீடியோ, \"%s\" ஆல்பத்தில் சேமிக்கப்பட்டது + + + %d இணைப்புகள், \"%s\" ஆல்பத்தில் சேமிக்கப்பட்டன + %d இணைப்பு, \"%s\" ஆல்பத்தில் சேமிக்கப்பட்டது + + + %d இணைப்புகள், \"இறக்கங்கள்\" என்பதில் சேமிக்கப்பட்டன + %d இணைப்பு, \"இறக்கங்கள்\" என்பதில் சேமிக்கப்பட்டது + + + %d இணைப்புகள் சேமிக்கப்பட்டன + %d இணைப்பு சேமிக்கப்பட்டது + + + %d இணைப்புகளைச் சேமிக்க முடியவில்லை + %d இணைப்பைச் சேமிக்க முடியவில்லை + + "சேமித்த MMS இணைப்பு" + "அமைப்பு" + "காப்பகப்படுத்தப்பட்டன" + "மூடு" + "MMS" + "மேம்பட்டவை" + "பிழைத்திருத்து" + "அறிவிப்புகள்" + "ஒலி" + "நிசப்தம்" + "அதிர்வு" + "தடுக்கப்பட்டது" + "SMS டெலிவரி அறிக்கைகள்" + "நீங்கள் அனுப்பும் ஒவ்வொரு SMS க்கும் வழங்கல் அறிக்கையைக் கோரவும்" + "தானாக மீட்டெடு" + "தானாக MMS செய்திகளை மீட்டெடு" + "ரோமிங்கில் தானாக மீட்டெடு" + "ரோமிங்கின் போது தானாகவே MMSஐ மீட்டெடு" + "குழு மெசேஜ்" + "ஒரு செய்தியைப் பல பெறுநர்களுக்கு அனுப்புவதற்கு MMS ஐப் பயன்படுத்து" + "இயல்புநிலை SMS பயன்பாடு" + "இயல்புநிலை SMS பயன்பாடு" + + + "மொபைல் எண்" + "தெரியாதது" + "வெளிச்செல்லும் செய்தி ஒலிகள்" + "SMS ஐ நகலெடு" + "பெற்ற SMS அசல் தரவை வெளிப்புறச் சேமிப்பகக் கோப்பில் நகலெடு" + "MMS ஐ நகலெடு" + "பெற்ற MMS அசல் தரவை வெளிப்புறச் சேமிப்பகச் கோப்பில் நகலெடு" + "வயர்லெஸ் விழிப்பூட்டல்கள்" + "செய்தி விருப்பங்கள்" + "உரையை நகலெடு" + "விவரங்களைக் காட்டு" + "நீக்கு" + "முன்னனுப்பு" + "செய்தி விவரங்கள்" + "வகை: " + "உரைச் செய்தி" + "மல்டிமீடியா செய்தி" + "அனுப்புநர்: " + "பெறுநர்: " + "அனுப்பியது: " + "பெறப்பட்டது: " + "தலைப்பு: " + "அளவு: " + "முன்னுரிமை: " + "SIM: " + "உயர்தரம்" + "இயல்பு" + "குறைந்த தரம்" + "SIM %s" + "மறைவிலிருக்கும் அனுப்புநர் முகவரி" + "இணைப்புகளை ஏற்றும் போது செய்தியை அனுப்ப முடியாது." + "இணைப்பை ஏற்ற முடியவில்லை. மீண்டும் முயலவும்." + "நெட்வொர்க் தயாராக இல்லை. மீண்டும் முயற்சிக்கவும்." + "உரையை நீக்கு" + "உரை மற்றும் எண்களை உள்ளிடுவதன் இடையே மாறு" + "மேலும் பங்கேற்பாளர்களைச் சேர்" + "பங்கேற்பாளர்களை உறுதிப்படுத்தவும்" + "புதிய உரையாடலைத் தொடங்கு" + "இதனைத் தேர்வுசெய்" + "வீடியோவை இயக்கு" + "நபர்களும் விருப்பங்களும்" + "பிழைத்திருத்து" + "நபர்களும் விருப்பங்களும்" + "பொது" + "உரையாடலில் உள்ளவர்கள்" + "அழை" + "செய்தி அனுப்பு" + "செய்தி அனுப்ப:<br/><small>%s</small>" + + படங்களை அனுப்பு + படத்தை அனுப்பு + + + ஆடியோக்களை அனுப்பு + ஆடியோவை அனுப்பு + + + வீடியோக்களை அனுப்பு + வீடியோவை அனுப்பு + + + தொடர்பு கார்டுகளை அனுப்பு + தொடர்பு கார்டை அனுப்பு + + + இணைப்புகளை அனுப்பு + இணைப்பை அனுப்பு + + + %d இணைப்புகள் அனுப்புவதற்குத் தயாராக உள்ளன + ஒரு இணைப்பு அனுப்புவதற்குத் தயாராக உள்ளது + + "கருத்தை அனுப்பு" + "Google Play Store இல் காட்டு" + "பதிப்புத் தகவல்" + "பதிப்பு %1$s" + "ஓப்பன் சோர்ஸ் உரிமங்கள்" + "அறிவிப்புகள்" + "இணைப்பிற்கான வரம்பை அடைந்துவிட்டீர்கள்" + "இணைப்பை ஏற்றுவதில் தோல்வி." + "தொடர்புகளில் சேர்க்கவா?" + "தொடர்பைச் சேர்" + "தலைப்பு" + "தலைப்பு: " + "%s%s" + "தொடர்பு கார்டை ஏற்றுகிறது" + "தொடர்பு கார்டை ஏற்ற முடியவில்லை" + "தொடர்பு கார்டைக் காட்டு" + + %d தொடர்புகள் + %d தொடர்பு + + "தொடர்பு கார்டுகள்" + "பிறந்தநாள்" + "குறிப்புகள்" + "செய்தியை முன்னனுப்பு" + "பதிலளிக்கவும்" + "+1" + "+%d" + "SMS முடக்கப்பட்டது" + "அனுப்ப, இயல்பு SMS பயன்பாடாக செய்தியிடலை அமைக்கவும்" + "இயல்பு SMS பயன்பாடாக செய்தியிடலை அமைக்கவும்" + "மாற்று" + "செய்திகளைப் பெற, இயல்பு SMS பயன்பாடாக செய்தியிடலை அமைக்கவும்" + "SMS செய்திகளை அனுப்புவதற்கு விருப்பமான சிம் எதுவும் தேர்ந்தெடுக்கப்படவில்லை" + "சாதன உரிமையாளர் இந்தப் பயன்பாட்டை அனுமதிக்கவில்லை." + "சரி" + "உரையாடலில் பலர் உள்ளனர்" + + தவறான தொடர்புகள் + தவறான தொடர்பு + + "கேமரா படத்தை ஏற்ற முடியவில்லை" + "நீங்கள்: " + "%s: " + "வரைவு" + "புதிய உரையாடலைத் தொடங்கியதும், அது இங்கு பட்டியலிடப்படும்" + "காப்பகப்படுத்திய உரையாடல்கள் இங்கே தோன்றும்" + "உரையாடல்களை ஏற்றுகிறது…" + "படம்" + "ஆடியோ கிளிப்" + "வீடியோ" + "தொடர்பு கார்டு" + "MMS" + "செயல்தவிர்" + "மீண்டும் முயற்சிக்கவும்" + "புதிய செய்தி உருவாக்க, தொடர்பின் பெயர் அல்லது மொபைல் எண்ணை உள்ளிடவும்" + "தடு" + "%sஐத் தடு" + "%s இன் தடுப்பை நீக்கு" + "%sஐத் தடைசெய்யவா?" + "தொடர்ந்து இந்த எண்ணில் இருந்து செய்திகளைப் பெறுவீர்கள், அது குறித்து இனி உங்களுக்கு அறிவிக்கப்படாது. உரையாடல் காப்பகப்படுத்தப்படும்." + "தடுக்கப்பட்ட தொடர்புகள்" + "தடுப்பை நீக்கு" + "தடுக்கப்பட்ட தொடர்புகள்" + "ஆவண நூலகத்தில் இருந்து படத்தைத் தேர்வு செய்யவும்" + "செய்தி அனுப்பப்படுகிறது" + "செய்தி அனுப்பப்பட்டது" + "செல்லுலார் தரவு முடக்கப்பட்டுள்ளது. அமைப்புகளைச் சரிபார்க்கவும்." + "விமானப் பயன்முறையில் செய்திகளை அனுப்ப முடியாது" + "செய்தியை அனுப்ப முடியவில்லை" + "செய்தி பதிவிறக்கப்பட்டது" + "செல்லுலார் தரவு முடக்கப்பட்டுள்ளது. அமைப்புகளைச் சரிபார்க்கவும்." + "விமானப் பயன்முறையில் செய்திகளை இறக்க முடியாது" + "செய்தி இறக்கப்படவில்லை" + "பூஜ்ஜியம்" + "ஒன்று" + "இரண்டு" + "மூன்று" + "நான்கு" + "ஐந்து" + "ஆறு" + "ஏழு" + "எட்டு" + "ஒன்பது" + "%1$s மூலம் செய்தியை அனுப்ப முடியவில்லை, பிழை %2$d" + "அறியப்படாத மொபைல் நிறுவனம் மூலம் செய்தியை அனுப்ப முடியவில்லை, பிழை %1$d" + "அனுப்பு: %s" + "செய்தி அனுப்பப்படவில்லை: நெட்வொர்க்கில் சேவை செயல்படுத்தப்படவில்லை" + "செய்தி அனுப்பப்படவில்லை: தவறான இலக்கு முகவரி" + "செய்தி அனுப்பப்படவில்லை: தவறான செய்தி" + "செய்தி அனுப்பப்படவில்லை: ஆதரிக்கப்படாத உள்ளடக்கம்" + "செய்தி அனுப்பப்படவில்லை: ஆதரிக்கப்படாத செய்தி" + "செய்தி அனுப்பப்படவில்லை: மிகப் பெரியது" + "புதிய செய்தி" + "காட்டு" + "படம்" + "தகுந்த பயன்பாட்டைக் கண்டுபிடிக்க இயலவில்லை" + "பெறுநரை அகற்று" + "புதிய செய்தி" + "ரத்துசெய்" + "ஆக்சஸ் பாயிண்ட்டைத் திருத்து" + "அமைக்கப்படவில்லை" + "பெயர்" + "APN" + "MMSC" + "MMS ப்ராக்ஸி" + "MMS போர்ட்" + "MCC" + "MNC" + "APN வகை" + "APNஐ நீக்கு" + "புதிய APN" + "சேமி" + "நிராகரி" + "பெயர் புலம் வெறுமையாக இருக்கக்கூடாது." + "APN வெறுமையாக இருக்கக்கூடாது." + "MCC புலம் கண்டிப்பாக 3 இலக்கங்களில் இருக்க வேண்டும்." + "MNC புலம் கண்டிப்பாக 2 அல்லது 3 இலக்கங்களில் இருக்க வேண்டும்." + "இயல்புநிலை APN அமைப்புகளை மீட்டமைக்கிறது." + "இயல்புநிலைக்கு மீட்டமை" + "இயல்புநிலை APN அமைப்புகளை மீட்டமைப்பது முடிந்தது." + "பெயரிடப்படாதது" + "ஆக்சஸ் பாயிண்ட் நேம்கள்" + "APNகள்" + "புதிய APN" + "இவரால் ஆக்சஸ் பாயிண்ட் நேம் அமைப்புகளை மாற்ற முடியாது" + "கிளிப்போர்டுக்கு நகலெடுக்கவா?" + "நகலெடு" + "%s" + "பொது" + "மேம்பட்டவை" + "பொது அமைப்பு" + "மேம்பட்ட அமைப்பு" + "\"%s\" சிம்" + "எல்லா பெறுநர்களுக்கும் SMS செய்திகளைத் தனித்தனியாக அனுப்பவும். நீங்கள் மட்டுமே பதில்களைப் பெறுவீர்கள்" + "எல்லா பெறுநர்களுக்கும் MMS செய்திகளைத் தனித்தனியாக அனுப்பவும்" + "தெரியாத எண்" + "புதிய செய்தி" + "புதிய செய்தி." + "சிம் தேர்வி" + "%1$s தேர்ந்தெடுக்கப்பட்டது, சிம் தேர்வி" + "தலைப்பைத் திருத்து" + "சிம்மைத் தேர்ந்தெடுக்கவும் அல்லது தலைப்பைத் திருத்தவும்" + "ஆடியோவைப் பதிவுசெய்ய, தொட்டுப் பிடித்திருக்கவும்" + "புதிய உரையாடலைத் தொடங்கு" + "செய்தியிடல்" + "செய்தியிடல் பட்டியல்" + "செய்தியிடல்" + "புதிய செய்தி" + "உரையாடல் பட்டியல்" + "உரையாடல்களை ஏற்றுகிறது" + "செய்திகளை ஏற்றுகிறது" + "மேலும் உரையாடல்களைக் காட்டு" + "கூடுதல் செய்திகளைக் காட்டு" + "உரையாடல் நீக்கப்பட்டது" + "உரையாடல் நீக்கப்பட்டது. வேறொரு செய்தியிடல் உரையாடலைக் காட்ட, தொடவும்" + "தடுக்கப்பட்டார்" + "தடுப்பு நீக்கப்பட்டார்" + "சேமிப்பகம் குறைவாக உள்ளது. சில தரவை இழக்க நேரிடலாம்." + "இணைப்புகளைத் தேர்ந்தெடு" + "தேர்வை உறுதிப்படுத்து" + "%d தேர்ந்தெடுக்கப்பட்டன" + "ஒன்று அல்லது அதற்கு மேற்பட்ட இணைப்புகளை அகற்றிவிட்டு, முயற்சிக்கவும்." + "நீங்கள் செய்தியை அனுப்ப முயற்சிக்கலாம், ஆனால் ஒன்று அல்லது அதற்கு மேற்பட்ட இணைப்புகளை நீக்கும் வரை இது வழங்கப்படாமல் இருக்கலாம்." + "ஒரு செய்தியில் ஒரு வீடியோவை மட்டுமே நீங்கள் அனுப்ப முடியும். கூடுதல் வீடியோக்களை அகற்றிவிட்டு, மீண்டும் முயற்சிக்கவும்." + "செய்தியிடலால் இணைப்பை ஏற்ற முடியவில்லை." + "எப்படியேனும் அனுப்பு" + "உரையாடலைத் தொடங்க முடியவில்லை" + "%1$s (%2$s)" + "%s தேர்ந்தெடுக்கப்பட்டது" + diff --git a/res/values-te-rIN/arrays.xml b/res/values-te-rIN/arrays.xml new file mode 100644 index 0000000..c18a1b6 --- /dev/null +++ b/res/values-te-rIN/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "విషయం లేదు" + "విషయం లేదు" + + + "అవును" + "వద్దు" + "సరే" + "హెహే" + "ధన్యవాదాలు" + "నేను అంగీకరిస్తున్నాను" + "బాగుంది" + "నేను ఆ పని మీదే ఉన్నాను" + "సరే, నేను మీతో తర్వాత మాట్లాడుతాను" + ":)" + ":(" + + diff --git a/res/values-te-rIN/strings.xml b/res/values-te-rIN/strings.xml new file mode 100644 index 0000000..4a58e0f --- /dev/null +++ b/res/values-te-rIN/strings.xml @@ -0,0 +1,519 @@ + + + + + "మెసేజింగ్" + "మెసేజింగ్" + "సంభాషణను ఎంచుకోండి" + "సెట్టింగ్‌లు" + "సందేశం పంపు" + "జోడింపును జోడించండి" + "సహాయం" + "స్వాగతం" + "దాటవేయి" + "తదుపరి >" + "తదుపరి" + "నిష్క్రమించండి" + "సెట్టింగ్‌లు >" + "సెట్టింగ్‌లు" + "సందేశ సేవకు SMS, ఫోన్ మరియు పరిచయాల ప్రాప్యత అనుమతి ఇవ్వడం అవసరం." + "మీరు సెట్టింగ్‌లు > అనువర్తనాలు > సందేశ సేవ > అనుమతులులో అనుమతులను మార్చవచ్చు." + "మీరు సెట్టింగ్‌లు, అనువర్తనాలు, సందేశ సేవ, అనుమతులులో అనుమతులను మార్చవచ్చు." + "తరచుగా సంప్రదించినవి" + "అన్ని పరిచయాలు" + "%sకి పంపండి" + "చిత్రాలను లేదా వీడియోను సంగ్రహించు" + "ఈ పరికరం నుండి చిత్రాలను ఎంచుకోండి" + "ఆడియోను రికార్డ్ చేయి" + "ఫోటోను ఎంచుకోండి" + "మీడియా ఎంచుకోబడింది." + "మీడియా ఎంపిక తీసివేయబడింది." + "%d ఎంచుకోబడింది" + "చిత్రం %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "చిత్రం" + "ఆడియోను రికార్డ్ చేయి" + "భాగస్వామ్యం చేయి" + "ఇప్పుడే" + "ఇప్పుడు" + + %d నిమి + %d నిమి + + + %d గంటలు + %d గంట + + + %d రోజులు + %d రోజు + + + %d వారాలు + ఒక వారం + + + %d నెలలు + ఒక నెల + + + %d సంవత్సరాలు + ఒక సంవత్సరం + + "తరగతి 0 సందేశం" + "సేవ్ చేయి" + "పరికరంలో ఖాళీ స్థలం తక్కువగా ఉంది. స్థలం ఖాళీ చేయడానికి సందేశ సేవ స్వయంచాలకంగా పాత సందేశాలను తొలగిస్తుంది." + "నిల్వ స్థలం అయిపోతోంది" + "మీ పరికరంలో మరింత ఖాళీ స్థలం లభించే వరకు సందేశ సేవ సందేశాలను పంపలేకపోవచ్చు లేదా స్వీకరించలేకపోవచ్చు." + "SMS నిల్వ తక్కువగా ఉంది. మీరు సందేశాలను తొలగించాల్సి రావచ్చు." + "మీ ఫోన్ నంబర్‌ను నిర్ధారించండి" + "ఈ ఒక పర్యాయ దశ సందేశ సేవ మీ సమూహ సందేశాలను సరిగ్గా బట్వాడా చేస్తుందని నిర్ధారిస్తుంది." + "ఫోన్ నంబర్" + "మీడియాలోని అన్ని సందేశాలను తొలగించండి" + "%s కంటే మునుపు ఉన్న సందేశాలను తొలగించండి" + "%s కంటే పాత సందేశాలను స్వయంచాలకంగా తొలగించు" + "విస్మరించు" + "మీడియా ఉన్న అన్ని సందేశాలను తొలగించాలా?" + "%s కంటే పాత సందేశాలను తొలగించాలా?" + "%s కంటే పాత సందేశాలను తొలగించి, స్వయంచాలక తొలగింపుని ఆన్ చేయాలా?" + "%s చెప్పారు" + "మీరు చెప్పారు" + "%s నుండి సందేశం" + "మీరు సందేశాన్ని పంపారు" + "పంపుతోంది..." + "పంపబడలేదు. మళ్లీ ప్రయత్నించడానికి తాకండి." + "పంపబడలేదు. మళ్లీ ప్రయత్నిస్తోంది..." + "మళ్లీ పంపండి లేదా తొలగించండి" + "దయచేసి అత్యవసర సేవలకు వాయిస్ కాల్ చేయండి. మీ వచన సందేశాన్ని ప్రస్తుతం బట్వాడా చేయడం సాధ్యపడలేదు." + "విఫలమైంది" + "డౌన్‌లోడ్ చేయడానికి కొత్త MMS సందేశం" + "కొత్త MMS సందేశం" + "డౌన్‌లోడ్ చేయడం సాధ్యపడలేదు" + "మళ్లీ ప్రయత్నించడానికి తాకండి" + "డౌన్‌లోడ్ చేయడానికి తాకండి" + "డౌన్‌లోడ్ చేయండి లేదా తొలగించండి" + "డౌన్‌లోడ్ చేస్తోంది…" + "సందేశం గడువు ముగిసింది లేదా అందుబాటులో లేదు" + "పరిమాణం: %1$s, గడువు: %2$s" + "పంపడం సాధ్యపడదు. చెల్లని స్వీకర్త." + "నెట్‌వర్క్‌లో సేవ సక్రియం చేయబడలేదు" + "నెట్‌వర్క్ సమస్య కారణంగా పంపడం సాధ్యపడలేదు" + "సందేశం గడువు ముగిసింది లేదా అందుబాటులో లేదు" + "(విషయం లేదు)" + "పంపినవారు తెలియదు" + "బట్వాడా చేయబడింది" + "%2$s నుండి %1$s సందేశాన్ని డౌన్‌లోడ్ చేయడం సాధ్యపడలేదు." + "మెమరీ తక్కువగా ఉన్న కారణంగా డేటాబేస్ నిర్వహణను పూర్తి చేయడం సాధ్యపడలేదు" + "సందేశం పంపబడలేదు" + "సందేశ సేవలో కొన్ని సందేశాలు పంపబడలేదు" + + %d సంభాషణల్లో %d సందేశాలు + ఒక సంభాషణలో %d సందేశాలు + + "సందేశం డౌన్‌లోడ్ కాలేదు" + "సందేశ సేవలో కొన్ని సందేశాలు డౌన్‌లోడ్ కాలేదు" + + %d సంభాషణల్లో %d సందేశాలు + ఒక సంభాషణలో %d సందేశాలు + + "%1$sకి సందేశం పంపబడలేదు" + "దయచేసి అత్యవసర సేవలకు వాయిస్ కాల్ చేయండి. మీరు %1$sకి పంపిన వచన సందేశాన్ని ప్రస్తుతం బట్వాడా చేయడం సాధ్యపడలేదు." + + %d కొత్త సందేశాలు + కొత్త సందేశం + + "ప్రారంభించు" + "కెమెరా అందుబాటులో లేదు" + "కెమెరా అందుబాటులో లేదు" + "వీడియో సంగ్రహణ అందుబాటులో లేదు" + "మీడియాను సేవ్ చేయడం సాధ్యపడదు" + "చిత్రం తీయడం సాధ్యపడలేదు" + "వెనుకకు" + "ఆర్కైవ్ చేయబడినవి" + "తొలగించు" + "ఆర్కైవ్ చేయి" + "ఆర్కైవ్ నుండి తీసివేయి" + "నోటిఫికేషన్‌లను ఆఫ్ చేయి" + "నోటిఫికేషన్‌లను ఆన్ చేయి" + "పరిచయాన్ని జోడించు" + "డౌన్‌లోడ్ చేయి" + "పంపు" + "తొలగించు" + "ఈ సందేశాన్ని తొలగించాలా?" + "ఈ చర్య రద్దు చేయబడదు." + "తొలగించు" + + ఈ సంభాషణలను తొలగించాలా? + ఈ సంభాషణను తొలగించాలా? + + "తొలగించు" + "రద్దు చేయి" + "వీరికి" + "బహుళ చిత్రాలను ఎంచుకోండి" + "ఎంపికను నిర్ధారించు" + "+%d" + "ఆడియోను రికార్డ్ చేయడం సాధ్యపడదు. మళ్లీ ప్రయత్నించండి." + "ఆడియోను ప్లే చేయడం సాధ్యపడదు. మళ్లీ ప్రయత్నించండి." + "ఆడియోను సేవ్ చేయడం సాధ్యపడలేదు. మళ్లీ ప్రయత్నించండి." + "తాకి ఉంచండి" + ", " + " " + ": " + " " + "చిత్రం" + "ఆడియో క్లిప్" + "వీడియో" + "పరిచయ కార్డు" + "డౌన్‌లోడ్ చేయి" + "SMS ద్వారా ప్రత్యుత్తరం ఇవ్వండి" + "MMSలో ప్రత్యు." + "ప్రత్యుత్తరం పంపండి" + + %d మంది పాల్గొన్నారు + %d పాల్గొన్నారు + + "నేను" + "పరిచయం బ్లాక్ చేయబడింది & ఆర్కైవ్ చేయబడింది" + "పరిచయం అన్‌బ్లాక్ చేయబడింది & అన్ఆర్కైవ్ చేయబడింది" + "%d ఆర్కైవ్ చేయబడింది/ఆర్కైవ్ చేయబడ్డాయి" + "%d ఆర్కైవ్ నుండి తీసివేయబడింది/ఆర్కైవ్ నుండి తీసివేయబడ్డాయి" + "నోటిఫికేషన్‌లు ఆఫ్ చేయబడ్డాయి" + "నోటిఫికేషన్‌లు ఆన్ చేయబడ్డాయి" + "అంతా సెట్ చేశారు. మళ్లీ పంపు తాకండి." + "సందేశ సేవ డిఫాల్ట్ SMS అనువర్తనంగా విజయవంతంగా సెట్ చేయబడింది." + + జోడింపులను విస్మరించండి + జోడింపును విస్మరించండి + + "ఆడియో జోడింపు" + "ఆడియో జోడింపుని ప్లే చేయండి" + "పాజ్ చేయి" + "%s నుండి సందేశం: %s." + "%s నుండి %s సందేశం పంపడంలో విఫలమైంది. సమయం: %s." + "%s నుండి %s సందేశం వచ్చింది. సమయం: %s." + "%sకి %s సందేశం పంపబడలేదు. సమయం: %s." + "%sకి %s సందేశం పంపబడుతోంది. సమయం: %s." + "%sకి %s సందేశం పంపడంలో విఫలమైంది. సమయం: %s." + "%sకి %s సందేశం పంపబడింది. సమయం: %s." + "%s నుండి %s సందేశం పంపడంలో విఫలమైంది. సమయం: %s. %s." + "%s నుండి %s సందేశం వచ్చింది. సమయం: %s. %s." + "%sకి %s సందేశం పంపబడలేదు. సమయం: %s." + "%sకి %s సందేశం పంపబడుతోంది. సమయం: %s." + "%sకి %s సందేశం పంపడంలో విఫలమైంది. సమయం: %s." + "%sకి %s సందేశం పంపబడింది. సమయం: %s." + "సందేశం పంపడం విఫలమైంది. మళ్లీ ప్రయత్నించడానికి తాకండి." + "%sతో సంభాషణ" + "విషయాన్ని తొలగించండి" + "వీడియోను క్యాప్చర్ చేయండి" + "నిలకడగా ఉన్న చిత్రాన్ని క్యాప్చర్ చేయండి" + "చిత్రం తీయండి" + "వీడియో రికార్డింగ్‌ను ప్రారంభించండి" + "కెమెరాను పూర్తి స్క్రీన్‌కి మార్చండి" + "ముందు మరియు వెనుక కెమెరా మోడ్‌ల మధ్య మారండి" + "రికార్డింగ్‌ను ఆపివేసి, వీడియోను చేర్చండి" + "వీడియో రికార్డ్ చేయడాన్ని ఆపివేయండి" + "సందేశ సేవ ఫోటోలు" + + %d ఫోటోలు \"%s\" ఆల్బమ్‌కి సేవ్ చేయబడ్డాయి + %d ఫోటో \"%s\" ఆల్బమ్‌కి సేవ్ చేయబడింది + + + %d వీడియోలు \"%s\" ఆల్బమ్‌కి సేవ్ చేయబడ్డాయి + %d వీడియో \"%s\" ఆల్బమ్‌కి సేవ్ చేయబడింది + + + %d జోడింపులు \"%s\" ఆల్బమ్‌కి సేవ్ చేయబడ్డాయి + %d జోడింపు \"%s\" ఆల్బమ్‌కి సేవ్ చేయబడింది + + + %d జోడింపులు \"డౌన్‌లోడ్‌లు\"కి సేవ్ చేయబడ్డాయి + %d జోడింపు \"డౌన్‌లోడ్‌లు\"కి సేవ్ చేయబడింది + + + %d జోడింపులు సేవ్ చేయబడ్డాయి + %d జోడింపు సేవ్ చేయబడింది + + + %d జోడింపులను సేవ్ చేయలేకపోయింది + %d జోడింపును సేవ్ చేయలేకపోయింది + + "MMS జోడింపు సేవ్ చేయబడింది" + "సెట్టింగ్‌లు" + "ఆర్కైవ్ చేయబడింది" + "మూసివేయి" + "MMS" + "అధునాతనం" + "డీబగ్" + "నోటిఫికేషన్‌లు" + "ధ్వని" + "నిశ్శబ్దం" + "వైబ్రేట్" + "బ్లాక్ చేయబడింది" + "SMS బట్వాడా నివేదికలు" + "మీరు పంపే ప్రతి SMS కోసం బట్వాడా నివేదికను అభ్యర్థించండి" + "స్వయంచాలకంగా తిరిగి పొందడం" + "MMS సందేశాలను స్వయంచాలకంగా పునరుద్ధరించండి" + "రోమింగ్‌లో ఉన్నప్పుడు స్వీయ-పునరుద్ధరణ" + "రోమింగ్‌లో ఉన్నప్పుడు MMSని స్వయంచాలకంగా తిరిగి పొందుతుంది" + "సమూహ సందేశాలు" + "ఒక సందేశాన్ని బహుళ స్వీకర్తలకు పంపాల్సినప్పుడు MMSను ఉపయోగించండి" + "డిఫాల్ట్ SMS అనువర్తనం" + "డిఫాల్ట్ SMS అనువర్తనం" + + + "మీ ఫోన్ నంబర్" + "తెలియదు" + "అవుట్‌గోయింగ్ సందేశ ధ్వనులు" + "SMSను డంప్ చేయండి" + "స్వీకరించబడిన SMS ప్రాసెస్ చేయబడని డేటాను బాహ్య నిల్వ ఫైల్‌లోకి డంప్ చేయండి" + "MMSను డంప్ చేయండి" + "స్వీకరించబడిన MMS ప్రాసెస్ చేయబడని డేటాను బాహ్య నిల్వ ఫైల్‌లోకి డంప్ చేయండి" + "వైర్‌లెస్ హెచ్చరికలు" + "సందేశ ఎంపికలు" + "వచనాన్ని కాపీ చేయి" + "వివరాలను వీక్షించండి" + "తొలగించు" + "ఫార్వార్డ్ చేయండి" + "సందేశ వివరాలు" + "రకం: " + "వచన సందేశం" + "మల్టీమీడియా సందేశం" + "వీరి నుండి: " + "వీరికి: " + "పంపినది: " + "స్వీకరించినది: " + "విషయం: " + "పరిమాణం: " + "ప్రాధాన్యత: " + "SIM: " + "అధికం" + "సాధారణం" + "తక్కువ" + "SIM %s" + "లేఖరి చిరునామా దాచబడింది" + "జోడింపులు లోడ్ అవుతున్నప్పుడు సందేశాన్ని పంపలేరు." + "జోడింపుని లోడ్ చేయడం సాధ్యపడదు. మళ్లీ ప్రయత్నించండి." + "నెట్‌వర్క్ సిద్ధంగా లేదు. మళ్లీ ప్రయత్నించండి." + "వచనాన్ని తొలగించు" + "వచనం మరియు సంఖ్యలను నమోదు చేయడానికి వాటిని అటుఇటు మార్చు" + "మరింతమంది పాల్గొనేవారిని జోడించు" + "పాల్గొనేవారిని నిర్ధారించండి" + "కొత్త సంభాషణను ప్రారంభించండి" + "ఈ అంశాన్ని ఎంచుకోండి" + "వీడియోను ప్లే చేయండి" + "వ్యక్తులు & ఎంపికలు" + "డీబగ్" + "వ్యక్తులు & ఎంపికలు" + "సాధారణం" + "ఈ సంభాషణలో పాల్గొన్న వ్యక్తులు" + "కాల్ చేయి" + "సందేశాన్ని పంపండి" + "సందేశాన్ని <small>%s నుండి</small><br/>పంపు" + + ఫోటోలను పంపండి + ఫోటోను పంపండి + + + ఆడియోలను పంపండి + ఆడియోను పంపండి + + + వీడియోలను పంపండి + వీడియోను పంపండి + + + పరిచయ కార్డ్‌లను పంపండి + పరిచయ కార్డ్‌ను పంపండి + + + జోడింపులను పంపండి + జోడింపుని పంపండి + + + %d జోడింపులు పంపడానికి సిద్ధంగా ఉన్నాయి + ఒక జోడింపు పంపడానికి సిద్ధంగా ఉంది + + "అభిప్రాయం పంపండి" + "Google Play Storeలో వీక్షించండి" + "సంస్కరణ సమాచారం" + "సంస్కరణ %1$s" + "ఓపెన్ సోర్స్ లైసెన్స్‌లు" + "నోటిఫికేషన్‌లు" + "జోడింపు పరిమితిని చేరుకున్నారు" + "జోడింపుని లోడ్ చేయడంలో విఫలమైంది." + "పరిచయాలకు జోడించాలా?" + "పరిచయాన్ని జోడించు" + "విషయం" + "విషయం: " + "%s%s" + "పరిచయ కార్డ్‌ను లోడ్ చేస్తోంది" + "పరిచయ కార్డ్‌ను లోడ్ చేయడం సాధ్యపడలేదు" + "పరిచయ కార్డ్‌ను వీక్షించండి" + + %d పరిచయాలు + %d పరిచయం + + "పరిచయ కార్డ్‌లు" + "పుట్టినరోజు" + "గమనికలు" + "సందేశాన్ని ఫార్వార్డ్ చేయండి" + "ప్రత్యుత్తరం" + "+1" + "+%d" + "SMS నిలిపివేయబడింది" + "పంపడానికి, సందేశ సేవను డిఫాల్ట్ SMS అనువర్తనంగా సెట్ చేయండి" + "సందేశ సేవను డిఫాల్ట్ SMS అనువర్తనంగా సెట్ చేయండి" + "మార్చు" + "సందేశాలు స్వీకరించడానికి, సందేశ సేవను డిఫాల్ట్ SMS అనువర్తనంగా సెట్ చేయండి" + "SMS సందేశాలు పంపడానికి ప్రాధాన్య SIM ఏదీ ఎంచుకోలేదు" + "ఈ అనువర్తనాన్ని పరికరం యజమాని అనుమతించలేదు." + "సరే" + "సంభాషణలో చాలా ఎక్కువ మంది పాల్గొన్నారు" + + పరిచయాలు చెల్లవు + పరిచయం చెల్లదు + + "కెమెరా చిత్రాన్ని లోడ్ చేయడం సాధ్యపడలేదు" + "మీరు: " + "%s: " + "చిత్తుప్రతి" + "మీరు కొత్త సంభాషణను ప్రారంభిస్తే, అది ఇక్కడ జాబితా కావడం మీకు కనిపిస్తుంది" + "ఆర్కైవ్ చేసిన సంభాషణలు ఇక్కడ కనిపిస్తాయి" + "సంభాషణలను లోడ్ చేస్తోంది…" + "చిత్రం" + "ఆడియో క్లిప్" + "వీడియో" + "పరిచయ కార్డ్" + "MMS" + "చర్య రద్దు చేయి" + "మళ్లీ ప్రయత్నించు" + "కొత్త సందేశాన్ని ప్రారంభించడానికి పరిచయం పేరు లేదా ఫోన్ నంబర్‌ని నమోదు చేయండి" + "బ్లాక్ చేయి" + "%sని బ్లాక్ చేయి" + "%sని అన్‌బ్లాక్ చేయి" + "%sని బ్లాక్ చేయాలా?" + "మీరు ఈ నంబర్ నుండి సందేశాలను స్వీకరించడం కొనసాగుతుంది కానీ ఇకపై వాటి గురించి తెలియజేయబడదు. ఈ సంభాషణ ఆర్కైవ్ చేయబడుతుంది." + "బ్లాక్ చేయబడిన పరిచయాలు" + "అన్‌బ్లాక్ చేయి" + "బ్లాక్ చేయబడిన పరిచయాలు" + "పత్రం లైబ్రరీ నుండి చిత్రాన్ని ఎంచుకోండి" + "సందేశం పంపబడుతోంది" + "సందేశం పంపబడింది" + "సెల్యులార్ డేటా ఆఫ్ చేయబడింది. మీ సెట్టింగ్‌లను తనిఖీ చేయండి." + "ఎయిర్‌ప్లైన్ మోడ్‌లో సందేశాలు పంపడం సాధ్యపడదు" + "సందేశం పంపడం సాధ్యపడలేదు" + "సందేశం డౌన్‌లోడ్ చేయబడింది" + "సెల్యులార్ డేటా ఆఫ్ చేయబడింది. మీ సెట్టింగ్‌లను తనిఖీ చేయండి." + "ఎయిర్‌ప్లైన్ మోడ్‌లో సందేశాలను డౌన్‌లోడ్ చేయడం సాధ్యపడదు" + "సందేశాన్ని డౌన్‌లోడ్ చేయడం సాధ్యపడలేదు" + "సున్నా" + "ఒకటి" + "రెండు" + "మూడు" + "నాలుగు" + "ఐదు" + "ఆరు" + "ఏడు" + "ఎనిమిది" + "తొమ్మిది" + "%1$sతో సందేశం పంపలేరు, దోషం %2$d" + "తెలియని క్యారియర్‌తో సందేశం పంపలేరు, లోపం %1$d" + "ఫార్వా.: %s" + "సందేశం పంపబడలేదు: నెట్‌వర్క్‌లో సేవ సక్రియం కాలేదు" + "సందేశం పంపబడలేదు: గమ్యస్థాన చిరునామా చెల్లదు" + "సందేశం పంపబడలేదు: సందేశం చెల్లదు" + "సందేశం పంపబడలేదు: మద్దతు లేని కంటెంట్" + "సందేశం పంపబడలేదు: మద్దతు లేని సందేశం" + "సందేశం పంపబడలేదు: చాలా పెద్దది" + "కొత్త సందేశం" + "వీక్షించండి" + "చిత్రం" + "సముచిత అనువర్తనాన్ని కనుగొనడం సాధ్యపడలేదు" + "స్వీకర్తను తీసివేయండి" + "కొత్త సందేశం" + "రద్దు చేయి" + "ప్రాప్యత స్థానాన్ని సవరించు" + "సెట్ చేయలేదు" + "పేరు" + "APN" + "MMSC" + "MMS ప్రాక్సీ" + "MMS పోర్ట్" + "MCC" + "MNC" + "APN రకం" + "APNను తొలగించు" + "కొత్త APN" + "సేవ్ చేయి" + "విస్మరించు" + "పేరు ఫీల్డ్ ఖాళీగా ఉండకూడదు." + "APN ఖాళీగా ఉండకూడదు." + "MCC ఫీల్డ్‌లో తప్పనిసరిగా 3 అంకెలు ఉండాలి." + "MNC ఫీల్డ్‌లో తప్పనిసరిగా 2 లేదా 3 అంకెలు ఉండాలి." + "డిఫాల్ట్ APN సెట్టింగ్‌లను పునరుద్ధరిస్తోంది." + "డిఫాల్ట్‌కు రీసెట్ చేయి" + "డిఫాల్ట్ APN సెట్టింగ్‌లను రీసెట్ చేయడం పూర్తయింది." + "శీర్షిక లేదు" + "ప్రాప్యత స్థానం పేర్లు" + "APNలు" + "కొత్త APN" + "ప్రాప్యత స్థానం పేరు సెట్టింగ్‌లు ఈ వినియోగదారుకి అందుబాటులో లేవు" + "క్లిప్‌బోర్డ్‌కు కాపీ చేయాలా?" + "కాపీ చేయి" + "%sకి" + "సాధారణం" + "అధునాతనం" + "సాధారణ సెట్టింగ్‌లు" + "అధునాతన సెట్టింగ్‌లు" + "\"%s\" SIM" + "స్వీకర్తలందరికీ వేర్వేరుగా SMS సందేశాలను పంపండి. ఏవైనా ప్రత్యుత్తరాలను మీరు మాత్రమే పొందుతారు" + "స్వీకర్తలందరికీ ఒకే MMS పంపండి" + "తెలియని నంబర్" + "కొత్త సందేశం" + "కొత్త సందేశం." + "SIM ఎంపిక సాధనం" + "%1$s ఎంచుకోబడింది, SIM ఎంపిక" + "విషయాన్ని సవరించు" + "SIM ఎంచుకోండి లేదా విషయాన్ని సవరించండి" + "ఆడియోను రికార్డ్ చేయడానికి తాకి, అలాగే ఉంచండి" + "కొత్త సంభాషణను ప్రారంభించండి" + "సందేశ సేవ" + "సందేశ సేవ జాబితా" + "సందేశ సేవ" + "కొత్త సందేశం" + "సంభాషణ జాబితా" + "సంభాషణలను లోడ్ చేస్తోంది" + "సందేశాలు లోడ్ అవుతున్నాయి" + "మరిన్ని సంభాషణలను వీక్షించండి" + "మరిన్ని సందేశాలను వీక్షించండి" + "సంభాషణ తొలగించబడింది" + "సంభాషణ తొలగించబడింది. విభిన్న సందేశ సేవ సంభాషణను చూపడానికి తాకండి" + "బ్లాక్ చేయబడ్డారు" + "అన్‌బ్లాక్ చేయబడ్డారు" + "నిల్వ స్థలంలో ఖాళీ తక్కువగా ఉంది. కొంత డేటా కోల్పోవచ్చు." + "జోడింపులను ఎంచుకోండి" + "ఎంపికను నిర్ధారించు" + "%d ఎంచుకోబడింది/ఎంచుకోబడ్డాయి" + "దయచేసి ఒకటి లేదా అంతకంటే ఎక్కువ జోడింపులను తీసివేసి, మళ్లీ ప్రయత్నించండి." + "మీరు మీ సందేశం పంపడాన్ని ప్రయత్నించవచ్చు, కానీ ఒకటి లేదా అంతకంటే ఎక్కువ జోడింపులను తీసివేస్తే మినహా అది బట్వాడా చేయబడదు." + "మీరు ఒక సందేశానికి ఒక వీడియోను మాత్రమే పంపగలరు. దయచేసి అదనపు వీడియోలను తీసివేసి, ఆపై మళ్లీ ప్రయత్నించండి." + "సందేశ సేవ జోడింపును లోడ్ చేయడంలో విఫలమైంది." + "ఏదేమైనా పంపు" + "సంభాషణను ప్రారంభించలేరు" + "%1$s (%2$s)" + "%s ఎంచుకోబడింది" + diff --git a/res/values-th/arrays.xml b/res/values-th/arrays.xml new file mode 100644 index 0000000..749516e --- /dev/null +++ b/res/values-th/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "ไม่มีเรื่อง" + "ไม่มีชื่อเรื่อง" + + + "ใช่" + "ไม่ใช่" + "ตกลง" + "อิอิ" + "ขอบคุณ" + "ฉันเห็นด้วย" + "เยี่ยมเลย" + "เดินทางอยู่" + "ตกลง ฉันจะติดต่อกลับมาในภายหลัง" + ":)" + ":(" + + diff --git a/res/values-th/strings.xml b/res/values-th/strings.xml new file mode 100644 index 0000000..d191be9 --- /dev/null +++ b/res/values-th/strings.xml @@ -0,0 +1,519 @@ + + + + + "การรับส่งข้อความ" + "การรับส่งข้อความ" + "เลือกการสนทนา" + "การตั้งค่า" + "ส่งข้อความ" + "เพิ่มไฟล์แนบ" + "ความช่วยเหลือ" + "ยินดีต้อนรับ" + "ข้าม" + "ถัดไป >" + "ถัดไป" + "ออก" + "การตั้งค่า >" + "การตั้งค่า" + "การรับส่งข้อความต้องใช้สิทธิ์เข้าถึง SMS, โทรศัพท์ และรายชื่อติดต่อ" + "คุณสามารถเปลี่ยนแปลงสิทธิ์ได้ในการตั้งค่า > แอป > การรับส่งข้อความ > สิทธิ์" + "คุณสามารถเปลี่ยนแปลงสิทธิ์ได้ในการตั้งค่า แอป การรับส่งข้อความ และสิทธิ์" + "ผู้ที่ติดต่อบ่อย" + "รายชื่อติดต่อทั้งหมด" + "ส่งไปยัง %s" + "จับภาพหรือวิดีโอ" + "เลือกรูปภาพจากอุปกรณ์นี้" + "บันทึกเสียง" + "เลือกรูปภาพ" + "เลือกสื่อแล้ว" + "ไม่มีการเลือกสื่อ" + "เลือกไว้ %d รายการ" + "ภาพเมื่อวันที่ %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "ภาพ" + "บันทึกเสียง" + "แชร์" + "เดี๋ยวนี้เอง" + "ตอนนี้" + + %d นาที + %d นาที + + + %d ชั่วโมง + %d ชั่วโมง + + + %d วัน + %d วัน + + + %d สัปดาห์ + 1 สัปดาห์ + + + %d เดือน + 1 เดือน + + + %d ปี + 1 ปี + + "ข้อความคลาส 0" + "บันทึก" + "อุปกรณ์มีพื้นที่เหลือน้อย การรับส่งข้อความจะลบข้อความเก่าโดยอัตโนมัติเพื่อเพิ่มพื้นที่ว่าง" + "พื้นที่จัดเก็บเหลือน้อย" + "การรับส่งข้อความอาจไม่ส่งหรือรับข้อความจนกว่าจะมีพื้นที่เพิ่มมากขึ้นบนอุปกรณ์" + "พื้นที่จัดเก็บ SMS เหลือน้อย คุณอาจต้องลบข้อความ" + "ยืนยันหมายเลขโทรศัพท์" + "ขั้นตอนที่ทำเพียงครั้งเดียวนี้จะช่วยให้การรับส่งข้อความส่งข้อความของกลุ่มได้อย่างถูกต้อง" + "หมายเลขโทรศัพท์" + "ลบข้อความทั้งหมดที่มีสื่อ" + "ลบข้อความที่เก่ากว่า %s" + "ลบข้อความที่เก่ากว่า %s โดยอัตโนมัติ" + "ไม่สนใจ" + "ลบข้อความทั้งหมดที่มีสื่อใช่ไหม" + "ลบข้อความที่เก่ากว่า %s ใช่ไหม" + "ลบข้อความที่เก่ากว่า %s และเปิดการลบอัตโนมัติใช่ไหม" + "%s กล่าว" + "คุณกล่าว" + "ข้อความจาก %s" + "คุณส่งข้อความแล้ว" + "กำลังส่ง…" + "ไม่สามารถส่ง แตะเพื่อลองอีกครั้ง" + "ไม่สามารถส่ง ลองอีกครั้ง…" + "ส่งอีกครั้งหรือลบ" + "โปรดโทรไปที่ฝ่ายบริการฉุกเฉิน ระบบไม่สามารถส่งข้อความของคุณได้ในขณะนี้" + "ล้มเหลว" + "มีข้อความ MMS ใหม่ให้ดาวน์โหลด" + "ข้อความ MMS ใหม่" + "ไม่สามารถดาวน์โหลดได้" + "แตะเพื่อลองอีกครั้ง" + "แตะเพื่อดาวน์โหลด" + "ดาวน์โหลดหรือลบ" + "กำลังดาวน์โหลด…" + "ข้อความหมดอายุหรือไม่สามารถใช้ได้" + "ขนาด: %1$s หมดอายุ: %2$s" + "ไม่สามารถส่งได้ ผู้รับไม่ถูกต้อง" + "ไม่ได้เปิดใช้บริการบนเครือข่าย" + "ไม่สามารถส่งได้เนื่องจากปัญหาของเครือข่าย" + "ข้อความหมดอายุหรือไม่สามารถใช้ได้" + "(ไม่มีเรื่อง)" + "ไม่ทราบผู้ส่ง" + "ส่งแล้ว" + "ไม่สามารถดาวน์โหลดข้อความ %1$s จาก %2$s" + "ไม่สามารถดำเนินการฐานข้อมูลให้เสร็จสิ้นเนื่องจากมีหน่วยความจำเหลือน้อย" + "ไม่ได้ส่งข้อความ" + "ไม่ได้ส่งข้อความบางรายการในการรับส่งข้อความ" + + %d ข้อความใน %d การสนทนา + %d ข้อความใน 1 การสนทนา + + "ข้อความไม่ได้รับการดาวน์โหลด" + "ไม่ได้ดาวน์โหลดข้อความบางรายการในการรับส่งข้อความ" + + %d ข้อความใน %d การสนทนา + %d ข้อความใน 1 การสนทนา + + "ไม่ได้ส่งข้อความถึง %1$s" + "โปรดโทรไปที่ฝ่ายบริการฉุกเฉิน ระบบไม่สามารถส่งข้อความของคุณไปยัง %1$s ได้ในขณะนี้" + + %d ข้อความใหม่ + ข้อความใหม่ + + "เริ่ม" + "กล้องไม่สามารถใช้ได้" + "กล้องไม่สามารถใช้ได้" + "การจับภาพวิดีโอไม่พร้อมใช้งาน" + "ไม่สามารถบันทึกสื่อได้" + "ไม่สามารถถ่ายภาพ" + "กลับ" + "เก็บถาวรแล้ว" + "ลบ" + "เก็บถาวร" + "ยกเลิกการเก็บถาวร" + "ปิดการแจ้งเตือน" + "เปิดการแจ้งเตือน" + "เพิ่มรายชื่อติดต่อ" + "ดาวน์โหลด" + "ส่ง" + "ลบ" + "ลบข้อความนี้ไหม" + "การดำเนินการนี้ไม่สามารถยกเลิกได้" + "ลบ" + + ลบการสนทนาเหล่านี้ใช่ไหม + ลบการสนทนานี้ใช่ไหม + + "ลบ" + "ยกเลิก" + "ถึง" + "เลือกภาพหลายภาพ" + "ยืนยันการเลือก" + "+%d" + "ไม่สามารถบันทึกเสียงได้ ลองอีกครั้ง" + "ไม่สามารถเล่นเสียงได้ ลองอีกครั้ง" + "ไม่สามารถบันทึกเสียงได้ ลองอีกครั้ง" + "แตะค้างไว้" + ", " + " " + ": " + " " + "ภาพ" + "คลิปเสียง" + "วิดีโอ" + "บัตรติดต่อ" + "ดาวน์โหลด" + "ตอบกลับทาง SMS" + "ตอบกลับผ่าน MMS" + "ตอบ" + + ผู้เข้าร่วม %d คน + ผู้เข้าร่วม %d คน + + "ฉัน" + "บล็อกและเก็บผู้ติดต่อแล้ว" + "เลิกบล็อกและเลิกเก็บรายชื่อติดต่อแล้ว" + "เก็บแล้ว %d รายการ" + "เลิกเก็บแล้ว %d รายการ" + "ปิดการแจ้งเตือนแล้ว" + "เปิดการแจ้งเตือนแล้ว" + "ตั้งค่าเรียบร้อย แตะ \"ส่ง\" อีกครั้ง" + "ตั้งค่าการรับส่งข้อความเป็นแอป SMS ค่าเริ่มต้นเรียบร้อยแล้ว" + + ยกเลิกไฟล์แนบ + ยกเลิกไฟล์แนบ + + "ไฟล์แนบเสียง" + "เล่นไฟล์เสียงที่แนบอยู่" + "หยุดชั่วคราว" + "ข้อความจาก %s: %s" + "ไม่สามารถรับข้อความจาก %s: %s เวลา: %s" + "ข้อความจาก %s: %s เวลา: %s" + "ข้อความที่ยังไม่ได้ส่งถึง %s: %s เวลา: %s" + "กำลังส่งข้อความถึง %s: %s เวลา: %s" + "ไม่สามารถส่งข้อความถึง %s: %s เวลา: %s" + "ส่งข้อความถึง %s: %s เวลา: %s" + "ไม่สามารถรับข้อความจาก %s: %s เวลา: %s %s" + "ข้อความจาก %s: %s เวลา: %s %s" + "ข้อความที่ยังไม่ได้ส่งถึง %s: %s เวลา: %s" + "กำลังส่งข้อความถึง %s: %s เวลา: %s" + "ไม่สามารถส่งข้อความถึง %s: %s เวลา: %s" + "ส่งข้อความถึง %s: %s เวลา: %s" + "ข้อความล้มเหลว แตะเพื่อลองใหม่" + "การสนทนากับ %s" + "ลบเรื่อง" + "จับภาพวิดีโอ" + "จับภาพนิ่ง" + "ถ่ายภาพ" + "เริ่มบันทึกวิดีโอ" + "สลับไปยังกล้องแบบเต็มหน้าจอ" + "สลับระหว่างกล้องหน้าและกล้องหลัง" + "หยุดการบันทึกและแนบวิดีโอ" + "หยุดการบันทึกวิดีโอ" + "รูปภาพในการรับส่งข้อความ" + + บันทึก %d ภาพไปยังอัลบั้ม \"%s\" แล้ว + บันทึก %d ภาพไปยังอัลบั้ม \"%s\" แล้ว + + + บันทึกวิดีโอ %d รายการไปยังอัลบั้ม \"%s\" แล้ว + บันทึกวิดีโอ %d รายการไปยังอัลบั้ม \"%s\" แล้ว + + + บันทึก %d ไฟล์แนบไปยังอัลบั้ม \"%s\" แล้ว + บันทึก %d ไฟล์แนบไปยังอัลบั้ม \"%s\" แล้ว + + + บันทึก %d ไฟล์แนบไปยัง \"ดาวน์โหลด\" แล้ว + บันทึก %d ไฟล์แนบไปยัง \"ดาวน์โหลด\" แล้ว + + + บันทึกไฟล์แนบ %d รายการแล้ว + บันทึกไฟล์แนบ %d รายการแล้ว + + + ไม่สามารถบันทึกไฟล์แนบ %d รายการ + ไม่สามารถบันทึกไฟล์แนบ %d รายการ + + "ไฟล์แนบของ MMS ที่บันทึกไว้" + "การตั้งค่า" + "เก็บถาวรแล้ว" + "ปิด" + "MMS" + "ขั้นสูง" + "แก้ไขข้อบกพร่อง" + "การแจ้งเตือน" + "เสียง" + "เงียบ" + "สั่น" + "ถูกบล็อก" + "รายงานการส่ง SMS" + "ขอรายงานการส่ง SMS แต่ละครั้ง" + "เรียกอัตโนมัติ" + "เรียกดูข้อความ MMS อัตโนมัติ" + "เรียกข้อความอัตโนมัติขณะโรมมิ่ง" + "ดึง MMS โดยอัตโนมัติเมื่อโรมมิ่ง" + "การรับส่งข้อความแบบกลุ่ม" + "ใช้ MMS ส่งข้อความเดียวเมื่อมีผู้รับหลายราย" + "แอป SMS เริ่มต้น" + "แอป SMS เริ่มต้น" + + + "หมายเลขโทรศัพท์ของคุณ" + "ไม่รู้จัก" + "เสียงข้อความขาออก" + "ถ่ายโอน SMS" + "ถ่ายโอนข้อมูลดิบของ SMS ที่ได้รับลงในไฟล์พื้นที่จัดเก็บข้อมูลภายนอก" + "ถ่ายโอน MMS" + "ถ่ายโอนข้อมูลดิบของ MMS ที่ได้รับลงในไฟล์พื้นที่จัดเก็บข้อมูลภายนอก" + "การแจ้งเตือนแบบไร้สาย" + "ตัวเลือกข้อความ" + "คัดลอกข้อความ" + "ดูรายละเอียด" + "ลบ" + "ส่งต่อ" + "รายละเอียดข้อความ" + "ประเภท: " + "ข้อความ" + "ข้อความมัลติมีเดีย" + "จาก: " + "ถึง: " + "ส่งแล้ว: " + "ได้รับ: " + "เรื่อง: " + "ขนาด: " + "ลำดับความสำคัญ: " + "ซิม: " + "สูง" + "ปกติ" + "ต่ำ" + "ซิม %s" + "ที่อยู่ผู้ส่งที่ซ่อนไว้" + "ไม่สามารถส่งข้อความเสียงขณะโหลดเอกสารแนบ" + "ไม่สามารถโหลดไฟล์แนบได้ ลองอีกครั้ง" + "เครือข่ายยังไม่พร้อม ลองอีกครั้ง" + "ลบข้อความ" + "สลับระหว่างการป้อนข้อความและตัวเลข" + "เพิ่มผู้เข้าร่วมเพิ่มเติม" + "ยืนยันผู้เข้าร่วม" + "เริ่มต้นการสนทนาใหม่" + "เลือกรายการนี้" + "เล่นวิดีโอ" + "ผู้คนและตัวเลือก" + "แก้ไขข้อบกพร่อง" + "ผู้คนและตัวเลือก" + "ทั่วไป" + "ผู้เข้าร่วมการสนทนานี้" + "โทรออก" + "ส่งข้อความ" + "ส่งข้อความ<br/><small>จาก %s</small>" + + ส่งรูปภาพ + ส่งรูปภาพ + + + ส่งไฟล์เสียง + ส่งไฟล์เสียง + + + ส่งวิดีโอ + ส่งวิดีโอ + + + ส่งการ์ดรายชื่อติดต่อ + ส่งการ์ดรายชื่อติดต่อ + + + ส่งไฟล์แนบ + ส่งไฟล์แนบ + + + %d ไฟล์แนบพร้อมส่ง + 1 ไฟล์แนบพร้อมส่ง + + "ส่งความคิดเห็น" + "ดูใน Google Play สโตร์" + "ข้อมูลเวอร์ชัน" + "เวอร์ชัน %1$s" + "สัญญาอนุญาตสำหรับโอเพนซอร์ส" + "การแจ้งเตือน" + "ถึงขีดจำกัดของไฟล์แนบแล้ว" + "ไม่สามารถโหลดไฟล์แนบได้" + "เพิ่มในรายชื่อติดต่อใช่ไหม" + "เพิ่มรายชื่อติดต่อ" + "เรื่อง" + "เรื่อง: " + "%s%s" + "กำลังโหลดบัตรติดต่อ" + "ไม่สามารถโหลดการ์ดรายชื่อติดต่อได้" + "ดูบัตรติดต่อ" + + รายชื่อติดต่อ %d ราย + รายชื่อติดต่อ %d ราย + + "การ์ดรายชื่อติดต่อ" + "วันเกิด" + "บันทึก" + "ส่งต่อข้อความ" + "ตอบ" + "+1" + "+%d" + "ปิดใช้ SMS แล้ว" + "ในการส่งข้อความ ให้ตั้งค่าการรับส่งข้อความเป็นแอป SMS ค่าเริ่มต้น" + "ตั้งค่าการรับส่งข้อความเป็นแอป SMS ค่าเริ่มต้น" + "เปลี่ยน" + "ในการรับข้อความ ให้ตั้งค่าการรับส่งข้อความเป็นแอป SMS ค่าเริ่มต้น" + "ยังไม่ได้เลือกซิมสำหรับการส่งข้อความ SMS" + "เจ้าของอุปกรณ์ไม่อนุญาตแอปนี้" + "ตกลง" + "มีผู้เข้าร่วมในการสนทนามากเกินไป" + + รายชื่อติดต่อไม่ถูกต้อง + รายชื่อติดต่อไม่ถูกต้อง + + "ไม่สามารถโหลดภาพจากกล้องได้" + "คุณ: " + "%s: " + "ข้อความร่าง" + "เมื่อคุณเริ่มการสนทนาใหม่ คุณจะเห็นการสนทนาใหม่ที่นี่" + "การสนทนาที่เก็บไว้จะปรากฏที่นี่" + "กำลังโหลดการสนทนา…" + "ภาพ" + "คลิปเสียง" + "วิดีโอ" + "บัตรติดต่อ" + "MMS" + "เลิกทำ" + "ลองอีกครั้ง" + "ป้อนชื่อผู้ติดต่อหรือหมายเลขโทรศัพท์เพื่อเริ่มเขียนข้อความใหม่" + "บล็อก" + "บล็อก %s" + "เลิกบล็อก %s" + "ต้องการบล็อก %s ไหม" + "คุณจะยังคงได้รับข้อความจากหมายเลขนี้ต่อไปแต่จะไม่ได้รับการแจ้งเตือนอีก ระบบจะเก็บการสนทนานี้ไว้" + "รายชื่อที่บล็อกไว้" + "เลิกบล็อก" + "รายชื่อที่บล็อกไว้" + "เลือกรูปภาพจากห้องสมุดเอกสาร" + "กำลังส่งข้อความ" + "ส่งข้อความแล้ว" + "ข้อมูลมือถือปิดอยู่ โปรดตรวจสอบการตั้งค่า" + "ไม่สามารถส่งข้อความในโหมดบนเครื่องบินได้" + "ไม่สามารถส่งข้อความนี้" + "ดาวน์โหลดข้อความแล้ว" + "ข้อมูลมือถือปิดอยู่ โปรดตรวจสอบการตั้งค่า" + "ไม่สามารถดาวน์โหลดข้อความในโหมดบนเครื่องบินได้" + "ไม่สามารถดาวน์โหลดข้อความได้" + "ศูนย์" + "หนึ่ง" + "สอง" + "สาม" + "สี่" + "ห้า" + "หก" + "เจ็ด" + "แปด" + "เก้า" + "ไม่สามารถส่งข้อความผ่าน %1$s ข้อผิดพลาด %2$d" + "ไม่สามารถส่งข้อความผ่านผู้ให้บริการที่ไม่รู้จัก ข้อผิดพลาด %1$d" + "ส่งต่อ: %s" + "ไม่มีการส่งข้อความ เพราะยังไม่เปิดใช้งานบริการบนเครือข่าย" + "ไม่มีการส่งข้อความ: ที่อยู่ปลายทางไม่ถูกต้อง" + "ไม่มีการส่งข้อความ: ข้อความไม่ถูกต้อง" + "ไม่มีการส่งข้อความ: ระบบไม่สนับสนุนเนื้อหา" + "ไม่มีการส่งข้อความ: ระบบไม่สนับสนุนข้อความ" + "ไม่ได้ส่งข้อความ: ขนาดใหญ่เกินไป" + "ข้อความใหม่" + "ดู" + "รูปภาพ" + "ไม่สามารถหาแอปพลิเคชันที่เหมาะสม" + "นำผู้รับออก" + "ข้อความใหม่" + "ยกเลิก" + "แก้ไขจุดเข้าใช้งาน" + "ไม่ได้ตั้งค่า" + "ชื่อ" + "APN" + "MMSC" + "พร็อกซี MMS" + "พอร์ต MMS" + "MCC" + "MNC" + "ประเภท APN" + "ลบ APN" + "APN ใหม่" + "บันทึก" + "ยกเลิก" + "ต้องใส่ข้อมูลในช่องชื่อ" + "ต้องใส่ข้อมูล APN" + "ช่อง MCC ต้องเป็นเลข 3 หลัก" + "ช่อง MNC ต้องเป็นเลข 2 หรือ 3 หลัก" + "กำลังคืนการตั้งค่า APN เริ่มต้น" + "รีเซ็ตเป็นค่าเริ่มต้น" + "รีเซ็ตการตั้งค่า APN กลับเป็นค่าเริ่มต้นเรียบร้อยแล้ว" + "ไม่มีชื่อ" + "ชื่อจุดเข้าใช้งาน" + "APN" + "APN ใหม่" + "การตั้งค่าจุดเข้าใช้งานไม่สามารถใช้ได้สำหรับผู้ใช้รายนี้" + "คัดลอกไปยังคลิปบอร์ดไหม" + "คัดลอก" + "ถึง %s" + "ทั่วไป" + "ขั้นสูง" + "การตั้งค่าทั่วไป" + "การตั้งค่าขั้นสูง" + "ซิม \"%s\"" + "ส่งข้อความ SMS ส่วนบุคคลให้กับผู้รับทุกคน เฉพาะคุณที่จะได้รับการตอบกลับ" + "ส่ง MMS เดี่ยวให้กับผู้รับทุกคน" + "ไม่ทราบหมายเลข" + "ข้อความใหม่" + "ข้อความใหม่" + "ตัวเลือกซิม" + "เลือก %1$s แล้ว ตัวเลือกซิม" + "แก้ไขเรื่อง" + "เลือกซิมหรือแก้ไขเรื่อง" + "แตะค้างไว้เพื่อบันทึกเสียง" + "เริ่มต้นการสนทนาใหม่" + "การรับส่งข้อความ" + "รายการสนทนาในการรับส่งข้อความ" + "การรับส่งข้อความ" + "ข้อความใหม่" + "รายการการสนทนา" + "กำลังโหลดการสนทนา" + "กำลังโหลดข้อความ" + "ดูการสนทนาเพิ่มเติม" + "ดูข้อความเพิ่มเติม" + "ลบการสนทนาแล้ว" + "ลบการสนทนาแล้ว แตะเพื่อแสดงการสนทนาอื่นๆ ในการรับส่งข้อความ" + "ถูกบล็อก" + "เลิกบล็อกแล้ว" + "พื้นที่เก็บข้อมูลเหลือน้อย ข้อมูลบางอย่างอาจสูญหายไป" + "เลือกไฟล์แนบ" + "ยืนยันการเลือก" + "เลือกไว้ %d รายการ" + "โปรดนำไฟล์แนบออกอย่างน้อย 1 รายการ แล้วลองอีกครั้ง" + "คุณสามารถลองส่งข้อความ แต่ข้อความอาจส่งไม่ได้หากคุณไม่ได้นำไฟล์แนบออกอย่างน้อย 1 รายการ" + "คุณสามารถส่งวิดีโอได้ 1 รายการต่อข้อความเท่านั้น โปรดนำวิดีโอเพิ่มเติมออกและลองอีกครั้ง" + "การรับส่งข้อความไม่สามารถโหลดไฟล์แนบ" + "ส่งเลย" + "ไม่สามารถเริ่มการสนทนาได้" + "%1$s (%2$s)" + "เลือก %s แล้ว" + diff --git a/res/values-tl/arrays.xml b/res/values-tl/arrays.xml new file mode 100644 index 0000000..6f30a29 --- /dev/null +++ b/res/values-tl/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "walang paksa" + "nosubject" + + + "Oo" + "Hindi" + "OK" + "Hehe" + "Salamat" + "Sang-ayon ako" + "Ayos" + "Papunta na ako" + "OK, balikan na lang kita mamaya" + ":)" + ":(" + + diff --git a/res/values-tl/strings.xml b/res/values-tl/strings.xml new file mode 100644 index 0000000..dddb04b --- /dev/null +++ b/res/values-tl/strings.xml @@ -0,0 +1,519 @@ + + + + + "Pagmemensahe" + "Pagmemensahe" + "Pumili ng pag-uusap" + "Mga Setting" + "Magpadala ng Mensahe" + "Magdagdag ng attachment" + "Tulong" + "Maligayang Pagdating" + "Laktawan" + "Susunod >" + "Susunod" + "Lumabas" + "Mga Setting >" + "Mga Setting" + "Kailangan ng pahintulot ng Pagmemensahe sa SMS, Telepono at Mga Contact." + "Maaari mong baguhin ang mga pahintulot sa Mga Setting > Mga App > Pagmemensahe > Mga Pahintulot." + "Maaari mong baguhin ang mga pahintulot sa Mga Setting, Mga App, Pagmemensahe, Mga Pahintulot." + "Mga madalas makaugnayan" + "Lahat ng contact" + "Ipadala sa %s" + "Kumuha ng mga larawan o video" + "Pumili ng mga larawan mula sa device na ito" + "I-record ang audio" + "Pumili ng larawan" + "Napili ang media." + "Inalis sa pagkakapili ang media." + "%d ang pinili" + "larawan %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "larawan" + "I-record ang audio" + "Ibahagi" + "Ngayon lang" + "Ngayon" + + %d min + %d na min + + + %d oras + %d na oras + + + %d araw + %d na araw + + + %d linggo + %d na linggo + + + %d buwan + %d na buwan + + + %d taon + %d na taon + + "Class 0 na mensahe" + "I-save" + "Kaunti na lang ang espasyo ng device. Awtomatikong ide-delete ng Pagmemensahe ang mga mas lumang mensahe upang magbakante ng espasyo." + "Nauubos na ang espasyo ng storage" + "Maaaring hindi makatanggap o makapagpadala ng mga mensahe ang Pagmemensahe hanggang sa maging available ang higit pang espasyo sa iyong device." + "Kaunti na ang storage ng SMS. Maaaring kailanganin mong magtanggal ng mga mensahe." + "Kumpirmahin ang numero ng iyong telepono" + "Titiyakin ng isang beses na hakbang na ito na maihahatid nang maayos ng Pagmemensahe ang iyong mga mensahe sa pangkat." + "Numero ng telepono" + "Tanggalin ang lahat ng mensahe na may media" + "Tangglain ang mga mensaheng lampas na ng %s" + "Awtomatikong i-delete ang mga mensaheng mas luma sa %s" + "Huwag Pansinin" + "I-delete ang lahat ng mensaheng may media?" + "I-delete ang mga mensaheng mas luma sa %s?" + "Awtomatikong i-delete ang mga mensaheng mas luma sa %s at i-on ang awtomatikong pag-delete?" + "Sinabi ni %s" + "Sinabi mo" + "Mensahe mula kay %s" + "Nagpadala ka ng mensahe" + "Ipinapadala..." + "Hindi naipadala. Pindutin upang subukang muli.." + "Hindi naipadala. Sinusubukang muli…" + "Ipadalang muli o i-delete" + "Mangyaring mag-voice call sa mga pang-emergency na serbisyo. Sa ngayon, hindi maipadala ang iyong text message." + "Hindi nagawa" + "Bagong MMS message na ida-download" + "Bagong MMS message" + "Hindi mai-download" + "Pindutin upang subukang muli" + "Pindutin upang i-download" + "I-download o i-delete" + "Nagda-download..." + "Nag-expire o hindi available ang mensahe" + "laki: %1$s, expiration: %2$s" + "Hindi maipadala. Di-wasto ang tatanggap." + "Hindi naka-activate sa network ang serbisyo" + "Hindi maipadala dahil sa problema sa network" + "Nag-expire o hindi available ang mensahe" + "(Walang paksa)" + "Hindi kilalang nagpadala" + "Naihatid" + "Hindi ma-download ang mensaheng %1$s mula kay %2$s." + "Hindi makumpleto ang operation sa database dahil sa mababang memory" + "Hindi naipadala ang mensahe" + "Hindi naipadala sa Pagmemensahe ang ilang mensahe" + + %d mensahe sa %d pag-uusap + %d na mensahe sa %d na pag-uusap + + "Hindi na-download ang mensahe" + "Hindi na-download sa Pagmemensahe ang ilang mensahe" + + %d mensahe sa %d pag-uusap + %d na mensahe sa %d na pag-uusap + + "Hindi naipadala ang mensahe sa %1$s" + "Mangyaring mag-voice call sa mga pang-emergency na serbisyo. Sa ngayon, hindi maipadala ang iyong text message sa %1$s." + + %d bagong mensahe + %d na bagong mensahe + + "Magsimula" + "Hindi available ang camera" + "Hindi available ang camera" + "Hindi available ang pagkuha ng video" + "Hindi mai-save ang media" + "Hindi makakakuha ng larawan" + "Bumalik" + "Naka-archive" + "I-delete" + "I-archive" + "Alisin sa archive" + "I-off ang mga notification" + "I-on ang mga notification" + "Magdagdag ng contact" + "I-download" + "Ipadala" + "I-delete" + "I-delete ang mensaheng ito?" + "Hindi maaaring bawiin ang pagkilos na ito." + "I-delete" + + I-delete ang mga pag-uusap na ito? + I-delete ang mga pag-uusap na ito? + + "Tanggalin" + "Kanselahin" + "Para kay" + "Pumili ng maramihang larawan" + "Kumpirmahin ang pinili" + "+%d" + "Hindi mai-record ang audio. Subukang muli." + "Hindi mai-play ang audio. Subukang muli." + "Hindi mai-save ang audio. Subukang muli." + "Pindutin nang matagal" + ", " + " " + ": " + " " + "Larawan" + "Audio clip" + "Video" + "Contact card" + "I-download" + "Tumugon sa pamamagitan ng SMS" + "Tumugon sa pamamagitan ng MMS" + "Tumugon" + + %d kalahok + %d na kalahok + + "Ako" + "Na-block at na-archive ang contact" + "Naka-unblock at hindi naka-archive ang contact" + "%d ang na-archive" + "%d ang inalis sa pagkaka-archive" + "Naka-off ang mga notification" + "Naka-on ang mga notification" + "Handa na ang lahat. Pindutin muli ang Ipadala." + "Matagumpay na naitakda ang Pagmemensahe bilang default na SMS app." + + I-discard ang mga attachment + I-discard ang mga attachment + + "Attachment na audio" + "I-play ang attachment na audio" + "I-pause" + "Mensahe mula kay %s: %s." + "Hindi naipadalang mensahe mula kay %s: %s. Oras: %s." + "Mensahe mula kay %s: %s. Oras: %s." + "Hindi naipadalang mensahe kay %s: %s. Oras: %s." + "Ipinapadala ang mensahe kay %s: %s. Oras: %s." + "Hindi naipadalang mensahe kay %s: %s. Oras: %s." + "Mensahe kay %s: %s. Oras: %s." + "Hindi naipadalang mensahe mula kay %s: %s. Oras: %s. %s." + "Mensahe mula kay %s: %s. Oras: %s. %s." + "Hindi naipadalang mensahe sa %s: %s. Oras: %s." + "Ipinapadala ang mensahe sa %s: %s. Oras: %s." + "Hindi naipadalang mensahe sa %s: %s. Oras: %s." + "Mensahe sa %s: %s. Oras: %s." + "Hindi naipadalang mensahe. Pindutin upang subukang muli." + "Pag-uusap kasama sina %s" + "I-delete ang paksa" + "Kumuha ng video" + "Kumuha ng still na larawan" + "Kumuha ng larawan" + "Simulan ang pag-record ng video" + "Lumipat sa camera na full screen" + "Lumipat sa pagitan ng camera sa harap at likod" + "Ihinto ang pagre-record at i-attach ang video" + "Itigil ang pagre-record ng video" + "Mga larawan sa pagmemensahe" + + %d larawan ang naka-save sa album na \"%s\" + %d na larawan ang naka-save sa album na \"%s\" + + + %d video ang naka-save sa album na \"%s\" + %d na video ang naka-save sa album na \"%s\" + + + %d attachment ang naka-save sa album na \"%s\" + %d na attachment ang naka-save sa album na \"%s\" + + + %d attachment ang naka-save sa \"Mga Download\" + %d na attachment ang naka-save sa \"Mga Download\" + + + %d attachment ang na-save + %d na attachment ang na-save + + + Hindi ma-save ang %d attachment + Hindi ma-save ang %d na attachment + + "Na-save na MMS attachment" + "Mga Setting" + "Naka-archive" + "Isara" + "MMS" + "Advanced" + "I-debug" + "Mga Notification" + "Tunog" + "Naka-silent" + "Mag-vibrate" + "Naka-block" + "Mga ulat sa pagpapadala ng SMS" + "Humiling ng ulat sa paghahatid para sa bawat SMS na iyong ipinapadala" + "I-auto-retrieve" + "Awtomatikong kunin ang mga mensaheng MMS" + "Awtomatikong pagkuha sa roaming" + "Awtomatikong kunin ang MMS kapag naka-roaming" + "Panggrupong pagpapadala ng mensahe" + "Gamitin ang MMS upang magpadala ng iisang mensahe kapag may maraming recipient" + "Default na SMS app" + "Default na SMS app" + + + "Numero ng iyong telepono" + "Hindi kilala" + "Mga tunog ng papalabas na mensahe" + "Itapon ang SMS" + "Itapon ang raw data ng natanggap na SMS sa file ng external storage" + "Itapon ang MMS" + "Itapon ang raw data ng natanggap na MMS sa file ng external storage" + "Mga wireless na alerto" + "Mga opsyon sa mensahe" + "Kopyahin ang text" + "Tingnan ang mga detalye" + "I-delete" + "Ipasa" + "Mga detalye ng mensahe" + "Uri: " + "Text message" + "Multimedia message" + "Mula kay: " + "Para kay: " + "Ipinadala: " + "Natanggap: " + "Paksa: " + "Laki: " + "Priyoridad: " + "SIM: " + "Mataas" + "Normal" + "Mababa" + "SIM %s" + "Nakatago ang address ng nagpadala" + "Hindi maaaring magpadala ng mensahe habang naglo-load ng mga attachment." + "Hindi mai-load ang attachment. Subukang muli." + "Hindi pa handa ang network. Subukang muli." + "I-delete ang text" + "Lumipat sa pagitan ng paglagay ng text at mga numero" + "Magdagdag ng higit pang mga kalahok" + "Kumpirmahin ang mga kalahok" + "Magsimula ng bagong pag-uusap" + "Piliin ang item na ito" + "I-play ang video" + "Mga tao at opsyon" + "I-debug" + "Mga tao at opsyon" + "Pangkalahatan" + "Mga tao sa pag-uusap na ito" + "Tumawag" + "Magpadala ng mensahe" + "Magpadala ng mensahe<br/><small>mula sa %s</small>" + + Ipadala ang mga larawan + Ipadala ang mga larawan + + + Ipadala ang mga audio + Ipadala ang mga audio + + + Ipadala ang mga video + Ipadala ang mga video + + + Ipadala ang mga contact card + Ipadala ang mga contact card + + + Ipadala ang mga attachment + Ipadala ang mga attachment + + + %d attachment ang handa nang ipadala + %d na attachment ang handa nang ipadala + + "Magpadala ng feedback" + "Tingnan sa Google Play Store" + "Impormasyon sa bersyon" + "Bersyon %1$s" + "Mga open source na lisensya" + "Mga Notification" + "Naabot na ang limitasyon ng attachment" + "Hindi na-load ang attachment." + "Idagdag sa Mga Contact?" + "Magdagdag ng Contact" + "Subject" + "Subject: " + "%s%s" + "Nilo-load ang contact card" + "Hindi mai-load ang contact card" + "Tingnan ang contact card" + + %d contact + %d na contact + + "Mga contact card" + "Kaarawan" + "Mga Tala" + "Ipasa ang mensahe" + "Tumugon" + "+1" + "+%d" + "Naka-disable ang SMS" + "Upang ipadala, itakda ang Pagmemensahe bilang default na SMS app" + "Itakda ang Pagmemensahe bilang default na SMS app" + "Baguhin" + "Upang makatanggap ng mga mensahe, itakda ang Pagmemensahe bilang default na SMS app" + "Walang piniling gustong SIM para sa pagpapadala ng mga mensaheng SMS" + "Hindi pinapayagan ang app na ito ng may-ari ng device." + "OK" + "Masyadong maraming kalahok sa isang pag-uusap" + + Mga di-wastong contact + Mga di-wastong contact + + "Hindi mai-load ang larawan ng camera" + "Ikaw: " + "%s: " + "Draft" + "Sa sandaling magsimula ka ng bagong pag-uusap, makikita mo ito na nakalista rito" + "Lumalabas dito ang mga naka-archive na pag-uusap" + "Nilo-load ang mga pag-uusap…" + "Larawan" + "Audio clip" + "Video" + "Card sa pakikipag-ugnayan" + "MMS" + "I-undo" + "Subukang muli" + "Maglagay ng pangalan ng contact o numero ng telepono upang magsimula ng isang bagong mensahe" + "I-block" + "I-block ang %s" + "Alisin sa pagkaka-block ang %s" + "I-block ang %s?" + "Patuloy kang makakatanggap ng mga mensahe mula sa numerong ito ngunit hindi ka na aabisuhan. Maa-archive ang pag-uusap na ito." + "Mga na-block na contact" + "ALISIN SA PAGKAKA-BLOCK" + "Mga na-block na contact" + "Pumili ng larawan mula sa library ng dokumento" + "Ipinapadala ang mensahe" + "Naipadala na ang mensahe" + "Naka-off ang cellular data. Suriin ang iyong mga setting." + "Hindi maaaring magpadala ng mga mensahe sa Airplane mode" + "Hindi maipadala ang mensahe" + "Na-download na ang mensahe" + "Naka-off ang cellular data. Tingnan ang iyong mga setting." + "Hindi maaaring mag-download ng mga mensahe sa Airplane mode" + "Hindi ma-download ang mensahe" + "Zero" + "Isa" + "Dalawa" + "Tatlo" + "Apat" + "Lima" + "Anim" + "Pito" + "Walo" + "Siyam" + "Hindi maipadala ang mensahe gamit ang %1$s, error na %2$d" + "Hindi maipadala ang mensahe gamit ang hindi nakikilalang carrier, error na %1$d" + "Fwd: %s" + "Hindi napadala ang mensahe: hindi naka-activate sa network ang serbisyo" + "Hindi naipadala ang mensahe: di-wastong patutunguhang address" + "Hindi naipadala ang mensahe: di-wastong mensahe" + "Hindi naipadala ang mensahe: hindi sinusuportahang content" + "Hindi naipadala ang mensahe: hindi sinusuportahang mensahe" + "Hindi napadala ang mensahe: masyadong malaki" + "Bagong mensahe" + "Tingnan" + "Larawan" + "Hindi makahanap ng naaangkop na application" + "Alisin ang tatanggap" + "Bagong mensahe" + "Kanselahin" + "I-edit ang access point" + "Hindi nakatakda" + "Pangalan" + "APN" + "MMSC" + "Proxy ng MMS" + "Port ng MMS" + "MCC" + "MNC" + "Uri ng APN" + "I-delete ang APN" + "Bagong APN" + "I-save" + "I-discard" + "Hindi maaaring walang laman ang field ng Pangalan." + "Hindi maaaring walang laman ang APN." + "Dapat na 3 digit ang field na MCC." + "Dapat na 2 o 3 digit ang field ng MNC." + "Nire-restore ang default na mga setting ng APN" + "I-reset sa default" + "Nakumpleto na ang pag-reset sa default na mga setting ng APN." + "Walang pamagat" + "Mga Access Point Name" + "Mga APN" + "Bagong APN" + "Hindi available ang mga setting ng Access Point Name para sa user na ito" + "Kopyahin sa clipboard?" + "Kopyahin" + "sa %s" + "Pangkalahatan" + "Advanced" + "Mga pangkalahatang setting" + "Mga advanced na setting" + "\"%s\" SIM" + "Magpadala ng mga indibidwal na mensaheng SMS sa lahat ng tatanggap. Ikaw lang ang makakatanggap ng anumang mga tugon" + "Magpadala ng iisang MMS sa lahat ng tatanggap" + "Hindi kilalang numero" + "Bagong mensahe" + "Bagong mensahe." + "SIM selector" + "Pinili ang %1$s, selector ng SIM" + "I-edit ang paksa" + "Pumili ng SIM o i-edit ang paksa" + "Pindutin nang matagal upang i-record ang audio" + "Magsimula ng bagong pag-uusap" + "Pagmemensahe" + "Listahan ng Pagmemensahe" + "Pagmemensahe" + "Bagong mensahe" + "Listahan ng pag-uusap" + "Nilo-load ang mga pag-uusap" + "Nilo-load ang mga mensahe" + "Tumingin ng higit pang pag-uusap" + "Tingnan ang higit pang mga mensahe" + "Na-delete na ang pag-uusap" + "Na-delete na ang pag-uusap. Pindutin upang magpakita ng iba\'t ibang pag-uusap sa Pagmemensahe" + "Na-block" + "Na-unblock" + "Kaunti na lang ang espasyo sa storage. Maaaring mawala ang ilang data." + "Pumili ng mga attachment" + "Kumpirmahin ang napili" + "%d ang napili" + "Mangyaring mag-alis ng isa o higit pang mga attachment at subukang muli." + "Maaari mong subukang ipadala ang iyong mensahe, ngunit maaaring hindi ito maipadala maliban kung mag-aalis ka ng isa o higit pang mga attachment." + "Maaari ka lang magpadala ng isang video sa bawat mensahe. Mangyaring alisin ang mga karagdagang video at subukang muli." + "Hindi na-load ng pagmemensahe ang attachment." + "Ipadala pa rin" + "Hindi makapagsimula ng pag-uusap" + "%1$s (%2$s)" + "%s ang napili" + diff --git a/res/values-tr/arrays.xml b/res/values-tr/arrays.xml new file mode 100644 index 0000000..d0b4e84 --- /dev/null +++ b/res/values-tr/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "konu yok" + "konu yok" + + + "Evet" + "Hayır" + "Tamam" + "Olur" + "Teşekkürler" + "Kabul ediyorum" + "Güzel" + "Yoldayım" + "Tamam, sana sonra dönerim" + ":)" + ":(" + + diff --git a/res/values-tr/strings.xml b/res/values-tr/strings.xml new file mode 100644 index 0000000..6ea1cbe --- /dev/null +++ b/res/values-tr/strings.xml @@ -0,0 +1,519 @@ + + + + + "Mesajlaşma" + "Mesajlaşma" + "İleti dizisini seç" + "Ayarlar" + "İleti Gönder" + "Dosya ekle" + "Yardım" + "Hoş geldiniz" + "Atla" + "Sonraki >" + "Sonraki" + "Çık" + "Ayarlar >" + "Ayarlar" + "Mesajlaşma\'nın SMS, Telefon ve Kişiler için izninize ihtiyacı var." + "İzinleri; Ayarlar > Uygulamalar > Mesajlaşma > İzinler\'de değiştirebilirsiniz." + "İzinleri; Ayarlar, Uygulamalar, Mesajlaşma, İzinler\'de değiştirebilirsiniz." + "Sık kullanılanlar" + "Tüm kişiler" + "Şuraya gönder: %s" + "Fotoğraf veya video çekin" + "Bu cihazdan resim seç" + "Ses kaydedin" + "Fotoğraf seç" + "Medya seçildi." + "Medya seçilmedi." + "%d öğe seçildi" + "resim %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "resim" + "Ses kaydedin" + "Paylaş" + "Şimdi" + "Şimdi" + + %d dk. + %d dk. + + + %d saat + %d saat + + + %d gün + %d gün + + + %d hafta + bir hafta + + + %d ay + bir ay + + + %d yıl + bir yıl + + "Sınıf 0 iletisi" + "Kaydet" + "Cihazınızda boş alan az. Mesajlaşma boş alan açmak için daha eski iletileri otomatik olarak silecektir." + "Depolama alanı bitiyor" + "Mesajlaşma, cihazınızda daha fazla yer açılana kadar ileti gönderip almayabilir." + "SMS depolama alanı azaldı. İletileri silmeniz gerekebilir." + "Telefon numaranızı doğrulayın" + "Bu bir kerelik adım, Mesajlaşma\'nın, grup iletilerini düzgün olarak teslim etmesini sağlayacaktır." + "Telefon numarası" + "Medya içeren tüm iletileri sil" + "Şundan eski iletileri sil: %s" + "Şundan daha eski iletileri otomatik olarak sil: %s" + "Yoksay" + "Medya içeren tüm iletiler silinsin mi?" + "Şundan daha eski iletiler silinsin mi: %s?" + "Şundan daha eski iletiler silinip otomatik silme etkinleştirilsin mi: %s?" + "%s şunu dedi:" + "Şunu dediniz:" + "%s adlı kişiden ileti" + "Bir ileti gönderdiniz" + "Gönderiliyor..." + "Gönderilmedi. Tekrar denemek için dokunun." + "Gönderilmedi. Tekrar deniyor…" + "Yeniden gönderin veya silin" + "Lütfen acil durum servislerine telefon edin. Kısa mesajınız şu anda teslim edilemedi." + "Başarısız oldu" + "İndirilecek yeni MMS iletisi" + "Yeni MMS iletisi" + "İndirilemedi" + "Tekrar denemek için dokunun" + "İndirmek için dokunun" + "İndirin veya silin" + "İndiriliyor..." + "İletinin süresi doldu veya kullanılamıyor" + "boyut: %1$s, süre sonu: %2$s" + "Gönderilemiyor. Alıcı geçersiz." + "Hizmet, ağda etkinleştirilmemiş" + "Ağ sorunu nedeniyle gönderilemedi" + "İletinin süresi doldu veya kullanılamıyor" + "(Konu yok)" + "Bilinmeyen gönderen" + "Teslim edildi" + "%2$s tarafından gönderilen %1$s konulu ileti indirilemedi." + "Yetersiz bellek nedeniyle veritabanı işlemi tamamlanamadı" + "İleti gönderilmedi" + "Bazı iletiler Mesajlaşma\'da gönderilmedi" + + %d ileti dizisinde %d ileti + Bir ileti dizisinde %d ileti + + "İleti indirilmedi" + "Bazı iletiler Mesajlaşma\'dan indirilmedi" + + %d ileti dizisinde %d ileti + Bir ileti dizisinde %d ileti + + "%1$s numaralı telefona ileti gönderilemedi" + "Lütfen acil durum servislerine telefon edin. %1$s numaralı telefona gönderdiğiniz kısa mesajınız şu anda teslim edilemedi." + + %d yeni ileti + Yeni ileti + + "Başlat" + "Kamera kullanılamıyor" + "Kamera kullanılamıyor" + "Video çekimi kullanılamıyor" + "Medya kaydedilemiyor" + "Resim çekilemiyor" + "Geri" + "Arşivlenen" + "Sil" + "Arşivle" + "Arşivden kaldır" + "Bildirimleri devre dışı bırak" + "Bildirimleri aç" + "Kişi ekle" + "İndir" + "Gönder" + "Sil" + "Bu ileti silinsin mi?" + "Bu işlem geri alınamaz." + "Sil" + + Bu ileti dizileri silinsin mi? + Bu ileti dizisi silinsin mi? + + "Sil" + "İptal" + "Alıcı:" + "Birden fazla resim seç" + "Seçimi onayla" + "+%d" + "Ses kaydedilemiyor. Tekrar deneyin." + "Ses çalınamıyor. Tekrar deneyin." + "Ses kaydedilemedi. Tekrar deneyin." + "Dokunup basılı tutun" + ", " + " " + ": " + " " + "Resim" + "Ses klibi" + "Video" + "Kişi kartı" + "İndir" + "SMS ile yanıtla" + "MMS ile yanıtla" + "Yanıtla" + + %d katılımcı + %d katılımcı + + "Ben" + "Kişi engellendi ve arşivlendi" + "Kişinin engellemesi kaldırıldı ve kişi arşivden geri alındı" + "%d ileti dizisi arşivlendi" + "%d ileti dizisi arşivden kaldırıldı" + "Bildirimler kapalı" + "Bildirimler açık" + "Tümü ayarlandı. Gönder\'e tekrar dokunun." + "Mesajlaşma, başarılı bir şekilde varsayılan SMS uygulaması olarak ayarlandı." + + Ekleri sil + Eki sil + + "Ses dosyası eki" + "Ekli ses dosyasını çal" + "Duraklat" + "%s adlı kişiden ileti: %s." + "Şu ileti %s tarafından gönderilemedi: %s. Saat: %s." + "Şu ileti %s tarafından gönderildi: %s. Saat: %s." + "Şu ileti %s adlı kişiye gönderilmedi: %s. Saat: %s." + "Şu ileti %s adlı kişiye gönderiliyor: %s. Saat: %s." + "Şu ileti %s adlı kişiye gönderilemedi: %s. Saat: %s." + "Şu ileti %s adlı kişiye gönderildi: %s. Saat: %s." + "Şu ileti %s tarafından gönderilemedi: %s. Saat: %s. %s." + "Şu ileti %s tarafından gönderildi: %s. Saat: %s. %s." + "Şu ileti %s adlı gruba gönderilmedi: %s. Saat: %s." + "Şu ileti %s adlı gruba gönderiliyor: %s. Saat: %s." + "Şu ileti %s adlı gruba gönderilemedi: %s. Saat: %s." + "Şu ileti %s adlı gruba gönderildi: %s. Saat: %s." + "İleti başarısız oldu. Yeniden denemek için dokunun." + "%s ile ileti dizisi" + "Konuyu sil" + "Video çek" + "Fotoğraf çek" + "Fotoğraf çek" + "Video kaydetmeye başla" + "Tam ekran kameraya geç" + "Ön ve arka kamera arasında geçiş yap" + "Kaydı durdur ve video ekle" + "Video kaydını durdur" + "Mesajlaşma fotoğrafları" + + %d fotoğraf, \"%s\" albümüne kaydedildi + %d fotoğraf, \"%s\" albümüne kaydedildi + + + %d video, \"%s\" albümüne kaydedildi + %d video, \"%s\" albümüne kaydedildi + + + Ekli %d dosya, \"%s\" albümüne kaydedildi + Ekli %d dosya, \"%s\" albümüne kaydedildi + + + Ekli %d dosya, \"İndirilenler\"e kaydedildi + Ekli %d dosya, \"İndirilenler\"e kaydedildi + + + %d ek kaydedildi + %d ek kaydedildi + + + %d ek kaydedilemedi + %d ek kaydedilemedi + + "MMS eki kaydedildi" + "Ayarlar" + "Arşivlenen" + "Kapat" + "MMS" + "Gelişmiş" + "Hata Ayıklama" + "Bildirimler" + "Ses" + "Sessiz" + "Titreşim" + "Engellendi" + "SMS iletim raporları" + "Gönderilen her SMS için bir iletim raporu iste" + "Otomatik al" + "MMS iletilerini otomatik olarak al" + "Dolaşımda otomatik al" + "Dolaşımdayken MMS\'leri otomatik olarak al" + "Grup mesajları" + "Birden fazla alıcıya tek bir ileti göndermek için MMS kullanın" + "Varsayılan SMS uygulaması" + "Varsayılan SMS uygulaması" + + + "Telefon numaranız" + "Bilinmiyor" + "Giden ileti sesleri" + "SMS verilerini aktar" + "Alınan ham SMS verilerini harici bir depolama dosyasına aktarın" + "MMS verilerini aktar" + "Alınan ham MMS verilerini harici bir depolama dosyasına aktarın" + "Kablosuz uyarıları" + "İleti seçenekleri" + "Metni kopyala" + "Ayrıntıları görüntüle" + "Sil" + "Yönlendir" + "İleti ayrıntıları" + "Tür: " + "Kısa mesaj" + "Multimedya iletisi" + "Gönderen: " + "Alıcı: " + "Gönderilme tarihi: " + "Alınma tarihi: " + "Konu: " + "Boyut: " + "Öncelik: " + "SIM: " + "Yüksek" + "Normal" + "Düşük" + "SIM %s" + "Gizli gönderen adresi" + "Ekler yüklenirken ileti gönderilemez." + "Ek yüklenemiyor. Tekrar deneyin." + "Ağ hazır değil. Tekrar deneyin." + "Metni sil" + "Metin ve sayı girişi arasında geçiş yap" + "Daha fazla katılımcı ekle" + "Katılımcıları onayla" + "Yeni ileti dizisi başlat" + "Bu öğeyi seçin" + "Videoyu oynat" + "Kişiler ve seçenekler" + "Hata ayıkla" + "Kişiler ve seçenekler" + "Genel" + "Bu görüşmedeki kişiler" + "Çağrı yap" + "İleti gönder" + "Mesajı şu karttan gönder:<br/><small>%s</small>" + + Fotoğrafları gönder + Fotoğrafı gönder + + + Ses dosyalarını gönder + Ses dosyasını gönder + + + Videoları gönder + Videoyu gönder + + + Kişi kartlarını gönder + Kişi kartını gönder + + + Ekleri gönder + Eki gönder + + + %d ek gönderilmeye hazır + Bir ek gönderilmeye hazır + + "Geri bildirim gönder" + "Google Play Store\'da görüntüle" + "Sürüm bilgisi" + "Sürüm %1$s" + "Açık kaynak lisansları" + "Bildirimler" + "Ek sınırına ulaşıldı" + "Ek yüklenemedi." + "Kişilere eklensin mi?" + "Kişi Ekle" + "Konu" + "Konu: " + "%s%s" + "Kişi kartı yükleniyor" + "Kişi kartı yüklenemedi" + "Kişi kartını görüntüle" + + %d kişi + %d kişi + + "Kişi kartları" + "Doğum günü" + "Notlar" + "İletiyi yönlendir" + "Yanıtla" + "+1" + "+%d" + "SMS devre dışı bırakıldı" + "Göndermek için Mesajlaşma\'yı varsayılan SMS uygulamanız olarak ayarlayın" + "Mesajlaşma\'yı varsayılan SMS uygulamanız olarak ayarlayın" + "Değiştir" + "İletileri almak için Mesajlaşma\'yı varsayılan SMS uygulamanız olarak ayarlayın" + "SMS iletilerini göndermek için SIM tercihi yapılmadı" + "Cihaz sahibi bu uygulamaya izin vermiyor." + "Tamam" + "Bir görüşmede çok fazla katılımcı var" + + Geçersiz kişiler + Geçersiz kişi + + "Kamera görüntüsü yüklenemedi" + "Siz: " + "%s: " + "Taslak" + "Yeni bir görüşme başlattığınızda burada listelendiğini göreceksiniz" + "Arşivlenmiş ileti dizileri burada görünür" + "İleti dizileri yükleniyor..." + "Resim" + "Ses klibi" + "Video" + "Kişi kartı" + "MMS" + "Geri al" + "Yeniden dene" + "Yeni bir iletiye başlamak için bir kişi adı veya telefon numarası girin" + "Engelle" + "%s hedefini engelle" + "%s adlı kişinin engellemesini kaldır" + "%s engellensin mi?" + "Bu numaradan gelen iletileri almaya devam edecek ancak artık bildirim almayacaksınız. Bu ileti dizisi arşivlenecektir." + "Engellenen kişiler" + "ENGELLEMEYİ KALDIR" + "Engellenen kişiler" + "Doküman kitaplığından resim seç" + "İleti gönderiliyor" + "İleti gönderildi" + "Hücresel veri özelliği kapalı. Ayarlarınızı kontrol edin." + "Uçak modunda iletiler gönderilemiyor" + "İleti gönderilemedi" + "İleti indirildi" + "Hücresel veri özelliği kapalı. Ayarlarınızı kontrol edin." + "Uçak modundayken iletiler indirilemez" + "İleti indirilemedi" + "Sıfır" + "Bir" + "İki" + "Üç" + "Dört" + "Beş" + "Altı" + "Yedi" + "Sekiz" + "Dokuz" + "%1$s ile ileti gönderilemiyor. Hata kodu: %2$d" + "Bilinmeyen operatör ile ileti gönderilemiyor. Hata kodu: %1$d" + "Yön: %s" + "İleti gönderilmedi: Hizmet, ağ üzerinde etkin değil" + "İleti gönderilmedi: Geçersiz gönderim adresi" + "İleti gönderilmedi: Geçersiz ileti" + "İleti gönderilmedi: Desteklenmeyen içerik" + "İleti gönderilmedi: Desteklenmeyen ileti" + "İleti gönderilmedi: Çok büyük" + "Yeni ileti" + "Görüntüle" + "Resim" + "Uygun bir uygulama bulunamadı" + "Alıcıyı kaldır" + "Yeni ileti" + "İptal" + "Erişim noktasını düzenle" + "Ayarlanmadı" + "Ad" + "APN" + "MMSC" + "MMS proxy\'si" + "MMS bağlantı noktası" + "MCC" + "MNC" + "APN türü" + "APN\'yi sil" + "Yeni APN" + "Kaydet" + "Yoksay" + "Ad alanı boş olamaz." + "APN boş olamaz." + "MCC alanındaki değer 3 rakamdan oluşmalıdır." + "MNC alanındaki değer 2 veya 3 rakamdan oluşmalıdır." + "Varsayılan APN ayarları geri yükleniyor." + "Varsayılana sıfırla" + "Varsayılan APN ayarlarına sıfırlama tamamlandı." + "Başlıksız" + "Erişim Noktası Adları (APN)" + "APN\'ler" + "Yeni APN" + "Erişim Noktası Adı (APN) ayarları bu kullanıcı için kullanılamıyor" + "Panoya kopyalansın mı?" + "Kopyala" + "Mesajın alınacağı kart: %s" + "Genel" + "Gelişmiş" + "Genel ayarlar" + "Gelişmiş ayarlar" + "\"%s\" SIM" + "Tüm alıcılara tek tek SMS iletisi gönder. Sadece size yanıt gönderilir." + "Tüm alıcılara tek bir MMS iletisi gönder" + "Bilinmeyen numara" + "Yeni ileti" + "Yeni ileti." + "SIM seçici" + "%1$s seçildi, SIM seçici" + "Konuyu düzenle" + "SIM seçin veya konuyu düzenleyin" + "Ses kaydetmek için dokunun ve basılı tutun" + "Yeni ileti dizisi başlat" + "Mesajlaşma" + "Mesajlaşma Listesi" + "Mesajlaşma" + "Yeni ileti" + "İleti dizisi listesi" + "İleti dizileri yükleniyor" + "İletiler yükleniyor" + "Diğer ileti dizilerini görüntüle" + "Daha fazla ileti görüntüle" + "İleti dizisi silindi" + "İleti dizisi silindi. Farklı bir Mesajlaşma ileti dizisini görüntülemek için dokunun" + "Engellendi" + "Engellemesi kaldırıldı" + "Depolama alanı az. Bazı veriler kaybolabilir." + "Ekleri seçin" + "Seçimi onayla" + "%d öğe seçildi" + "Lütfen bir veya daha fazla eki kaldırın ve tekrar deneyin." + "İletinizi göndermeyi deneyebilirsiniz, ancak bir veya daha fazla eki kaldırmadığınız sürece ileti teslim edilmeyebilir." + "Her iletide yalnızca bir tane video gönderebilirsiniz. Lütfen ek videoları kaldırın ve tekrar deneyin." + "Mesajlaşma, eki yükleyemedi." + "Yine de gönder" + "Sohbet başlatılamadı" + "%1$s (%2$s)" + "%s seçildi" + diff --git a/res/values-uk/arrays.xml b/res/values-uk/arrays.xml new file mode 100644 index 0000000..fc47a64 --- /dev/null +++ b/res/values-uk/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "без теми" + "без теми" + + + "Так" + "Ні" + "ОК" + "Ха-ха-ха" + "Дякую" + "Погоджуюсь" + "Чудово" + "У дорозі" + "Добре, я зателефоную пізніше" + ":)" + ":(" + + diff --git a/res/values-uk/strings.xml b/res/values-uk/strings.xml new file mode 100644 index 0000000..1c44548 --- /dev/null +++ b/res/values-uk/strings.xml @@ -0,0 +1,571 @@ + + + + + "Повідомлення" + "Повідомлення" + "Виберіть ланцюжок повідомлень" + "Налаштування" + "Надіслати повідомлення" + "Вкласти файл" + "Довідка" + "Вітаємо" + "Пропустити" + "Далі >" + "Далі" + "Вийти" + "Налаштування >" + "Налаштування" + "Додатку Повідомлення потрібен доступ до SMS, телефона й контактів." + "Щоб змінити дозволи, виберіть \"Налаштування\" > \"Додатки\" > \"Повідомлення\" > \"Дозволи\"." + "Щоб змінити дозволи, виберіть \"Налаштування\" > \"Додатки\" > \"Повідомлення\" > \"Дозволи\"." + "Найчастіше на зв’язку" + "Усі контакти" + "Надіслати на номер %s" + "Зняти фото або відео" + "Вибрати зображення на цьому пристрої" + "Записати аудіо" + "Вибрати фотографію" + "Медіафайл вибрано." + "Медіафайл не вибрано." + "Вибрано %d" + "зображення: %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "зображення" + "Записати аудіо" + "Поділитися" + "Щойно" + "Зараз" + + %d хвилина + %d хвилини + %d хвилин + %d хвилини + + + %d година + %d години + %d годин + %d години + + + %d день + %d дні + %d днів + %d дня + + + %d тиждень + %d тижні + %d тижнів + %d тижня + + + %d місяць + %d місяці + %d місяців + %d місяця + + + %d рік + %d роки + %d років + %d року + + "Повідомлення класу 0" + "Зберегти" + "На пристрої замало місця. З додатка Повідомлення буде автоматично видалено старіші SMS, щоб звільнити місце." + "Закінчується пам’ять" + "Додаток Повідомлення не зможе надсилати й отримувати SMS, доки не звільниться місце на пристрої." + "Недостатньо місця для SMS. Видаліть старі повідомлення." + "Підтвердьте свій номер телефону" + "Номер потрібен, щоб надсилати групові повідомлення. Його потрібно ввести лише один раз." + "Номер телефону" + "Видалити всі повідомлення з медіафайлами" + "Видалити повідомлення, отримані понад %s тому" + "Автоматично видаляти повідомлення, отримані понад %s тому" + "Ігнорувати" + "Видалити всі повідомлення з медіафайлами?" + "Видалити повідомлення, отримані понад %s тому?" + "Видаляти повідомлення, отримані понад %s тому й увімкнути автоматичне видалення?" + "Повідомлення від користувача %s" + "Ваше повідомлення" + "Повідомлення від користувача %s" + "Ви надіслали повідомлення" + "Надсилання…" + "Не надіслано. Торкніться, щоб повторити спробу." + "Не надіслано. Повторна спроба…" + "Надіслати повторно або видалити" + "Не вдалося надіслати текстове повідомлення в екстрену службу. Спробуйте зателефонувати." + "Помилка" + "Доступне нове MMS-повідомлення" + "Нове MMS-повідомлення" + "Не вдалося завантажити" + "Торкніться, щоб повторити спробу" + "Торкніться, щоб завантажити" + "Завантажити або видалити" + "Завантаження…" + "Повідомлення прострочене або недоступне" + "розмір: %1$s, дійсне до: %2$s" + "Не вдається надіслати. Недійсний отримувач." + "Послугу не активовано в мережі" + "Не вдалося надіслати через проблему з мережею" + "Повідомлення прострочене або недоступне" + "(Без теми)" + "Невідомий відправник" + "Доставлено" + "Не вдалося завантажити повідомлення з темою \"%1$s\" від користувача %2$s." + "Не вдалося завершити дію з базою даних. Недостатньо пам’яті" + "Повідомлення не надіслано" + "Деякі повідомлення не надіслано" + + %d повідомлення в ланцюжках (%d) + %d повідомлення в ланцюжках (%d) + %d повідомлень у ланцюжках (%d) + %d повідомлення в ланцюжках (%d) + + "Повідомлення не завантажено" + "Деякі повідомлення не завантажено" + + %d повідомлення в ланцюжках (%d) + %d повідомлення в ланцюжках (%d) + %d повідомлень у ланцюжках (%d) + %d повідомлення в ланцюжках (%d) + + "Повідомлення на номер %1$s не надіслано" + "Не вдалося надіслати текстове повідомлення на номер екстреної служби %1$s. Спробуйте зателефонувати." + + %d нове повідомлення + %d нові повідомлення + %d нових повідомлень + %d нового повідомлення + + "Почати" + "Камера недоступна" + "Камера недоступна" + "Відеозйомка недоступна" + "Не вдається зберегти медіафайли" + "Не вдається сфотографувати" + "Назад" + "Архів" + "Видалити" + "Архів" + "Розархівувати" + "Вимкнути сповіщення" + "Увімкнути сповіщення" + "Додати контакт" + "Завантажити" + "Надіслати" + "Видалити" + "Видалити це повідомлення?" + "Цю дію не можна скасувати." + "Видалити" + + Видалити ці ланцюжки повідомлень? + Видалити ці ланцюжки повідомлень? + Видалити ці ланцюжки повідомлень? + Видалити ці ланцюжки повідомлень? + + "Видалити" + "Скасувати" + "Кому:" + "Вибрати кілька зображень" + "Підтвердити вибір" + "+%d" + "Не вдається записати аудіо. Повторіть спробу." + "Не вдається відтворити аудіо. Повторіть спробу." + "Не вдалося зберегти аудіо. Повторіть спробу." + "Натисніть і утримуйте" + ", " + " " + ": " + " " + "Зображення" + "Аудіокліп" + "Відео" + "Картка контакта" + "Завантажити" + "Відповісти за допомогою SMS" + "Надіслати MMS" + "Відповісти" + + %d учасник + %d учасники + %d учасників + %d учасника + + "Я" + "Користувача заблоковано та заархівовано" + "Контакт розблоковано та розархівовано" + "Заархівовано: %d" + "Розархівовано: %d" + "Сповіщення вимкнено" + "Сповіщення ввімкнено" + "Готово. Торкніться опції \"Надіслати\" знову." + "Повідомлення тепер є додатком для SMS за умовчанням." + + Видалити вкладені файли + Видалити вкладені файли + Видалити вкладені файли + Видалити вкладені файли + + "Вкладений аудіофайл" + "Відтворити аудіовкладення" + "Пауза" + "Повідомлення від користувача %s: %s." + "Не вдалося надіслати повідомлення від користувача %s: %s. Час: %s." + "Повідомлення від користувача %s: %s. Час: %s." + "Ненадіслане повідомлення групі %s: %s. Час: %s." + "Надсилається повідомлення групі %s: %s. Час: %s." + "Не вдалося надіслати повідомлення групі %s: %s. Час: %s." + "Повідомлення групі %s: %s. Час: %s." + "Не вдалося надіслати повідомлення від користувача %s: %s. Час: %s. %s." + "Повідомлення від користувача %s: %s. Час: %s. %s." + "Ненадіслане повідомлення групі %s: %s. Час: %s." + "Надсилається повідомлення групі %s: %s. Час: %s." + "Не вдалося надіслати повідомлення групі %s: %s. Час: %s." + "Повідомлення групі %s: %s. Час: %s." + "Не вдалося надіслати повідомлення. Торкніться, щоб повторити спробу." + "Учасники бесіди: %s" + "Видалити тему" + "Зняти відео" + "Зняти статичне зображення" + "Зробити знімок" + "Почати запис відео" + "Показувати зображення з камери на весь екран" + "Переключатися між камерами на передній і задній панелях" + "Зупинити запис і долучити відео" + "Зупинити запис відео" + "Фото з повідомлень" + + %d фотографію збережено в альбом \"%s\" + %d фотографії збережено в альбом \"%s\" + %d фотографій збережено в альбом \"%s\" + %d фотографії збережено в альбом \"%s\" + + + %d відео збережено в альбом \"%s\" + %d відео збережено в альбом \"%s\" + %d відео збережено в альбом \"%s\" + %d відео збережено в альбом \"%s\" + + + %d вкладений файл збережено в альбом \"%s\" + %d вкладені файли збережено в альбом \"%s\" + %d вкладених файлів збережено в альбом \"%s\" + %d вкладеного файлу збережено в альбом \"%s\" + + + %d вкладений файл збережено в папку \"Завантаження\" + %d вкладені файли збережено в папку \"Завантаження\" + %d вкладених файлів збережено в папку \"Завантаження\" + %d вкладеного файлу збережено в папку \"Завантаження\" + + + Збережено %d вкладений файл + Збережено %d вкладені файли + Збережено %d вкладених файлів + Збережено %d вкладеного файлу + + + Не вдалося зберегти %d вкладений файл + Не вдалося зберегти %d вкладені файли + Не вдалося зберегти %d вкладених файлів + Не вдалося зберегти %d вкладеного файлу + + "Вкладення з MMS збережено" + "Налаштування" + "Архів" + "Закрити" + "MMS" + "Додатково" + "Налагодження" + "Сповіщення" + "Сигнал" + "Без звуку" + "Вібросигнал" + "Заблоковано" + "Звіти про доставку SMS" + "Запитувати звіт про доставку для кожного надісланого SMS" + "Автоматичне отримання" + "Автоматично отримувати MMS-повідомлення" + "Автоотримання в роумінгу" + "Автоматично отримувати MMS у роумінгу" + "Групові повідомлення" + "Використовуйте опцію MMS, щоб надсилати одне повідомлення декільком одержувачам" + "Додаток для SMS за умовчанням" + "Додаток для SMS за умовчанням" + + + "Ваш номер телефону" + "Невідомий" + "Сигнали для вихідних повідомлень" + "Записати дані SMS" + "Записати отримані вихідні дані SMS у файл зовнішньої пам’яті" + "Записати дані MMS" + "Записати отримані вихідні дані MMS у файл зовнішньої пам’яті" + "Сповіщення через бездротові мережі" + "Параметри повідомлення" + "Копіювати текст" + "Переглянути деталі" + "Видалити" + "Переслати" + "Деталі повідомлення" + "Тип: " + "SMS" + "Мультимедійне повідомлення" + "Від: " + "Кому: " + "Надіслано: " + "Отримано: " + "Тема: " + "Розмір: " + "Пріоритет: " + "SIM-карта: " + "Високий" + "Звичайний" + "Низький" + "SIM-карта %s" + "Прихована адреса відправника" + "Неможливо надіслати повідомлення під час завантаження вкладених файлів." + "Не вдається завантажити вкладений файл. Повторіть спробу." + "Мережа не готова. Повторіть спробу." + "Видалити текст" + "Переключення між введенням тексту та числами" + "Додати більше учасників" + "Підтвердити учасників" + "Почати нову бесіду" + "Вибрати цей елемент" + "Відтворити відео" + "Учасники й опції" + "Налагодження" + "Учасники й опції" + "Загальні" + "Учасники цієї бесіди" + "Телефонувати" + "Надіслати повідомлення" + "Надіслати повідомлення<br/><small>із SIM-карти %s</small>" + + Надіслати фото + Надіслати фото + Надіслати фото + Надіслати фото + + + Надіслати аудіозаписи + Надіслати аудіозаписи + Надіслати аудіозаписи + Надіслати аудіозаписи + + + Надіслати відео + Надіслати відео + Надіслати відео + Надіслати відео + + + Надіслати картки контактів + Надіслати картки контактів + Надіслати картки контактів + Надіслати картки контактів + + + Надіслати вкладені файли + Надіслати вкладені файли + Надіслати вкладені файли + Надіслати вкладені файли + + + Можна надсилати %d вкладений файл + Можна надсилати %d вкладені файли + Можна надсилати %d вкладених файлів + Можна надсилати %d вкладеного файлу + + "Надіслати відгук" + "Переглянути в магазині Google Play" + "Інформація про версію" + "Версія %1$s" + "Ліцензії відкритого коду" + "Сповіщення" + "Ви вклали максимальну кількість файлів" + "Не вдалося завантажити вкладений файл." + "Додати в контакти?" + "Додати контакт" + "Тема" + "Тема: " + "%s. %s" + "Завантаження картки контакта" + "Не вдалося завантажити картку контакта" + "Переглянути картку контакта" + + %d контакт + %d контакти + %d контактів + %d контакта + + "Картки контактів" + "День народження" + "Нотатки" + "Переслати повідомлення" + "Відповісти" + "+1" + "+%d" + "SMS вимкнено" + "Щоб надсилати SMS, зробіть Повідомлення додатком для SMS за умовчанням" + "Зробіть Повідомлення додатком для SMS за умовчанням" + "Змінити" + "Щоб отримувати повідомлення, зробіть Повідомлення додатком для SMS за умовчанням" + "Не вибрано основної SIM-карти для надсилання SMS-повідомлень" + "Власник пристрою не надав дозволів для цього додатка." + "ОК" + "Забагато учасників у бесіді" + + Недійсні контакти + Недійсні контакти + Недійсні контакти + Недійсні контакти + + "Не вдалося завантажити зображення з камери" + "Ви: " + "%s: " + "Чернетка" + "Коли ви почнете переписку, вона з’явиться тут" + "Тут відображаються заархівовані бесіди" + "Завантаження бесід…" + "Зображення" + "Аудіокліп" + "Відео" + "Картка контакта" + "MMS" + "Скасувати" + "Повторити" + "Щоб створити нове повідомлення, введіть ім’я контакта або номер телефону" + "Заблокувати" + "Блокувати користувача %s" + "Розблокувати користувача %s" + "Заблокувати номер %s?" + "Ви надалі отримуватимете повідомлення з цього номера, але не отримуватимете сповіщення. Цю бесіду буде заархівовано." + "Заблоковані контакти" + "РОЗБЛОКУВАТИ" + "Заблоковані контакти" + "Вибрати зображення з бібліотеки документів" + "Повідомлення надсилається" + "Повідомлення надіслано" + "Мобільне передавання даних вимкнено. Перевірте налаштування." + "Не вдається надіслати повідомлення в режимі польоту" + "Не вдалося надіслати повідомлення" + "Повідомлення завантажено" + "Мобільний Інтернет вимкнено. Перевірте налаштування." + "Неможливо завантажити повідомлення в режимі польоту" + "Не вдалося завантажити повідомлення" + "Нуль" + "Один" + "Два" + "Три" + "Чотири" + "П’ять" + "Шість" + "Сім" + "Вісім" + "Дев’ять" + "Не вдається надіслати повідомлення. Помилка %2$d, оператор %1$s" + "Не вдається надіслати повідомлення. Помилка %1$d, невідомий оператор" + "Пересилання: %s" + "Повідомлення не надіслано: послугу не активовано в мережі" + "Повідомлення не надіслано. Недійсна адреса одержувача" + "Повідомлення не надіслано. Повідомлення недійсне" + "Повідомлення не надіслано. Вміст не підтримується" + "Повідомлення не надіслано. Повідомлення не підтримується" + "Повідомлення не надіслано: текст задовгий" + "Нове повідомлення" + "Переглянути" + "Зображення" + "Не вдалося знайти відповідний додаток" + "Видалити отримувача" + "Нове повідомлення" + "Скасувати" + "Редагування точки доступу" + "Не вказано" + "Назва" + "Назва точки доступу" + "MMSC" + "Проксі MMS" + "Порт MMS" + "MCC" + "MNC" + "Тип назви точки доступу" + "Видалити назву точки доступу" + "Нова назва точки доступу" + "Зберегти" + "Відхилити" + "Поле \"Назва\" не може бути порожнім." + "Поле назви точки доступу не може бути порожнім." + "Поле MCC має містити 3 цифри." + "Поле MNC має містити 2 або 3 цифри." + "Відновлення налаштувань назви точки доступу за умовчанням." + "Відновити налаштування за умовчанням" + "Налаштування назви точки доступу за умовчанням відновлено." + "Без назви" + "Назви точок доступу" + "Назви точок доступу" + "Нова назва точки доступу" + "Цей користувач не може вказувати назву точки доступу" + "Копіювати в буфер обміну?" + "Копіювати" + "на SIM-карту %s" + "Загальні" + "Розширені" + "Загальні налаштування" + "Додаткові налаштування" + "SIM-карта \"%s\"" + "Надсилати окремі SMS усім отримувачам. Відповіді надходитимуть лише вам" + "Надсилати одне MMS усім отримувачам" + "Невідомий номер" + "Нове повідомлення" + "Нове повідомлення." + "Вибір SIM-карти" + "Вибір SIM-карти: вибрано %1$s" + "Змінити тему" + "Вибрати SIM-карту або змінити тему" + "Торкніться й утримуйте, щоб записати аудіо" + "Почати нову бесіду" + "Повідомлення" + "Список у додатку Повідомлення" + "Повідомлення" + "Нове повідомлення" + "Список бесід" + "Завантаження бесід" + "Завантаження повідомлень" + "Переглянути інші бесіди" + "Переглянути більше повідомлень" + "Бесіду видалено" + "Бесіду видалено. Торкніться, щоб переглянути іншу бесіду в додатку Повідомлення" + "Заблоковано" + "Розблоковано" + "Замало пам’яті. Деякі дані можуть не зберегтись." + "Вибрати вкладені файли" + "Підтвердити вибір" + "Вибрано %d" + "Видаліть один або декілька вкладених файлів і повторіть спробу." + "Спробуйте надіслати повідомлення, але адресат може його не отримати, якщо ви не видалите один або декілька вкладених файлів." + "У повідомленні можна надіслати лише одне відео. Видаліть зайві відео та спробуйте знову." + "Не вдалося завантажити вкладенні файли." + "Усе одно надіслати" + "Не вдалося почати бесіду" + "%1$s (%2$s)" + "Вибрано %s" + diff --git a/res/values-ur-rPK/arrays.xml b/res/values-ur-rPK/arrays.xml new file mode 100644 index 0000000..fa4e5da --- /dev/null +++ b/res/values-ur-rPK/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "بلا عنوان" + "بلا عنوان" + + + "ہاں" + "نہیں" + "ٹھیک ہے" + "Hehe" + "شکریہ" + "میں متفق ہوں" + "اچھا" + "راستے میں ہوں" + "ٹھیک ہے، میں بعد میں آپ سے رابطہ کرتا ہوں" + "‎:)‎" + "‎:(‎" + + diff --git a/res/values-ur-rPK/strings.xml b/res/values-ur-rPK/strings.xml new file mode 100644 index 0000000..1ed22e1 --- /dev/null +++ b/res/values-ur-rPK/strings.xml @@ -0,0 +1,519 @@ + + + + + "پیغام رسانی" + "پیغام رسانی" + "گفتگو منتخب کریں" + "ترتیبات" + "پیغام بھیجیں" + "ایک منسلکہ شامل کریں" + "مدد" + "خوش آمدید" + "نظر انداز کریں" + "اگلا >" + "آگے" + "باہر نکلیں" + "ترتیبات >" + "ترتیبات" + "‏پیغام رسانی کو SMS، فون اور رابطوں کیلئے اجازت درکار ہے۔" + "آپ ترتیبات > ایپس > پیغام رسانی > اجازتوں میں جا کر اجازتیں تبدیل کر سکتے ہیں۔" + "آپ ترتیبات، ایپس، پیغام رسانی، اجازتوں میں جا کر اجازتیں تبدیل کر سکتے ہیں۔" + "اکثر و بیشتر" + "سبھی رابطے" + "%s پر بھیجیں" + "تصاویر یا ویڈیو کیپچر کریں" + "اس آلہ سے تصاویر منتخب کریں" + "آڈیو ریکارڈ کریں" + "تصویر منتخب کریں" + "میڈیا منتخب کردہ ہے۔" + "میڈیا غیر منتخب کردہ ہے۔" + "%d کو منتخب کیا گیا" + "تصویر %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "تصویر" + "آڈیو ریکارڈ کریں" + "اشتراک کریں" + "ابھی ابھی" + "ابھی" + + %d منٹ + %d منٹ + + + %d گھنٹے + %d گھنٹہ + + + %d دن + %d دن + + + %d ہفتے + ایک ہفتہ + + + %d مہینے + ایک ماہ + + + %d سال + ایک سال + + "کلاس 0 پیغام" + "محفوظ کریں" + "آلہ میں جگہ کم ہے۔ پیغام رسانی جگہ خالی کرنے کیلئے خودکار طریقے سے پرانے پیغامات حذف کر دے گی۔" + "اسٹوریج کی جگہ ختم ہو رہی ہے" + "جب تک آپ کے آلہ پر مزید جگہ دستیاب نہ ہو، ممکن ہے اس وقت تک پیغام رسانی نہ پیغامات بھیجے یا نہ موصول کرے۔" + "‏SMS کا کم اسٹوریج۔ آپ کو کچھ پیغامات کو حذف کرنے کی ضرورت پڑ سکتی ہے۔" + "اپنے فون نمبر کی توثیق کریں" + "یہ یک وقتی مرحلہ اس بات کو یقینی بنائے گا کہ پیغام رسانی آپ کے گروپ پیغامات کو مناسب طریقے سے ڈیلیور کرے گی۔" + "فون نمبر" + "میڈیا والے سبھی پیغامات کو حذف کریں" + "%s سے پرانے پیغامات کو حذف کر دیں" + "%s سے پہلے کے پیغامات کو خودکار طور پر حذف کریں" + "نظر انداز کریں" + "میڈیا کے ساتھ سبھی پیغامات کو حذف کریں؟" + "%s سے پہلے کے پیغامات کو حذف کریں؟" + "%s سے پہلے کے پیغامات کو حذف کریں اور خودکار طور پر حذف کرنے کو آن کریں؟" + "%s نے کہا" + "آپ نے کہا" + "پیغام منجانب %s" + "آپ نے ایک پیغام بھیجا" + "بھیجا جا رہا ہے…" + "بھیجا نہیں گیا۔ دوبارہ کوشش کرنے کیلئے ٹچ کریں۔" + "بھیجا نہیں گیا۔ دوبارہ کوشش کی جا رہی ہے…" + "دوبارہ بھیجیں یا حذف کریں" + "براہ کرم ہنگامی سروسز کیلئے ایک صوتی کال کریں۔ آپ کا متنی پیغام اس وقت ڈیلیور نہیں کیا جا سکا۔" + "ناکام" + "‏ڈاؤن لوڈ کرنے کیلئے نیا MMS پیغام" + "‏نیا MMS پیغام" + "ڈاؤن لوڈ نہیں کیا جا سکا" + "دوبارہ کوشش کرنے کیلئے ٹچ کریں" + "ڈاؤن لوڈ کرنے کیلئے ٹچ کریں" + "ڈاؤن لوڈ کریں یا حذف کریں" + "ڈاؤن لوڈ ہو رہا ہے…" + "پیغام کی میعاد ختم ہو گئی یا دستیاب نہیں ہے" + "سائز: %1$s، اختتامی میعاد: %2$s" + "بھیجا نہیں جا سکتا۔ وصول کنندہ درست نہیں ہے۔" + "نیٹ ورک پر سروس فعال نہیں ہے" + "نیٹ ورک کے مسئلہ کی وجہ سے نہیں بھیجا جا سکا" + "پیغام کی میعاد ختم ہو گئی یا دستیاب نہیں ہے" + "(بلا عنوان)" + "نامعلوم ارسال کنندہ" + "ڈیلیور ہو گیا" + "پیغام %1$s کو %2$s سے ڈاؤن لوڈ نہیں کیا جا سکا۔" + "کم میموری کی وجہ سے ڈیٹا بیس کی کارروائی مکمل نہیں ہو سکی" + "پیغام نہیں بھیجا گیا" + "پیغام رسانی میں کچھ پیغامات کو نہیں بھیجا گیا" + + %d گفتگوؤں میں %d پیغامات + ایک گفتگو میں %d پیغامات + + "پیغام ڈاؤن لوڈ نہیں ہوا" + "پیغام رسانی میں کچھ پیغامات ڈاؤن لوڈ نہیں ہوئے" + + %d گفتگوؤں میں %d پیغامات + ایک گفتگو میں %d یغامات + + "%1$s پر پیغام نہیں بھیجا گیا" + "براہ کرم ہنگامی سروسز کیلئے ایک صوتی کال کریں۔ %1$s پر آپ کا متنی پیغام اس وقت ڈیلیور نہیں کیا جا سکا۔" + + %d نئے پیغامات + نیا پیغام + + "شروع کریں" + "کیمرہ دستیاب نہیں ہے" + "کیمرہ دستیاب نہیں ہے" + "ویڈیو کیپچر دستیاب نہیں ہے" + "میڈیا کو محفوظ نہیں کیا جا سکتا ہے" + "تصویر نہیں لی جا سکتی" + "واپس جائیں" + "آرکائیو کردہ" + "حذف کریں" + "آرکائیو کریں" + "آرکائیو سے نکالیں" + "اطلاعات آف کریں" + "اطلاعات آن کریں" + "رابطہ شامل کریں" + "ڈاؤن لوڈ کریں" + "بھیجیں" + "حذف کریں" + "یہ پیغام حذف کریں؟" + "اس عمل کو کالعدم نہیں کیا جاسکتا۔" + "حذف کریں" + + ان گفتگوؤں کو حذف کریں؟ + اس گفتگو کو حذف کریں؟ + + "حذف کریں" + "منسوخ کریں" + "بنام" + "متعدد تصاویر منتخب کریں" + "انتخاب کی توثیق کریں" + "‎+%d" + "آڈیو کو ریکارڈ نہیں کیا جا سکتا ہے۔ دوبارہ کوشش کریں۔" + "آڈیو کو چلایا نہیں جا سکتا ہے۔ دوبارہ کوشش کریں۔" + "آڈیو کو محفوظ نہیں کیا جا سکا۔ دوبارہ کوشش کریں۔" + "ٹچ کریں اور دبائے رکھیں" + "، " + " " + ": " + " " + "تصویر" + "آڈیو کلپ" + "ویڈیو" + "رابطہ کارڈ" + "ڈاؤن لوڈ کریں" + "‏SMS کے ذریعے جواب دیں" + "‏MMS کے ذریعے جواب دیں" + "جواب دیں" + + %d شرکت کنندگان + %d شرکت کنندہ + + "میں" + "رابطہ کو مسدود اور آرکائیو کر دیا گیا" + "رابطہ کو غیر مسدود کر دیا گیا اور آرکائیو سے نکال دیا گیا" + "%d کو آرکائیو کر دیا گیا" + "%d کو آرکائیو سے نکال دیا گیا" + "اطلاعات آف کر دی گئیں" + "اطلاعات آن کر دی گئیں" + "سب ٹھیک ہے۔ بھیجیں کو دوبارہ ٹچ کریں۔" + "‏پیغام رسانی کامیابی سے بطور ڈیفالٹ SMS ایپ سیٹ ہو گئی۔" + + منسلکہ جات کو مسترد کریں + منسلکہ کو مسترد کریں + + "آڈیو منسلکہ" + "آڈیو منسلکہ چلائیں" + "موقوف کریں" + "پیغام منجانب %s: %s۔" + "ناکام پیغام منجانب %s: %s۔ وقت: %s۔" + "پیغام منجانب %s: %s۔ وقت: %s۔" + "غیر ارسال کردہ پیغام بنام %s: %s۔ وقت: %s۔" + "پیغام بھیجا جا رہا ہے بنام %s: %s۔ وقت: %s۔" + "ناکام پیغام بنام %s: %s۔ وقت: %s۔" + "پیغام بنام %s: %s۔ وقت: %s۔" + "ناکام پیغام منجانب %s: %s۔ وقت: %s۔ %s۔" + "پیغام منجانب %s: %s۔ وقت: %s۔ %s۔" + "غیر ارسال کردہ پیغام بنام %s: %s۔ وقت: %s۔" + "پیغام بھیجا جا رہا ہے بنام %s: %s۔ وقت: %s۔" + "ناکام پیغام بنام %s: %s۔ وقت: %s۔" + "پیغام بنام %s: %s۔ وقت: %s۔" + "ناکام پیغام۔ دوبارہ کوشش کرنے کیلئے ٹچ کریں۔" + "%s کے ساتھ گفتگو" + "موضوع حذف کریں" + "ویڈیو کیپچر کریں" + "ایک ساکت تصویر کیپچر کریں" + "تصویر لیں" + "ویڈیو ریکارڈ کرنا شروع کریں" + "پوری اسکرین کے کیمرہ پر سوئچ کریں" + "سامنے اور پیچھے کے کیمرے کے درمیان سوئچ کریں" + "ریکارڈنگ بند کریں اور ویڈیو منسلک کریں" + "ویڈیو ریکارڈ کرنے کو روکیں" + "پیغام رسانی تصاویر" + + %d تصاویر \"%s\" البم میں محفوظ ہو گئیں + %d تصویر \"%s\" البم میں محفوظ ہو گئی + + + %d ویڈیوز \"%s\" البم میں محفوظ ہو گئیں + %d ویڈیو \"%s\" البم میں محفوظ ہو گئی + + + %d منسلکہ جات \"%s\" البم میں محفوظ ہو گئیں + %d منسلکہ \"%s\" البم میں محفوظ ہو گئی + + + %d منسلکہ جات \'ڈاؤن لوڈز\' میں محفوظ ہو گئیں + %d منسلکہ \'ڈاؤن لوڈز\' میں محفوظ ہو گئی + + + %d منسلکہ جات کو محفوظ کر دیا گیا + %d منسلکہ کو محفوظ کر دیا گیا + + + %d منسلکہ جات کو محفوظ نہیں کیا جا سکا + %d منسلکہ کو محفوظ نہیں کیا جا سکا + + "‏MMS منسلکہ کو محفوظ کر دیا گیا" + "ترتیبات" + "آرکائیو کردہ" + "بند کریں" + "MMS" + "جدید ترین" + "ڈیبگ کریں" + "اطلاعات" + "آواز" + "خاموش" + "وائبریٹ" + "مسدود" + "‏SMS ڈیلیوری کی رپورٹس" + "‏اپنے ذریعہ بھیجے جانے والے ہر SMS کی ایک ڈیلیوری رپورٹ کی درخواست کریں" + "خودکار طور پر بازیافت کریں" + "‏خود کار طور پر MMS پیغامات کی بازیابی کریں" + "رومنگ کے وقت خود کار بازیابی" + "‏رومنگ میں رہتے ہوئے MMS کی خود کار طور پر بازيابی کریں" + "گروپ پیغام رسانی" + "‏متعدد وصول کنندگان ہونے کی صورت میں ایک پیغام بھیجنے کیلئے MMS استعمال کریں" + "‏ڈیفالٹ SMS ایپ" + "‏ڈیفالٹ SMS ایپ" + + + "آپ کا فون نمبر" + "نامعلوم" + "آؤٹ گوئنگ پیغامات کی آوازیں" + "‏SMS کو ڈمپ کریں" + "‏وصول کردہ SMS خام ڈیٹا کو خارجی اسٹوریج فائل میں ڈمپ کریں" + "‏MMS کو ڈمپ کریں" + "‏وصول کردہ MMS خام ڈیٹا کو خارجی اسٹوریج فائل میں ڈمپ کریں" + "وائرلیس الرٹس" + "پیغام کے اختیارات" + "متن کاپی کریں" + "تفصیلات دیکھیں" + "حذف کریں" + "فارورڈ کریں" + "پیغام کی تفصیلات" + "قسم: " + "متنی پیغام" + "ملٹی میڈیا پیغام" + "منجانب: " + "بنام: " + "ارسال کردہ: " + "موصول کردہ: " + "موضوع: " + "سائز: " + "ترجیح: " + "SIM:‎ " + "اعلی" + "عام" + "کم" + "SIM %s‎" + "ارسال کنندہ کا پتہ مخفی کر دیا گیا" + "منسلکات کو لوڈ کرتے وقت پیغام نہیں بھیجا جا سکتا۔" + "منسلکہ کو لوڈ نہیں کیا جا سکتا۔ دوبارہ کوشش کریں۔" + "نیٹ ورک تیار نہیں ہے۔ دوبارہ کوشش کریں۔" + "متن کو حذف کریں" + "متن اور اعداد درج کرنے کے بیچ سوئچ کریں" + "مزید شرکاء کو شامل کریں" + "شرکاء کی توثیق کریں" + "نئی گفتگو شروع کریں" + "اس آئٹم کو منتخب کریں" + "ویڈیو چلائیں" + "لوگ اور اختیارات" + "ڈیبگ کریں" + "لوگ اور اختیارات" + "عام" + "اس گفتگو میں موجود لوگ" + "ایک کال کریں" + "پیغام بھیجیں" + "‏پیغام بھیجیں‎<br/><small>‎از %s‎</small>‎" + + تصاویر بھیجیں + تصویر بھیجیں + + + آڈیوز بھیجیں + آڈیو بھیجیں + + + ویڈیوز بھیجیں + ویڈیو بھیجیں + + + رابطہ کارڈز بھیجیں + رابطہ کارڈ بھیجیں + + + منسلکہ جات بھیجیں + منسلکہ بھیجیں + + + %d منسلکہ جات بھیجنے کیلئے تیار + ایک منسلکہ بھیجنے کیلئے تیار + + "تاثرات بھیجیں" + "‏Google Play اسٹور میں دیکھیں" + "ورژن کی معلومات" + "‏ورژن ‎%1$s" + "اوپن سورس لائسنسز" + "اطلاعات" + "منسلکہ کی حد تک پہنچ گئے" + "منسلکہ لوڈ کرنے میں ناکام۔" + "رابطوں میں شامل کریں؟" + "رابطہ شامل کریں" + "موضوع" + "موضوع: " + "‎%s%s‎" + "رابطہ کارڈ لوڈ کر رہا ہے" + "رابطہ کارڈ کو لوڈ نہیں کیا جا سکا" + "رابطہ کارڈ دیکھیں" + + %d رابطے + %d رابطہ + + "رابطہ کارڈز" + "سالگرہ" + "نوٹس" + "پیغام فارورڈ کریں" + "جواب دیں" + "‎+1‎" + "‎+%d‎" + "‏SMS غیر فعال ہے" + "‏بھیجنے کیلئے پیغام رسانی کو بطور ڈیفالٹ SMS ایپ سیٹ کریں" + "‏پیغام رسانی کو بطور ڈیفالٹ SMS ایپ سیٹ کریں" + "تبدیل کریں" + "‏پیغامات موصول کرنے کیلئے پیغام رسانی کو بطور ڈیفالٹ SMS ایپ سیٹ کریں" + "‏SMS پیغامات بھیجنے کیلئے کوئی ترجیحی SIM منتخب نہیں کیا گیا" + "آلہ کے مالک نے اس ایپ کی اجازت نہیں دی ہے۔" + "ٹھیک ہے" + "ایک گفتگو میں بہت زیادہ شرکاء" + + غلط رابطے + غلط رابطہ + + "کیمرے کی تصویر کو لوڈ نہیں کیا جا سکا" + "آپ: " + "‎%s:‎ " + "مسودہ" + "کوئی نئی گفتگو شروع کرنے پر، آپ اسے یہاں مندرج دیکھیں گے" + "آرکائیو کردہ گفتگوئیں یہاں ظاہر ہوتی ہیں" + "گفتگوئیں لوڈ ہو رہی ہیں…" + "تصویر" + "آڈیو کلپ" + "ویڈیو" + "رابطہ کارڈ" + "MMS" + "کالعدم کریں" + "دوبارہ کوشش کریں" + "ایک نیا پیغام شروع کرنے کیلئے رابطہ کا ایک نام یا فون نمبر درج کریں" + "مسدود کریں" + "%s کو مسدود کریں" + "%s کو غیر مسدود کریں" + "%s کو مسدود کریں؟" + "آپ کو اس نمبر سے پیغامات موصول ہوتے رہیں گے لیکن مزید مطلع نہیں کیا جائے گا۔ اس گفتگو کو آرکائیو کر دیا جائے گا۔" + "مسدود رابطے" + "غیر مسدود کریں" + "مسدود رابطے" + "دستاویز لائبریری سے تصویر منتخب کریں" + "پیغام بھیجا جا رہا ہے" + "پیغام بھیج دیا گیا" + "سیلولر ڈیٹا آف ہے۔ اپنی ترتیبات چیک کریں۔" + "پیغامات کو ہوائی جہاز وضع میں نہیں بھیجا جا سکتا ہے" + "پیغام بھیجا نہیں جا سکا" + "پیغام ڈاؤن لوڈ ہو گیا" + "سیلولر ڈیٹا آف ہے۔ اپنی ترتیبات چیک کریں۔" + "ہوائی جہاز وضع میں پیغامات ڈاؤن لوڈ نہیں کیے جا سکتے ہیں" + "پیغام ڈاؤن لوڈ نہیں کیا جا سکا" + "صفر" + "ایک" + "دو" + "تین" + "چار" + "پانچ" + "چھ" + "سات" + "آٹھ" + "نو" + "%1$s کے ساتھ پیغام نہیں بھیجا جا سکتا، خرابی %2$d" + "نامعلوم کیرئیر کے ساتھ پیغام نہیں بھیجا جا سکتا، خرابی %1$d" + "آگے منتقل کریں: %s" + "پیغام نہیں بھیجا گیا: سروس نیٹ ورک پر فعال نہیں ہے" + "پیغام نہیں بھیجا گیا: منزل مقصول کا غلط پتہ" + "پیغام نہیں بھیجا گیا: غلط پیغام" + "پیغام نہیں بھیجا گیا: غیر تعاون یافتہ مواد" + "پیغام کو نہیں بھیجا گیا: غیر تعاون یافتہ پیغام" + "پیغام نہیں بھیجا گیا: کافی بڑا ہے" + "نیا پیغام" + "دیکھیں" + "تصویر" + "ایک مناسب ایپلیکیشن تلاش نہیں کی جا سکی" + "وصول کنندہ کو ہٹائیں" + "نیا پیغام" + "منسوخ کریں" + "رسائی کی جگہ میں ترمیم کریں" + "سیٹ نہیں ہے" + "نام" + "APN" + "MMSC" + "‏MMS پراکسی" + "‏MMS پورٹ" + "MCC" + "MNC" + "‏APN قسم" + "‏APN کو حذف کریں" + "‏نیا APN" + "محفوظ کریں" + "مسترد کریں" + "نام کی فیلڈ خالی نہیں رہ سکتی-" + "‏APN خالی نہيں رہ سکتا۔" + "‏MCC فیلڈ میں 3 ہندسے ہونا ضروری ہے۔" + "‏MNC فیلڈ میں 2 یا 3 ہندسے ہونا ضروری ہے-" + "‏ڈیفالٹ APN ترتیبات بحال ہو رہی ہیں-" + "ڈیفالٹ پر دوبارہ ترتیب دیں" + "‏ڈیفالٹ APN ترتیبات کو دوبارہ ترتیب دینے کا عمل مکمل ہو گیا-" + "بلا عنوان" + "رسائی کی جگہ کے نام" + "APNs" + "‏نیا APN" + "رسائی کی جگہ کے نام کی ترتیبات اس صارف کیلئے دستیاب نہیں ہیں" + "کلپ بورڈ پر کاپی کریں؟" + "کاپی کریں" + "%s پر" + "عام" + "جدید ترین" + "عام ترتیبات" + "جدید ترین ترتیبات" + "‎\"%s\" SIM‎" + "‏سبھی وصول کنندگان کو انفرادی SMS پیغامات بھیجیں۔ صرف آپ کو کوئی جوابات موصول ہوں گے" + "‏سبھی وصول کنندگان کو ایک MMS بھیجیں" + "نامعلوم نمبر" + "نیا پیغام" + "نیا پیغام۔" + "‏SIM کا انتخاب کنندہ" + "‏منتخب شدہ %1$s، SIM کا انتخاب کنندہ" + "موضوع میں ترمیم کریں" + "‏SIM منتخب کریں یا موضوع میں ترمیم کریں" + "آڈیو ریکارڈ کرنے کیلئے ٹچ کریں اور پکڑ کر رکھیں" + "نئی گفتگو شروع کریں" + "پیغام رسانی" + "پیغام رسانی کی فہرست" + "پیغام رسانی" + "نیا پیغام" + "گفتگو کی فہرست" + "گفتگوئیں لوڈ ہو رہی ہیں" + "پیغامات لوڈ ہو رہے ہیں" + "مزید گفتگوئیں دیکھیں" + "مزید پیغامات دیکھیں" + "گفتگو کو حذف کر دیا گیا" + "گفتگو حذف ہوگئی۔ پیغام رسانی کی ایک دوسری گفتگو دکھانے کیلئے ٹچ کریں" + "مسدود" + "غیر مسدود" + "اسٹوریج کی جگہ کم ہے۔ کچھ ڈیٹا ضائع ہو سکتا ہے۔" + "منسلکات منتخب کریں" + "انتخاب کی توثیق کریں" + "%d کو منتخب کیا گیا" + "براہ کرم ایک یا مزید منسلکات ہٹائیں اور دوبارہ کوشش کریں۔" + "آپ اپنا پیغام بھیجنے کی کوشش کر سکتے ہیں، لیکن ہو سکتا ہے وہ تب تک ڈیلیور نہ ہو جب تک کہ آپ ایک یا مزيد منسلکات نہیں ہٹا دیتے۔" + "آپ فی پیغام صرف ایک ویڈیو بھیج سکتے ہیں۔ براہ کرم اضافی ویڈیوز ہٹائیں اور دوبارہ کوشش کریں۔" + "پیغام رسانی منسلکہ کو لوڈ کرنے میں ناکام رہی۔" + "بہر صورت بھیجیں" + "گفتگو شروع نہیں کی جا سکی" + "%1$s (%2$s)" + "%s منتخب کردہ" + diff --git a/res/values-uz-rUZ/arrays.xml b/res/values-uz-rUZ/arrays.xml new file mode 100644 index 0000000..79b4c47 --- /dev/null +++ b/res/values-uz-rUZ/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "mavzu yo‘q" + "mavzu yo‘q" + + + "Ha" + "Yo‘q" + "OK" + "Ha-ha" + "Rahmat" + "Yaxshi" + "Ajoyib" + "Yo‘ldaman" + "OK, keyinroq telefon qilaman" + ":)" + ":(" + + diff --git a/res/values-uz-rUZ/strings.xml b/res/values-uz-rUZ/strings.xml new file mode 100644 index 0000000..2437398 --- /dev/null +++ b/res/values-uz-rUZ/strings.xml @@ -0,0 +1,519 @@ + + + + + "Xabarlar" + "Xabarlar" + "Suhbatni tanlang" + "Sozlamalar" + "Xabar yuborish" + "Fayl biriktiring" + "Yordam" + "Xush kelibsiz" + "O’tkazib yuborish" + "Keyingi >" + "Keyingi" + "Chiqish" + "Sozlamalar >" + "Sozlamalar" + "SMS/MMS xizmatiga SMS, Telefon va Kontaktlardan foydalanish uchun ruxsat zarur." + "Ruxsatnomalarni bu yerdan o‘zgartiring: Sozlamalar > Ilovalar > SMS/MMS > Ruxsatnomalar" + "Ruxsatnomalarni bu yerdan o‘zgartiring: Sozlamalar > Ilovalar > SMS/MMS > Ruxsatnomalar" + "Tez-tez" + "Barcha kontaktlar" + "Yuborish: %s" + "Rasm yoki video tasvirga olish" + "Ushbu qurilmadan rasmlarni tanlang" + "Audio yozish" + "Rasmni tanlang" + "Media tanlandi." + "Mediani tanlash bekor qilindi." + "%d ta tanlandi" + "tasvir %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "tasvir" + "Audio yozish" + "Ulashish" + "Hozir" + "Hozir" + + %d daqiqa + %d daqiqa + + + %d soat + %d soat + + + %d kun + %d kun + + + %d hafta + bir hafta + + + %d oy + bir oy + + + %d yil + bir yil + + "0-toifa xabari" + "Saqlash" + "Qurilma xotirasidagi bo‘sh joy kam qolmoqda. Joy bo‘shatish uchun eskiroq xabarlar avtomatik o‘chirib tashlanadi." + "Xotirada bo‘sh joy tugamoqda" + "Qurilmangizda bo‘sh joy kam qolsa, SMS/MMS xizmati xabarlarni yubora yoki qabul qila olmay qolishi mumkin." + "SMS uchun xotirada joy kam. Sizdan ba’zi xabarlarni o‘chirish talab qilinishi mumkin." + "Telefon raqamingizni tasdiqlang" + "Bu bir martalik bosqich bajarilsa, SMS/MMS xizmati guruh xabarlarini to‘g‘ri yuboradigan bo‘ladi." + "Telefon raqami" + "Barcha multimediali xabarlarni o‘chirish" + "%sdan eski xabarlar o‘chirilsin" + "%sdan oldingi xabarlar avtomatik o‘chirilsin" + "E’tiborsiz qoldirish" + "Media faylli barcha xabarlar o‘chirib tashlansinmi?" + "%s dan oldingi barcha xabarlar o‘chirib tashlansinmi?" + "%sdan oldingi xabarlar o‘chirilib, avtomatik o‘chirish funksiyasi yoqilsinmi?" + "%s dedi" + "Siz dedingiz" + "%s yuborgan xabar" + "Siz xabar yubordingiz" + "Yuborilmoqda…" + "Yuborilmadi. Qayta urinish uchun bosing." + "Yuborilmadi. Qayta urinilmoqda…" + "Qayta yuboring yoki o‘chirib tashlang" + "Favqulodda xizmatlarga ovozli qo‘ng‘iroq qiling. Favqulodda xizmat raqamlariga yuborilgan SMS xabarlarni hozirda yetkazib bo‘lmaydi." + "Xabar yuborilmadi" + "Yuklab olish uchun yangi MMS xabari" + "Yangi MMS xabari" + "Yuklab olinmadi" + "Qayta urinish uchun bosing" + "Yuklab olish uchun bosing" + "Yuklab oling yoki o‘chirib tashlang" + "Yuklab olinmoqda…" + "Xabar eskirgan yoki mavjud emas" + "hajmi: %1$s, tugash muddati: %2$s" + "Yuborib bo‘lmadi. Qabul qiluvchi noto‘g‘ri ko‘rsatilgan." + "Xizmat tarmoqda faollashtirilmagan" + "Tarmoqdagi muammo tufayli yuborib bo‘lmadi" + "Xabar eskirgan yoki mavjud emas" + "(Mavzu yo‘q)" + "Noma’lum yuboruvchi" + "Yetkazildi" + "“%1$s” to‘g‘risidagi xabarni (yuboruvchi: %2$s) yuklab bo‘lmadi." + "Xotirada kam joy qolganligi sababli ma’lumotlar bazasi amalini bajarib bo‘lmaydi" + "Xabar yuborilmadi" + "SMS/MMS xizmatidagi ayrim xabarlar yuborilmadi" + + Suhbatlar: %d, xabarlar: %d + Suhbatdagi xabarlar soni: %d + + "Xabar yuklab olinmadi" + "SMS/MMS xizmatidagi ayrim xabarlar yuklab olinmadi" + + Suhbatlar: %d, xabarlar: %d + Suhbatdagi xabarlar soni: %d + + "%1$s raqamiga xabar yuborilmadi" + "Favqulodda xizmatlarga ovozli qo‘ng‘iroq qiling. %1$s raqamiga yuborgan SMS xabaringizni hozir yetkazib bo‘lmaydi." + + %d ta yangi xabar + Yangi xabar + + "Boshlash" + "Kamera tayyor emas" + "Kamera tayyor emas" + "Videoga olib bo‘lmaydi" + "Media faylni saqlab bo‘lmadi" + "Rasmga olib bo‘lmadi" + "Orqaga" + "Arxivlangan" + "O‘chirish" + "Arxivlash" + "Arxivdan chiqarish" + "Bildirishnomalarni o‘chirib qo‘yish" + "Bildirishnomalarni yoqish" + "Kontakt qo‘shish" + "Yuklab olish" + "Yuborish" + "O‘chirish" + "Ushbu xabar o‘chirilsinmi?" + "Ushbu amalni bekor qilib bo‘lmaydi." + "O‘chirish" + + Bu suhbatlar o’chirib tashlansinmi? + Bu suhbat o’chirib tashlansinmi? + + "O‘chirish" + "Bekor qilish" + "Kimga:" + "Bir nechta rasm tanlash" + "Tanlovni tasdiqlash" + "+%d ta" + "Audioni yozib bo‘lmadi. Qaytadan urining." + "Audioni ijro qilib bo‘lmadi. Qaytadan urining." + "Audioni saqlab bo‘lmadi. Qaytadan urining." + "Bosib turing" + ", " + " " + ": " + " " + "Rasm" + "Audio-klip" + "Video" + "Kontaktlar kartasi" + "Yuklab olish" + "SMS orqali javob berish" + "MMS orqali javob" + "Javob berish" + + %d ta ishtirokchi + %d ta ishtirokchi + + "Men" + "Kontakt bloklandi va arxivlandi" + "Kontakt blokdan va arxivdan chiqarildi" + "%d ta qayd arxivlandi" + "%d ta qayd arxivdan chiqarildi" + "Bildirishnomalar o‘chirib qo‘yildi" + "Bildirishnomalar yoqildi" + "Hammasi tayyor. Endi “Yuborish” tugmasini bosing." + "SMS/MMS xizmati birlamchi SMS ilovasi sifatida o‘rnatildi." + + Biriktirmalarni olib tashlash + Biriktirmani olib tashlash + + "Ovozli biriktirma" + "Audio biriktirmani ijro qilish" + "To‘xtatib turish" + "%s yuborgan xabar: %s." + "%s yuborgan muvaffaqiyatsiz xabar: %s. Vaqt: %s" + "%s yuborgan xabar: %s. Vaqt: %s" + "%sga yuborilmay qolgan xabar: %s. Vaqt: %s" + "%sga xabar yuborilmoqda: %s. Vaqt: %s" + "%sga yuborilgan muvaffaqiyatsiz xabar: %s. Vaqt: %s" + "%sga xabar: %s. Vaqt: %s" + "%s yuborgan muvaffaqiyatsiz xabar: %s. Vaqt: %s. %s" + "%s yuborgan xabar: %s. Vaqt: %s. %s" + "%s guruhiga yuborilmay qolgan xabar: %s. Vaqt: %s" + "%s guruhiga xabar yuborilmoqda: %s. Vaqt: %s" + "%s guruhiga yuborilgan muvaffaqiyatsiz xabar: %s. Vaqt: %s" + "%s guruhiga xabar: %s. Vaqt: %s" + "Muvaffaqiyatsiz xabar. Qayta urinish uchun bosing." + "%s bilan suhbat" + "Mavzuni o‘chirish" + "Video tasvirga olish" + "Harakatsiz rasmni suratga olish" + "Rasmga olish" + "Video tasvirga olishni boshlash" + "To‘liq ekranli kameraga o‘tish" + "Old va orqa kamera o‘rtasida almashish" + "Tasvirga olishni to‘xtatish va faylni biriktirish" + "Videoga olishni to‘xtatish" + "SMS/MMS rasmlari" + + %d ta rasm “%s” albomiga saqlandi + %d ta rasm “%s” albomiga saqlandi + + + %d ta video “%s” albomiga saqlandi + %d ta video “%s” albomiga saqlandi + + + %d ta biriktirma “%s” albomiga saqlandi + %d ta biriktirma “%s” albomiga saqlandi + + + %d ta biriktirma “Yuklanishlar” jildiga saqlandi + %d ta biriktirma “Yuklanishlar” jildiga saqlandi + + + %d ta biriktirma saqlandi + %d ta biriktirma saqlandi + + + %d ta biriktirmani saqlab bo’lmadi + %d ta biriktirmani saqlab bo’lmadi + + "MMS fayli saqlandi" + "Sozlamalar" + "Arxivlangan" + "Yopish" + "MMS" + "Qo‘shimcha" + "Tuzatish" + "Bildirishnomalar" + "Ovoz" + "Ovozsiz" + "Tebranish" + "Bloklandi" + "SMS xabarlarning yetkazilganlik hisobotlari" + "Yuborilgan har bir SMS uchun yetkazilganlik hisobotini talab qilish" + "Avtomatik yuklanish" + "MMS xabarlarni avtomatik yuklab olish" + "Rouming vaqtida avtomatik yuklab olish" + "Rouming vaqtida MMS xabarlarni avtomatik yuklab olish" + "Guruh xabarlari" + "Bitta xabarni bir nechta qabul qiluvchiga yuborish uchun MMS xabardan foydalanilsin" + "Birlamchi SMS ilovasi" + "Birlamchi SMS ilovasi" + + + "Telefon raqamingiz" + "Noma’lum" + "Chiquvchi xabar ovozlari" + "SMS xabarni o‘tkazish" + "SMS orqali olingan qayta ishlanmagan ma’lumotlarni tashqi xotiraga o‘tkazish" + "MMS xabarni o‘tkazish" + "MMS orqali olingan qayta ishlanmagan ma’lumotlarni tashqi xotiraga o‘tkazish" + "Aholini ogohlantirish" + "Xabar sozlamalari" + "Matndan nusxa olish" + "Tafsilotlarni ko‘rish" + "O‘chirish" + "Qayta yo‘naltirish" + "Xabar tafsilotlari" + "Turi: " + "Matnli xabar" + "Multimediali xabar" + "Kimdan: " + "Kimga: " + "Yuborildi: " + "Qabul qilindi: " + "Mavzu: " + "Hajmi: " + "Ustivorlik: " + "SIM-karta: " + "Yuqori" + "O‘rtacha" + "Past" + "%s-SIM karta" + "Yuboruvchining manzili yashirilgan" + "Biriktirilgan fayllarni yuklab olayotganda xabar yuborib bo‘lmaydi." + "Biriktirma faylini yuklab bo‘lmadi. Qaytadan urining." + "Tarmoq hali tayyor emas. Qaytadan urining." + "Matnni o‘chirish" + "Harf va raqam kiritish o‘rtasida almashish" + "Yana ishtirokchi qo‘shish" + "Ishtirokchilarni tasdiqlang" + "Yangi suhbat boshlash" + "Ushbu variantni tanlash" + "Videoni ijro qilish" + "Odamlar va tanlamalar" + "Tuzatish" + "Odamlar va tanlamalar" + "Umumiy" + "Ushbu suhbat ishtirokchilari" + "Qo‘ng‘iroq qilish" + "Xabar yuborish" + "<br/><small>%s orqali</small> xabar yuboring" + + Rasmlarni yuborish + Rasmni yuborish + + + Audio faylni yuborish + Audio faylni yuborish + + + Videolarni yuborish + Videoni yuborish + + + Kontaktlar kartasini yuborish + Kontaktlar kartasini yuborish + + + Biriktirmani yuborish + Biriktirmani yuborish + + + %d ta biriktirma yuborishga tayyor + Biriktirma yuborishga tayyor + + "Fikr va mulohaza yuborish" + "Play Marketdan qidirish" + "Versiyasi" + "%1$s versiya" + "Ochiq kodli DT litsenziyalari" + "Bildirishnomalar" + "Biriktirma chekloviga yetdi" + "Biriktirmani yuklab bo‘lmadi." + "Kontaktlarga qo‘shilsinmi?" + "Kontakt qo‘shish" + "Mavzu" + "Mavzu: " + "%s%s" + "Kontaktlar kartasi yuklanmoqda" + "Kontaktlar kartasini yuklab bo‘lmadi" + "Kontaktlar kartasini ko‘rish" + + %d ta kontakt + %d ta kontakt + + "Kontakt kartalari" + "Tug‘ilgan kun" + "Qaydlar" + "Xabarni qayta yo‘naltirish" + "Javob berish" + "+1" + "+%d" + "SMS o‘chirib qo‘yilgan" + "Yuborish uchun SMS/MMS xizmatini birlamchi SMS ilovasi sifatida o‘rnating" + "SMS/MMS xizmatini birlamchi SMS ilovasi sifatida o‘rnating" + "O‘zgartirish" + "Xabarlarni qabul qilish uchun SMS/MMS xizmatini birlamchi SMS ilovasi sifatida o‘rnating" + "SMS xabarlarni yuborish uchun ma’qul SIM-karta tanlanmagan" + "Qurilma egasi ushbu ilovadan foydalanishga ruxsat bermagan." + "OK" + "Suhbatdagi ishtirokchilar soni juda ko‘p" + + Kontaktlar topilmadi + Kontakt topilmadi + + "Kameradagi rasmni yuklab bo‘lmadi" + "Siz: " + "%s: " + "Qoralama" + "Yangi suhbat boshlasangiz, u bu yerda ko‘rinadi" + "Arxivlangan suhbatlar bu yerda ko‘rinadi" + "Suhbatlar yuklanmoqda…" + "Rasm" + "Audio-klip" + "Video" + "Kontaktlar kartasi" + "MMS" + "Bekor qilish" + "Qayta urinish" + "Yangi xabar yozish uchun kontaktning ismi yoki telefon raqamini kiriting" + "Bloklash" + "Bloklash:%s" + "Blokdan chiqarish: %s" + "%s bloklansinmi?" + "Sizga ushbu raqamdan xabarlar kelishi mumkin, biroq bu haqda siz xabardor qilinmaysiz. Ushbu suhbat esa arxivlanadi." + "Bloklangan kontaktlar" + "BLOKDAN CHIQARISH" + "Bloklangan kontaktlar" + "Hujjatlar kutubxonasidan rasm tanlang" + "Xabar yuborilmoqda" + "Xabar yuborildi" + "Mobil internet o‘chirilgan. Sozlamalarni tekshiring." + "Parvoz rejimida xabarlarni yuborib bo‘lmaydi" + "Xabar yuborilmadi" + "Xabar yuklab olindi" + "Mobil internet o‘chirilgan. Sozlamalarni tekshiring." + "Parvoz rejimida xabarlarni yuklab olib bo‘lmaydi" + "Xabarni yuklab olib bo‘lmadi" + "Nol" + "Bir" + "Ikki" + "Uch" + "To‘rt" + "Besh" + "Olti" + "Yetti" + "Sakkiz" + "To‘qqiz" + "%1$s orqali xabar yuborib bo‘lmadi, xatolik kodi: %2$d" + "Noma’lum aloqa operatori orqali xabar yuborib bo‘lmadi, xatolik kodi: %1$d" + "Fwd: %s" + "Xabar yuborilmadi: xizmat tarmoqda faollashtirilmagan" + "Xabar yuborilmadi: qabul qiluvchining manzili noto‘g‘ri" + "Xabar yuborilmadi: xabar noto‘g‘ri" + "Xabar yuborilmadi: kontent turi qo‘llab-quvvatlanmaydi" + "Xabar yuborilmadi: xabar turi qo‘llab-quvvatlanmaydi" + "Xabar yuborilmadi: juda katta" + "Yangi xabar" + "Ko‘rish" + "Rasm" + "Mos ilovani topib bo‘lmadi" + "Qabul qiluvchini o‘chirish" + "Yangi xabar" + "Bekor qilish" + "Ulanish nuqtasi sozlamalari" + "Sozlanmagan" + "Nomi" + "APN" + "MMSC" + "MMS proksi-serveri" + "MMS porti" + "MCC" + "MNC" + "APN turi" + "APNni o‘chirish" + "Yangi APN" + "Saqlash" + "Bekor qilish" + "APN nomi maydonchasini to‘ldirish shart." + "APN maydonchasini to‘ldirish shart." + "MCC 3 ta raqamdan iborat bo‘lishi shart." + "MNC 2 yoki 3 ta raqamdan iborat bo‘lishi shart." + "Standart APN sozlamalari qayta tiklanmoqda." + "Standart sozlamalarni tiklash" + "Standart sozlamalar tiklandi." + "Nomsiz" + "Ulanish nuqtalari" + "APN" + "Yangi APN" + "Ushbu foydalanuvchi uchun Ulanish nuqtasi nomi (APN) sozlamalari mavjud emas" + "Vaqtinchalik xotiraga nusxa olinsinmi?" + "Nusxa olish" + "ushbu SIM-kartaga: %s" + "Umumiy" + "Qo‘shimcha" + "Umumiy sozlamalar" + "Qo‘shimcha sozlamalar" + "“%s” SIM kartasi" + "Har bir qabul qiluvchiga alohida SMS xabar yuboring. Faqat siz barcha javoblarni olishingiz mumkin" + "Barcha qabul qiluvchilarga bir xil MMS xabar yuboring" + "Noma’lum raqam" + "Yangi xabar" + "Yangi xabar." + "SIM kartani tanlash" + "%1$s SIM-kartasi tanlangan" + "Mavzuni tahrirlash" + "SIM kartani tanlang yoki mavzuni tahrirlang" + "Audio yozib olish uchun bosib turing" + "Yangi suhbat boshlash" + "SMS/MMS" + "SMS/MMS chatlari" + "SMS/MMS" + "Yangi xabar" + "Suhbatlar ro‘yxati" + "Suhbatlar yuklanmoqda" + "Xabarlar yuklanmoqda" + "Ko‘proq suhbatlarni ko‘rish" + "Boshqa xabarlarni ko‘rish" + "Suhbat o‘chirib tashlandi" + "Suhbat o‘chirib tashlandi. SMS/MMS xizmatidagi boshqa suhbatni ko‘rish uchun bosing." + "Bloklandi" + "Blokdan chiqarildi" + "Xotirada joy kam qoldi. Ba’zi ma’lumotlar saqlanmasligi mumkin." + "Biriktirmalarni tanlash" + "Tanlovni tasdiqlash" + "%d ta tanlandi" + "Bir yoki bir nechta biriktirmani olib tashlab, keyin qayta urinib ko‘ring." + "Xabarni yuborib ko‘rishingiz mumkin, lekin undagi bir yoki bir nechta biriktirma olib tashlanmaguncha qabul qiluvchiga yetib bormasligi mumkin." + "Siz bitta xabarda faqat bitta video jo‘nata olasiz. Ortiqcha videolarni olib tashlang va qaytadan urinib ko‘ring." + "Biriktirmani yuklab bo‘lmadi." + "Baribir yuborilsin" + "Suhbatni boshlab bo‘lmadi" + "%1$s (%2$s)" + "%s tanlandi" + diff --git a/res/values-v17/styles.xml b/res/values-v17/styles.xml new file mode 100644 index 0000000..01cec58 --- /dev/null +++ b/res/values-v17/styles.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/values-v21/colors.xml b/res/values-v21/colors.xml new file mode 100644 index 0000000..d51ad04 --- /dev/null +++ b/res/values-v21/colors.xml @@ -0,0 +1,22 @@ + + + + #DE000000 + #8A000000 + #8A000000 + #DE000000 + diff --git a/res/values-v21/dimens.xml b/res/values-v21/dimens.xml new file mode 100644 index 0000000..466d2c8 --- /dev/null +++ b/res/values-v21/dimens.xml @@ -0,0 +1,22 @@ + + + + 16dp + 16dp + 56dp + 0dp + diff --git a/res/values-v21/styles.xml b/res/values-v21/styles.xml new file mode 100644 index 0000000..aef5d1c --- /dev/null +++ b/res/values-v21/styles.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + diff --git a/res/values-vi/arrays.xml b/res/values-vi/arrays.xml new file mode 100644 index 0000000..b16f18b --- /dev/null +++ b/res/values-vi/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "không có chủ đề" + "không có chủ đề" + + + "Có" + "Không" + "OK" + "Hehe" + "Xin cảm ơn" + "Tôi đồng ý" + "Tuyệt vời" + "Đang trên đường" + "OK, tôi sẽ liên hệ lại với bạn sau" + ":)" + ":(" + + diff --git a/res/values-vi/strings.xml b/res/values-vi/strings.xml new file mode 100644 index 0000000..ac23771 --- /dev/null +++ b/res/values-vi/strings.xml @@ -0,0 +1,519 @@ + + + + + "Nhắn tin" + "Nhắn tin" + "Chọn cuộc hội thoại" + "Cài đặt" + "Gửi tin nhắn" + "Thêm tệp đính kèm" + "Trợ giúp" + "Chào mừng" + "Bỏ qua" + "Tiếp theo >" + "Tiếp theo" + "Thoát" + "Cài đặt >" + "Cài đặt" + "Nhắn tin cần quyền truy cập vào SMS, Điện thoại và Danh bạ." + "Bạn có thể thay đổi các quyền trong Cài đặt > Ứng dụng > Nhắn tin > Quyền." + "Bạn có thể thay đổi các quyền trong Cài đặt, Ứng dụng, Nhắn tin, Quyền." + "Liên hệ thường xuyên" + "Tất cả liên hệ" + "Gửi đến %s" + "Chụp ảnh hoặc quay video" + "Chọn hình ảnh từ thiết bị này" + "Ghi âm" + "Chọn ảnh" + "Phương tiện được chọn." + "Phương tiện không được chọn." + "Đã chọn %d" + "hình ảnh %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "hình ảnh" + "Ghi âm" + "Chia sẻ" + "Vừa đăng" + "Hiện tại" + + %d phút + %d phút + + + %d giờ + %d giờ + + + %d ngày + %d ngày + + + %d tuần + một tuần + + + %d tháng + một tháng + + + %d năm + một năm + + "Thông báo hạng 0" + "Lưu" + "Thiết bị sắp hết bộ nhớ. Nhắn tin sẽ tự động xóa các tin nhắn cũ để giải phóng dung lượng." + "Sắp hết dung lượng lưu trữ" + "Nhắn tin không thể gửi hoặc nhận tin nhắn cho tới khi có thêm bộ nhớ trên thiết bị của bạn." + "Sắp hết dung lượng SMS. Bạn có thể cần xóa tin nhắn." + "Xác nhận số điện thoại của bạn" + "Bước thực hiện một lần này sẽ đảm bảo ứng dụng Nhắn tin gửi tin nhắn nhóm của bạn đúng cách." + "Số điện thoại" + "Xóa tất cả tin nhắn có phương tiện" + "Xóa tin nhắn cũ hơn %s" + "Tự động xóa tin nhắn cũ hơn %s" + "Bỏ qua" + "Xóa tất cả tin nhắn có phương tiện?" + "Xóa tin nhắn cũ hơn %s?" + "Xóa tin nhắn cũ hơn %s và bật tự động xóa?" + "%s đã nói" + "Bạn đã nói" + "Tin nhắn từ %s:" + "Bạn đã gửi tin nhắn" + "Đang gửi…" + "Không gửi được. Hãy chạm để thử lại." + "Không gửi được. Đang thử lại…" + "Gửi lại hoặc xóa" + "Vui lòng thực hiện cuộc gọi thoại tới các dịch vụ khẩn cấp. Chúng tôi hiện không thể gửi tin nhắn văn bản của bạn." + "Không thành công" + "Tin nhắn MMS mới cần tải xuống" + "Tin nhắn MMS mới" + "Không thể tải xuống" + "Chạm để thử lại" + "Chạm để tải xuống" + "Tải xuống hoặc xóa" + "Đang tải xuống..." + "Tin nhắn đã hết hạn hoặc không khả dụng" + "kích thước: %1$s, hết hạn: %2$s" + "Không thể gửi. Người nhận không hợp lệ." + "Dịch vụ không được kích hoạt trên mạng" + "Không thể gửi do sự cố mạng" + "Tin nhắn đã hết hạn hoặc không khả dụng" + "(Không có chủ đề)" + "Người gửi không xác định" + "Đã gửi" + "Không thể tải xuống tin nhắn %1$s từ %2$s." + "Không thể hoàn tất thao tác liên quan đến cơ sở dữ liệu do sắp hết bộ nhớ" + "Không gửi được tin nhắn" + "Không gửi được một số tin nhắn trong Nhắn tin" + + %d tin nhắn trong %d cuộc trò chuyện + %d tin nhắn trong một cuộc trò chuyện + + "Không tải xuống được tin nhắn" + "Không tải xuống được một số tin nhắn trong Nhắn tin" + + %d tin nhắn trong %d cuộc trò chuyện + %d tin nhắn trong một cuộc trò chuyện + + "Tin nhắn tới %1$s chưa được gửi" + "Vui lòng thực hiện cuộc gọi thoại tới các dịch vụ khẩn cấp. Chúng tôi hiện không thể gửi tin nhắn văn bản của bạn tới %1$s." + + %d tin nhắn mới + Tin nhắn mới + + "Bắt đầu" + "Máy ảnh không khả dụng" + "Máy ảnh không khả dụng" + "Quay video không khả dụng" + "Không thể lưu phương tiện" + "Không thể chụp ảnh" + "Quay lại" + "Đã lưu trữ" + "Xóa" + "Lưu trữ" + "Hủy lưu trữ" + "Tắt thông báo" + "Bật thông báo" + "Thêm liên hệ" + "Tải xuống" + "Gửi" + "Xóa" + "Xóa tin nhắn này?" + "Không thể hoàn tác tác vụ này." + "Xóa" + + Xóa những cuộc trò chuyện này? + Xóa cuộc trò chuyện này? + + "Xóa" + "Hủy" + "Đến" + "Chọn nhiều hình ảnh" + "Xác nhận lựa chọn" + "+%d" + "Không thể ghi âm thanh. Hãy thử lại." + "Không thể phát âm thanh. Hãy thử lại." + "Không thể lưu âm thanh. Hãy thử lại." + "Chạm và giữ" + ", " + " " + ": " + " " + "Hình ảnh" + "Clip âm thanh" + "Video" + "Thẻ liên hệ" + "Tải xuống" + "Trả lời qua SMS" + "Trả lời qua MMS" + "Trả lời" + + %d người tham gia + %d người tham gia + + "Tôi" + "Đã chặn và lưu trữ địa chỉ liên hệ" + "Đã bỏ chặn và hủy lưu trữ liên hệ" + "Đã lưu trữ %d" + "Đã hủy lưu trữ %d" + "Đã tắt thông báo" + "Đã bật thông báo" + "Đã hoàn tất. Hãy chạm vào Gửi lần nữa." + "Đã đặt thành công Nhắn tin là ứng dụng SMS mặc định." + + Hủy tệp đính kèm + Hủy tệp đính kèm + + "Tệp âm thanh đính kèm" + "Phát tệp âm thanh đính kèm" + "Tạm dừng" + "Tin nhắn từ %s: %s." + "Không gửi được tin nhắn từ %s: %s. Thời gian: %s." + "Tin nhắn từ %s: %s. Thời gian: %s." + "Chưa gửi được tin nhắn tới %s: %s. Thời gian: %s." + "Đang gửi tin nhắn tới %s: %s. Thời gian: %s." + "Không gửi được tin nhắn tới %s: %s. Thời gian: %s." + "Tin nhắn tới %s: %s. Thời gian: %s." + "Không gửi được tin nhắn từ %s: %s. Thời gian: %s. %s." + "Tin nhắn từ %s: %s. Thời gian: %s. %s." + "Chưa gửi được tin nhắn tới %s: %s. Thời gian: %s." + "Đang gửi tin nhắn tới %s: %s. Thời gian: %s." + "Không gửi được tin nhắn tới %s: %s. Thời gian: %s." + "Tin nhắn tới %s: %s. Thời gian: %s." + "Không gửi được tin nhắn. Hãy chạm để thử lại." + "Cuộc trò chuyện với %s" + "Xóa chủ đề" + "Quay video" + "Chụp ảnh tĩnh" + "Chụp ảnh" + "Bắt đầu quay video" + "Chuyển sang máy ảnh toàn màn hình" + "Chuyển đổi giữa máy ảnh trước và sau" + "Dừng quay và đính kèm video" + "Dừng ghi video" + "Ảnh trong Nhắn tin" + + %d ảnh đã được lưu vào album \"%s\" + %d ảnh đã được lưu vào album \"%s\" + + + %d video đã được lưu vào album \"%s\" + %d video đã được lưu vào album \"%s\" + + + %d tệp đính kèm đã được lưu vào album \"%s\" + %d tệp đính kèm đã được lưu vào album \"%s\" + + + %d tệp đính kèm đã được lưu vào \"Nội dung tải xuống\" + %d tệp đính kèm đã được lưu vào \"Nội dung tải xuống\" + + + Đã lưu %d tệp đính kèm + Đã lưu %d tệp đính kèm + + + Không thể lưu %d tệp đính kèm + Không thể lưu %d tệp đính kèm + + "Đã lưu tệp đính kèm MMS" + "Cài đặt" + "Đã lưu trữ" + "Đóng" + "MMS" + "Nâng cao" + "Gỡ lỗi" + "Thông báo" + "Âm thanh" + "Im lặng" + "Rung" + "Đã chặn" + "Báo cáo gửi SMS" + "Yêu cầu báo cáo gửi cho mỗi SMS bạn gửi" + "Tự động truy xuất" + "Tự động truy xuất tin nhắn MMS" + "Tự động truy xuất khi chuyển vùng" + "Tự động truy xuất MMS khi chuyển vùng" + "Nhắn tin theo nhóm" + "Sử dụng MMS để gửi một tin nhắn duy nhất khi có nhiều người nhận" + "Ứng dụng SMS mặc định" + "Ứng dụng SMS mặc định" + + + "Số điện thoại của bạn" + "Không xác định" + "Âm thanh tin nhắn đi" + "Kết xuất SMS" + "Kết xuất dữ liệu thô SMS đã nhận vào tệp bộ nhớ ngoài" + "Kết xuất MMS" + "Kết xuất dữ liệu thô MMS đã nhận vào tệp bộ nhớ ngoài" + "Cảnh báo không dây" + "Tùy chọn tin nhắn" + "Sao chép văn bản" + "Xem chi tiết" + "Xóa" + "Chuyển tiếp" + "Chi tiết tin nhắn" + "Loại: " + "Tin nhắn văn bản" + "Tin nhắn đa phương tiện" + "Người gửi: " + "Người nhận: " + "Đã gửi: " + "Đã nhận: " + "Chủ đề: " + "Kích cỡ: " + "Mức độ ưu tiên: " + "SIM: " + "Cao" + "Bình thường" + "Thấp" + "SIM %s" + "Địa chỉ người gửi bị ẩn" + "Không thể gửi tin nhắn trong khi tải tệp đính kèm." + "Không thể tải tệp đính kèm. Hãy thử lại." + "Mạng chưa sẵn sàng. Hãy thử lại." + "Xóa văn bản" + "Chuyển đổi giữa nhập văn bản và số" + "Thêm người tham gia khác" + "Xác nhận người tham gia" + "Bắt đầu cuộc trò chuyện mới" + "Chọn mục này" + "Phát video" + "Danh bạ và tùy chọn" + "Gỡ lỗi" + "Danh bạ và tùy chọn" + "Chung" + "Mọi người trong cuộc hội thoại này" + "Gọi điện" + "Gửi tin nhắn" + "Gửi tin nhắn<br/><nhỏ>từ %s</nhỏ>" + + Gửi ảnh + Gửi ảnh + + + Gửi âm thanh + Gửi âm thanh + + + Gửi video + Gửi video + + + Gửi danh thiếp + Gửi danh thiếp + + + Gửi tệp đính kèm + Gửi tệp đính kèm + + + %d tệp đính kèm đã sẵn sàng gửi + Một tệp đính kèm đã sẵn sàng gửi + + "Gửi phản hồi" + "Xem trong Cửa hàng Google Play" + "Thông tin phiên bản" + "Phiên bản %1$s" + "Giấy phép nguồn mở" + "Thông báo" + "Đã đạt đến giới hạn tệp đính kèm" + "Không tải được tệp đính kèm." + "Thêm vào Danh bạ?" + "Thêm liên hệ" + "Chủ đề" + "Chủ đề: " + "%s%s" + "Đang tải thẻ liên hệ" + "Không thể tải danh thiếp" + "Xem thẻ liên hệ" + + %d liên hệ + %d liên hệ + + "Danh thiếp" + "Ngày sinh" + "Ghi chú" + "Chuyển tiếp thư" + "Trả lời" + "+1" + "+%d" + "Đã tắt tính năng nhắn tin SMS" + "Để gửi, hãy đặt Nhắn tin là ứng dụng SMS mặc định" + "Đặt Nhắn tin là ứng dụng SMS mặc định" + "Thay đổi" + "Để nhận tin nhắn, hãy đặt Nhắn tin là ứng dụng SMS mặc định" + "Không có SIM ưu tiên nào được chọn để gửi tin nhắn SMS" + "Ứng dụng này không được chủ sở hữu thiết bị cho phép." + "OK" + "Có quá nhiều người tham gia trong một cuộc trò chuyện" + + Liên hệ không hợp lệ + Liên hệ không hợp lệ + + "Không thể tải hình ảnh trong máy ảnh" + "Bạn: " + "%s: " + "Thư nháp" + "Sau khi bạn bắt đầu một cuộc trò chuyện mới, bạn sẽ thấy cuộc trò chuyện đó được liệt kê ở đây" + "Các cuộc trò chuyện đã lưu trữ xuất hiện ở đây" + "Đang tải cuộc trò chuyện..." + "Hình ảnh" + "Clip âm thanh" + "Video" + "Thẻ liên hệ" + "MMS" + "Hoàn tác" + "Thử lại" + "Nhập tên liên hệ hoặc số điện thoại để bắt đầu tin nhắn mới" + "Chặn" + "Chặn %s" + "Bỏ chặn %s" + "Chặn %s?" + "Bạn sẽ tiếp tục nhận tin nhắn từ số này nhưng bạn sẽ không được thông báo nữa. Cuộc trò chuyện này sẽ được lưu trữ." + "Địa chỉ liên hệ bị chặn" + "BỎ CHẶN" + "Địa chỉ liên hệ bị chặn" + "Chọn hình ảnh từ thư viện tài liệu" + "Đang gửi tin nhắn" + "Đã gửi tin nhắn" + "Dữ liệu di động đã bị tắt. Hãy kiểm tra cài đặt của bạn." + "Không thể gửi tin nhắn ở Chế độ trên máy bay" + "Không thể gửi thư" + "Đã tải tin nhắn xuống" + "Dữ liệu di động đã bị tắt. Hãy kiểm tra cài đặt của bạn." + "Không thể tải xuống tin nhắn ở Chế độ trên máy bay" + "Không thể tải xuống tin nhắn" + "Không" + "Một" + "Hai" + "Ba" + "Bốn" + "Năm" + "Sáu" + "Bảy" + "Tám" + "Chín" + "Không thể gửi tin nhắn bằng %1$s, lỗi %2$d" + "Không thể gửi tin nhắn bằng nhà cung cấp dịch vụ không xác định, lỗi %1$d" + "Chuyển tiếp: %s" + "Không gửi được tin nhắn: dịch vụ chưa được kích hoạt trên mạng" + "Không gửi được tin nhắn: địa chỉ đích không hợp lệ" + "Không gửi được tin nhắn: tin nhắn không hợp lệ" + "Không gửi được tin nhắn: nội dung không được hỗ trợ" + "Không gửi được tin nhắn: tin nhắn không được hỗ trợ" + "Không gửi được tin nhắn: quá lớn" + "Tin nhắn mới" + "Xem" + "Hình ảnh" + "Không thể tìm thấy ứng dụng thích hợp" + "Xóa người nhận" + "Tin nhắn mới" + "Hủy" + "Chỉnh sửa điểm truy cập" + "Chưa đặt" + "Tên" + "APN" + "MMSC" + "Proxy của MMS" + "Cổng MMS" + "MCC" + "MNC" + "Loại APN" + "Xóa APN" + "APN mới" + "Lưu" + "Hủy" + "Không được để trống trường Tên." + "Không được để trống APN." + "Trường MCC phải có 3 chữ số." + "Trường MNC phải có 2 hoặc 3 chữ số." + "Đang khôi phục cài đặt APN mặc định." + "Đặt lại về mặc định" + "Đặt lại cài đặt APN mặc định đã hoàn tất." + "Không có tiêu đề" + "Tên điểm truy cập" + "APN" + "APN mới" + "Cài đặt Tên điểm truy cập không khả dụng cho người dùng này" + "Sao chép vào khay nhớ tạm?" + "Sao chép" + "tới %s" + "Chung" + "Nâng cao" + "Cài đặt chung" + "Cài đặt nâng cao" + "SIM \"%s\"" + "Gửi từng tin nhắn SMS cho tất cả người nhận. Chỉ bạn mới nhận được mọi tin nhắn trả lời" + "Gửi một MMS cho tất cả người nhận" + "Số điện thoại không xác định" + "Tin nhắn mới" + "Tin nhắn mới." + "Bộ chọn SIM" + "Đã chọn %1$s, bộ chọn SIM" + "Chỉnh sửa chủ đề" + "Chọn SIM hoặc chỉnh sửa chủ đề" + "Chạm và giữ để ghi âm" + "Bắt đầu cuộc trò chuyện mới" + "Nhắn tin" + "Danh sách Nhắn tin" + "Nhắn tin" + "Tin nhắn mới" + "Danh sách cuộc hội thoại" + "Đang tải cuộc trò chuyện..." + "Đang tải thư" + "Xem các cuộc hội thoại khác" + "Xem thêm tin nhắn" + "Đã xóa cuộc hội thoại" + "Đã xóa cuộc trò chuyện. Chạm để hiển thị cuộc trò chuyện khác trong Nhắn tin" + "Đã chặn" + "Đã bỏ chặn" + "Dung lượng bộ nhớ thấp. Một số dữ liệu có thể bị mất." + "Chọn tài liệu đính kèm" + "Xác nhận lựa chọn" + "Đã chọn %d" + "Vui lòng xóa một hoặc nhiều tệp đính kèm và thử lại." + "Bạn có thể thử gửi tin nhắn của mình nhưng tin nhắn có thể không gửi được trừ khi bạn xóa một hoặc nhiều tệp đính kèm." + "Bạn chỉ có thể gửi một video cho mỗi tin nhắn. Hãy xóa các video khác rồi thử lại." + "Ứng dụng Nhắn tin không tải được tệp đính kèm." + "Vẫn gửi" + "Không thể bắt đầu cuộc trò chuyện" + "%1$s (%2$s)" + "Đã chọn %s" + diff --git a/res/values-zh-rCN/arrays.xml b/res/values-zh-rCN/arrays.xml new file mode 100644 index 0000000..9512884 --- /dev/null +++ b/res/values-zh-rCN/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "无主题" + "无主题" + + + "是的" + "不" + "好" + "呵呵" + "谢谢" + "同意" + "不错" + "在路上" + "稍后再回复你" + ":)" + ":(" + + diff --git a/res/values-zh-rCN/strings.xml b/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..1a128fc --- /dev/null +++ b/res/values-zh-rCN/strings.xml @@ -0,0 +1,519 @@ + + + + + "短信" + "短信" + "选择对话" + "设置" + "发送信息" + "添加附件" + "帮助" + "欢迎使用" + "跳过" + "下一步 >" + "下一步" + "退出" + "设置 >" + "设置" + "“短信”应用需要获得“短信”、“电话”和“通讯录”的访问权限。" + "您可以在“设置”>“应用”>“短信”>“权限”中更改权限。" + "您可以依次触摸“设置”、“应用”、“短信”和“权限”来更改权限。" + "常用联系人" + "所有联系人" + "发送到%s" + "拍照或录像" + "选择此设备上的图片" + "录制语音" + "选择照片" + "已选择此媒体。" + "已取消选择此媒体。" + "已选择%d项" + "图片(%1$tB %1$te %1$tY %1$tl %1$tM %1$tp)" + "图片" + "录制语音" + "分享" + "刚刚" + "刚刚" + + %d 分钟 + %d 分钟 + + + %d 小时 + %d 小时 + + + %d + %d + + + %d + 1 周 + + + %d 个月 + 1 个月 + + + %d + 1 年 + + "0级信息" + "保存" + "设备存储空间不足。“短信”将自动删除较早的信息,以便腾出存储空间。" + "存储空间不足" + "除非您的设备上腾出更多可用空间,否则“短信”可能无法收发信息。" + "短信存储空间不足,建议您删除部分信息。" + "确认您的电话号码" + "执行这个一次性步骤可确保“短信”正确地传送您的群发信息。" + "电话号码" + "删除所有含媒体内容的信息" + "删除%s以前的信息" + "自动删除超过 %s以前的信息" + "忽略" + "要删除所有含媒体内容的信息吗?" + "要删除超过 %s以前的信息吗?" + "要删除超过 %s以前的信息并开启自动删除功能吗?" + "%s说" + "您说" + "来自%s的信息" + "您发送了一条信息" + "正在发送…" + "发送失败。触摸即可重试。" + "发送失败。正在重试…" + "重新发送或删除" + "请拨打语音电话寻求紧急服务。目前您的信息无法成功发送。" + "失败" + "待下载的新彩信" + "新彩信" + "无法下载" + "触摸即可重试" + "触摸即可下载" + "下载或删除" + "正在下载…" + "信息已过期或无法查看" + "信息大小:%1$s,过期时间:%2$s" + "无法发送。收件人无效。" + "您尚未在网络上启用该服务。" + "遇到网络问题,发送失败" + "信息已过期或无法查看" + "(无主题)" + "未知发件人" + "已送达" + "无法下载来自%2$s的信息“%1$s”。" + "内存不足,无法完成数据库操作" + "信息发送失败" + "“短信”未发送部分信息" + + %d 个对话中的 %d 条信息 + 1 个对话中的 %d 条信息 + + "信息下载失败" + "“短信”未下载部分信息" + + %d 个对话中的 %d 条信息 + 1 个对话中的 %d 条信息 + + "未能将信息发送至 %1$s" + "请拨打语音电话寻求紧急服务。目前您的信息无法发送至 %1$s。" + + %d 条新信息 + 新信息 + + "发起" + "无法使用相机" + "无法使用相机" + "无法使用视频拍摄功能" + "无法保存媒体" + "无法拍照" + "返回" + "已归档的对话" + "删除" + "归档" + "取消归档" + "关闭通知" + "开启通知" + "添加到通讯录" + "下载" + "发送" + "删除" + "要删除此信息吗?" + "该操作无法撤消。" + "删除" + + 要删除这些对话吗? + 要删除此对话吗? + + "删除" + "取消" + "收件人" + "选择多张图片" + "确认选择" + "+%d" + "无法录制语音,请重试。" + "无法播放语音,请重试。" + "无法保存语音,请重试。" + "触摸并按住" + "、 " + " " + ": " + " " + "图片" + "语音" + "视频" + "名片" + "下载" + "通过短信回复" + "通过彩信回复" + "回复" + + %d 位参与者 + %d 位参与者 + + "我" + "联系人已被屏蔽且已归档" + "已取消屏蔽并取消归档联系人" + "已归档 %d 个" + "已取消归档 %d 个" + "通知已关闭" + "通知已开启" + "设置已完成。请再次触摸“发送”。" + "已成功地将“短信”设为默认短信应用。" + + 舍弃附件 + 舍弃附件 + + "语音附件" + "播放语音附件" + "暂停" + "%s发来了消息:%s。" + "%s未能成功发送的信息,内容为:%s。时间为:%s。" + "%s发来的信息,内容为:%s。时间为:%s。" + "要发送给%s的草稿信息,内容为:%s。时间为:%s。" + "正在向%s发送的信息,内容为:%s。时间为:%s。" + "未能成功发送给%s的信息,内容为:%s。时间为:%s。" + "发送给%s的信息,内容为:%s。时间为:%s。" + "%s未能成功发送的信息,内容为:%s。时间为:%s%s。" + "%s发来的信息,内容为:%s。时间为:%s%s。" + "要发送给%s的草稿信息,内容为:%s。时间为:%s。" + "正在向%s发送的信息,内容为:%s。时间为:%s。" + "未能成功发送给%s的信息,内容为:%s。时间为:%s。" + "发送给%s的信息,内容为:%s。时间为:%s。" + "信息发送失败。触摸即可重试。" + "与%s的对话" + "删除主题" + "拍摄视频" + "拍摄静态照片" + "拍照" + "开始录制视频" + "切换到全屏相机模式" + "在前置和后置相机之间切换" + "停止拍摄并添加视频附件" + "停止录制视频" + "“短信”中的照片" + + %d 张照片已保存至“%s”相册 + %d 张照片已保存至“%s”相册 + + + %d 个视频已保存至“%s”相册 + %d 个视频已保存至“%s”相册 + + + %d 个附件已保存至“%s”相册 + %d 个附件已保存至“%s”相册 + + + %d 个附件已保存至“下载内容” + %d 个附件已保存至“下载内容” + + + 已保存 %d 个附件 + 已保存 %d 个附件 + + + 无法保存 %d 个附件 + 无法保存 %d 个附件 + + "保存的彩信附件" + "设置" + "已归档的对话" + "关闭" + "彩信" + "高级" + "调试" + "通知" + "提示音" + "静音" + "振动" + "已屏蔽" + "短信送达情况报告" + "了解您发的每条短信的送达情况" + "自动检索" + "自动检索彩信" + "漫游时自动检索" + "漫游时自动检索彩信" + "群发彩信" + "有多个收件人时,也使用彩信发送信息" + "默认短信应用" + "默认短信应用" + + + "您的手机号码" + "未知" + "信息发送提示音" + "转储短信" + "将收到的短信原始数据转储到外部存储文件中" + "转储彩信" + "将收到的彩信原始数据转储到外部存储文件中" + "无线警报" + "信息选项" + "复制文字" + "查看详情" + "删除" + "转发" + "信息详情" + "类型: " + "文字信息" + "多媒体信息" + "发件人: " + "收件人: " + "发送时间: " + "接收时间: " + "主题: " + "大小: " + "优先级: " + "SIM 卡: " + "高" + "中" + "低" + "SIM 卡 %s" + "隐藏的发件人地址" + "附件尚未加载完毕,无法发送信息。" + "无法加载附件,请重试。" + "网络尚未就绪,请重试。" + "删除文字" + "在文字和数字输入模式间切换" + "添加更多参与者" + "确认参与者" + "发起新的对话" + "选择此项内容" + "播放视频" + "参与者名单和选项" + "调试" + "参与者名单和选项" + "常规" + "此对话的参与者" + "拨打电话" + "发送信息" + "发送信息<br/><small>(通过%s)</small>" + + 发送照片 + 发送照片 + + + 发送语音 + 发送语音 + + + 发送视频 + 发送视频 + + + 发送名片 + 发送名片 + + + 发送附件 + 发送附件 + + + %d 个附件待发送 + 1 个附件待发送 + + "发送反馈" + "在 Google Play 商店中查看" + "版本信息" + "版本%1$s" + "开放源代码许可" + "通知" + "已达到附件上限" + "加载附件失败。" + "要添加到通讯录吗?" + "添加到通讯录" + "主题" + "主题: " + "%s%s" + "正在加载名片" + "无法加载名片" + "查看名片" + + %d 位联系人 + %d 位联系人 + + "名片" + "生日" + "备注" + "转发信息" + "回复" + "+1" + "+%d" + "短信功能已停用" + "要发送信息,请将“短信”设为默认短信应用" + "将“短信”设为默认短信应用" + "更改" + "要接收信息,请将“短信”设为默认短信应用" + "未选择用于发送短信的首选 SIM 卡" + "此应用未获得设备机主授权。" + "确定" + "对话中的参与者过多" + + 联系人无效 + 联系人无效 + + "无法加载相机图像" + "你: " + "%s: " + "草稿" + "您的短信对话将列在此处" + "已归档的对话会显示在此处" + "正在加载对话…" + "照片" + "语音" + "视频" + "名片" + "彩信" + "撤消" + "重试" + "要发送新信息,请输入联系人姓名或电话号码" + "屏蔽" + "屏蔽%s" + "取消屏蔽%s" + "要屏蔽%s吗?" + "您将继续接收来自此号码的信息,但不会再收到相关通知。此对话将被归档。" + "屏蔽的联系人" + "取消屏蔽" + "屏蔽的联系人" + "从文档库中选择图片" + "正在发送消息" + "消息已发送" + "移动数据网络已关闭。请检查您的设置。" + "在飞行模式下无法发送信息" + "无法发送信息" + "彩信已下载" + "移动数据网络已关闭。请检查您的设置。" + "在飞行模式下无法下载信息" + "无法下载信息" + "0" + "1" + "2" + "3" + "4" + "5" + "6" + "7" + "8" + "9" + "无法发送消息(运营商:%1$s,错误代码:%2$d)" + "无法发送消息(运营商:未知,错误代码:%1$d)" + "转发:%s" + "信息发送失败:服务未在网络上激活" + "信息发送失败:目标地址无效" + "信息发送失败:信息无效" + "信息发送失败:内容不受支持" + "信息发送失败:信息不受支持" + "信息发送失败:信息过长" + "新信息" + "查看" + "图片" + "找不到合适的应用" + "移除收件人" + "新消息" + "取消" + "修改接入点" + "未设置" + "名称" + "APN" + "MMSC" + "彩信代理" + "彩信端口" + "MCC" + "MNC" + "APN 类型" + "删除 APN" + "新建 APN" + "保存" + "舍弃" + "“名称”字段不能为空。" + "APN 不能为空。" + "MCC 字段必须为 3 位数。" + "MNC 字段必须为 2 位数或 3 位数。" + "正在恢复默认 APN 设置。" + "重置为默认值" + "已重置默认 APN 设置。" + "未命名" + "接入点名称" + "APN" + "新建 APN" + "此用户无权修改接入点名称设置" + "要复制到剪贴板吗?" + "复制" + "发至%s" + "常规" + "高级" + "常规设置" + "高级设置" + "SIM 卡 “%s”" + "将单条短信发送给所有收件人。只有您能收到相应回复" + "将单条彩信发送给所有收件人" + "未知号码" + "新信息" + "新信息。" + "SIM 卡选择器" + "已选择%1$s,SIM 卡选择器" + "修改主题" + "选择 SIM 卡或修改主题" + "触摸并按住即可录制语音" + "发起新的对话" + "短信" + "短信列表" + "短信" + "新信息" + "对话列表" + "正在加载对话" + "正在加载信息" + "查看更多对话" + "查看更多信息" + "对话已删除" + "对话已删除。触摸可显示其他“短信”对话" + "已屏蔽" + "已取消屏蔽" + "存储空间不足,部分数据可能会丢失。" + "选择附件" + "确认选择" + "已选择 %d 个附件" + "请移除一个或多个附件,然后重试。" + "您可以尝试发送信息,但您可能需要移除一个或多个附件才能成功发送。" + "您在每条消息中只能发送一个视频。请移除多余的视频,然后重试。" + "“短信”无法加载附件。" + "仍然发送" + "无法发起对话" + "%1$s (%2$s)" + "已选择%s" + diff --git a/res/values-zh-rHK/arrays.xml b/res/values-zh-rHK/arrays.xml new file mode 100644 index 0000000..ab1cad4 --- /dev/null +++ b/res/values-zh-rHK/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "沒有主旨" + "無主旨" + + + "是" + "否" + "確定" + "呵呵" + "謝謝" + "我同意" + "很好" + "我正在路上" + "好,我稍後再回覆您" + ":)" + ":(" + + diff --git a/res/values-zh-rHK/strings.xml b/res/values-zh-rHK/strings.xml new file mode 100644 index 0000000..a93ba92 --- /dev/null +++ b/res/values-zh-rHK/strings.xml @@ -0,0 +1,519 @@ + + + + + "訊息" + "訊息" + "選取對話群組" + "設定" + "傳送訊息" + "新增附件" + "說明" + "歡迎" + "略過" + "繼續 >" + "下一步" + "結束" + "設定 >" + "設定" + "「短訊」需要存取短訊、手機和聯絡人的權限。" + "您可以前往 [設定] > [應用程式] > [短訊] > [權限] 變更權限。" + "您可以前往 [設定]、[應用程式]、[短訊]、[權限] 變更權限。" + "常用聯絡人" + "所有聯絡人" + "傳送至 %s" + "拍照或錄影" + "從這部裝置選擇圖片" + "錄音" + "選擇相片" + "已選取媒體。" + "已取消選取媒體。" + "已選取 %d 個項目" + "%1$tB %1$te %1$tY %1$tl %1$tM %1$tp的圖片" + "圖片" + "錄音" + "分享" + "剛剛" + "現在" + + %d分鐘 + %d分鐘 + + + %d小時 + %d小時 + + + %d + %d + + + %d個星期 + 一個星期 + + + %d個月 + 一個月 + + + %d + 一年 + + "等級 0 訊息" + "儲存" + "裝置儲存空間不足。「短訊」會自動刪除較舊的訊息以騰出空間。" + "儲存空間即將用盡" + "「短訊」需要更多裝置空間方可發送或接收訊息。" + "短訊儲存空間即將用完,建議您刪除部分訊息。" + "確認您的電話號碼" + "這個單一步驟能確保「短訊」正確發送群組訊息。" + "電話號碼" + "刪除所有附有媒體的訊息" + "刪除超過 %s的訊息" + "自動刪除超過 %s的訊息" + "略過" + "要刪除所有附有媒體檔案的訊息嗎?" + "要刪除超過 %s的訊息嗎?" + "要刪除超過 %s的訊息並開啟自動刪除嗎?" + "傳送者:%s" + "您說" + "來自%s的訊息" + "您傳送了訊息" + "正在傳送…" + "訊息傳送失敗,輕觸即可再試一次。" + "訊息傳送失敗,正在重試…" + "重新傳送或刪除" + "請致電緊急服務。目前,您的短訊無法送達。" + "失敗" + "下載新的多媒體訊息" + "新的多媒體訊息" + "無法下載" + "輕觸即可再試一次" + "輕觸即可下載" + "下載或刪除" + "正在下載…" + "訊息已過期或無法取得" + "大小:%1$s,期限:%2$s" + "收件者無效,因此無法傳送訊息。" + "您還未在網絡上啟用這項服務" + "網絡發生問題,因此無法傳送" + "訊息已過期或無法取得" + "(沒有主旨)" + "未知的寄件者" + "已傳送" + "無法下載 %2$s 傳送的這則訊息「%1$s」。" + "記憶體不足,因此無法完成資料庫操作" + "無法傳送訊息" + "部分訊息未能在「短訊」中發送" + + %d個對話中的%d則訊息 + 一個對話中的%d則訊息 + + "仍未下載訊息" + "部分訊息未能在「短訊」中下載" + + %d個對話中的%d則訊息 + 一個對話中的%d則訊息 + + "未能傳送訊息至 %1$s" + "請致電緊急服務。目前,您的短訊無法送達至 %1$s。" + + %d 則新訊息 + 新訊息 + + "開始" + "無法使用相機" + "無法使用相機" + "無法錄製影片" + "無法儲存媒體檔案" + "無法拍照" + "返回" + "已封存" + "刪除" + "封存" + "取消封存" + "關閉通知" + "開啟通知" + "新增聯絡人" + "下載" + "傳送" + "刪除" + "要刪除這個訊息嗎?" + "此操作將無法復原。" + "刪除" + + 要刪除這些對話嗎? + 要刪除這個對話嗎? + + "刪除" + "取消" + "收件者︰" + "選取多個圖片" + "確認選取" + "超過 %d 個項目" + "無法錄製語音,請再試一次。" + "無法播放語音,請再試一次。" + "無法儲存語音,請再試一次。" + "輕觸並按住" + "、 " + " " + ": " + " " + "圖片" + "錄音片段" + "影片" + "聯絡人卡" + "下載" + "透過短訊回覆" + "透過 MMS 回覆" + "回覆" + + %d位參加者 + %d位參加者 + + "我" + "已封鎖並封存聯絡人" + "已解除封鎖並取消封存聯絡人" + "已封存 %d 個對話記錄" + "已取消封存 %d 個項目" + "已關閉通知" + "已開啟通知" + "準備就緒,輕觸 [重新傳送]。" + "已成功將「短訊」設定為預設短訊應用程式。" + + 捨棄附件 + 捨棄附件 + + "音效檔案附件" + "播放音效檔案附件" + "暫停" + "來自%s的訊息:%s。" + "%s未能成功傳送的訊息:%s。時間:%s。" + "%s傳送的訊息:%s。時間:%s。" + "尚未傳送給%s的訊息:%s。時間:%s。" + "正在傳送給%s的訊息:%s。時間:%s。" + "未能成功傳送給%s的訊息:%s。時間:%s。" + "傳送給%s的訊息:%s。時間:%s。" + "%s未能成功傳送的訊息:%s。時間:%s%s。" + "%s傳送的訊息:%s。時間:%s%s。" + "尚未傳送給%s的訊息:%s。時間:%s。" + "正在傳送給%s的訊息:%s。時間:%s。" + "未能成功傳送給%s的訊息:%s。時間:%s。" + "傳送給%s的訊息:%s。時間:%s。" + "未能成功傳送的訊息。輕觸即可重試。" + "與%s的對話" + "刪除主旨" + "拍攝影片" + "拍攝靜態圖像" + "拍照" + "開始錄製影片" + "切換至全屏攝像" + "切換前置和後置鏡頭" + "停止錄製並附加影片" + "停止錄製影片" + "「短訊」相片" + + %d 張相片已儲存至「%s」相簿 + %d 張相片已儲存至「%s」相簿 + + + %d 部影片已儲存至「%s」相簿 + %d 部影片已儲存至「%s」相簿 + + + %d 個附件已儲存至「%s」相簿 + %d 個附件已儲存至「%s」相簿 + + + %d 個附件已儲存至「下載」 + %d 個附件已儲存至「下載」 + + + 已儲存%d個附件 + 已儲存%d個附件 + + + 無法儲存%d個附件 + 無法儲存%d個附件 + + "已儲存的 MMS 附件" + "設定" + "已封存" + "關閉" + "MMS" + "進階" + "除錯" + "通知" + "音效" + "靜音" + "震動" + "已封鎖" + "短訊傳送報告" + "每次發送短訊後要求發送報告" + "自動擷取" + "自動擷取 MMS 訊息" + "漫遊時自動擷取" + "漫遊時自動擷取 MMS 訊息" + "群組短訊" + "有多位收件者時只發送一則 MMS 訊息" + "預設短訊應用程式" + "預設短訊應用程式" + + + "您的電話號碼" + "不明" + "傳送訊息音效" + "傾印短訊" + "將收到的短訊原始資料傾印到外部儲存檔案" + "傾印 MMS" + "將收到的 MMS 原始資料傾印到外部儲存檔案" + "無線警示" + "訊息選項" + "複製文字" + "查看詳情" + "刪除" + "轉寄" + "訊息詳情" + "類型: " + "短訊" + "多媒體訊息" + "寄件者: " + "收件者: " + "傳送時間: " + "接收時間: " + "主旨: " + "大小: " + "重要: " + "SIM 卡: " + "高" + "正常" + "低" + "SIM 卡 %s" + "隱藏的寄件者地址" + "載入附件時無法傳送訊息。" + "無法載入附件,請再試一次。" + "網絡尚未準備好,請再試一次。" + "刪除文本" + "切換文字和數字輸入" + "新增更多參與者" + "確認參與者" + "開始新的對話" + "選取這個項目" + "播放影片" + "參與者和選項" + "除錯" + "參與者和選項" + "一般" + "這個對話的參與者" + "進行通話" + "傳送訊息" + "<br/><small>從 %s</small> 發送短訊" + + 傳送相片 + 傳送相片 + + + 傳送音效檔案 + 傳送音效檔案 + + + 傳送影片 + 傳送影片 + + + 傳送聯絡資料 + 傳送聯絡資料 + + + 傳送附件 + 傳送附件 + + + %d個附件正等待傳送 + 一個附件正等待傳送 + + "傳送意見" + "前往 Google Play 商店查看" + "版本資料" + "%1$s 版" + "開放原始碼授權" + "通知" + "已達到附件限制" + "未能成功載入附件。" + "新增至聯絡人?" + "新增聯絡人" + "主旨" + "主旨: " + "%s%s" + "正在載入聯絡人卡片" + "無法載入聯絡人資訊卡" + "查看聯絡人卡片" + + %d 個聯絡人 + %d 個聯絡人 + + "聯絡人資訊卡" + "生日" + "附註" + "轉寄郵件" + "回覆" + "+1" + "+%d" + "短訊功能已停用" + "將「短訊」設定為預設短訊應用程式,即可發送訊息" + "將「短訊」設定為預設短訊應用程式" + "變更" + "將「短訊」設定為預設短訊應用程式,即可接收訊息" + "尚未選取傳送短訊的首選 SIM" + "裝置擁有者不允許使用該應用程式。" + "確定" + "參與對話的人過多" + + 無效的聯絡人 + 無效的聯絡人 + + "無法載入相機圖片" + "您: " + "%s: " + "草稿" + "只要有新對話,便會在此列出" + "封存的對話顯示在這裏" + "正在載入對話…" + "圖片" + "錄音片段" + "影片" + "聯絡人卡" + "MMS" + "復原" + "重試" + "如要傳送新訊息,請輸入聯絡人姓名或電話號碼" + "封鎖" + "封鎖%s" + "解除封鎖%s" + "要封鎖 %s?" + "您將繼續接收這個電話號碼傳來的訊息,但不會再收到通知。這個對話將被封存。" + "已封鎖的聯絡人" + "解除封鎖" + "已封鎖的聯絡人" + "從文件庫選擇圖片" + "正在傳送訊息" + "已傳送訊息" + "流動數據已關閉,請檢查您的設定。" + "無法在飛行模式下傳送訊息" + "無法傳送訊息" + "已下載訊息" + "流動數據已關閉,請檢查您的設定。" + "在飛行模式下無法下載訊息" + "無法下載訊息" + "0" + "1" + "2" + "3" + "4" + "5" + "6" + "7" + "8" + "9" + "無法經%1$s傳送訊息,錯誤:%2$d" + "無法經不明流動網絡供應商傳送訊息,錯誤:%1$d" + "轉寄:%s" + "無法傳送訊息:未在網絡上啟用服務" + "無法傳送訊息:目的地位址無效" + "無法傳送訊息:訊息無效" + "無法傳送訊息:不支援的內容" + "無法傳送訊息:不支援的訊息" + "無法傳送訊息:太大" + "新訊息" + "檢視" + "圖片" + "找不到合適的應用程式" + "移除收件者" + "新訊息" + "取消" + "編輯接入點" + "未設定" + "名稱" + "APN" + "MMSC" + "MMS 代理" + "MMS 通訊埠" + "MCC" + "MNC" + "APN 類型" + "刪除 APN" + "新增 APN" + "儲存" + "捨棄" + "[名稱] 欄位不可留空。" + "APN 不可留空。" + "MCC 欄位必須為 3 位數。" + "MNC 欄位必須要有 2 或 3 位數。" + "正在還原預設 APN 設定。" + "重設回預設值" + "重設預設 APN 設定已完成。" + "未命名" + "接入點名稱" + "APN" + "新增 APN" + "這位使用者無法修改存取點名稱設定" + "要複製到剪貼簿嗎?" + "複製" + "至 %s" + "一般" + "進階" + "一般設定" + "進階設定" + "「%s」SIM 卡" + "向所有收件者逐一傳送短訊。只有您會收到回覆" + "向所有收件者傳送一封 MMS" + "電話號碼不明" + "新訊息" + "新訊息。" + "SIM 選取器" + "已選擇%1$s,SIM 選取器" + "編輯主旨" + "選取 SIM 或編輯主旨" + "輕觸並按住即可錄製音效檔案" + "開始新的對話" + "短訊" + "「短訊」清單" + "短訊" + "新訊息" + "對話群組清單" + "正在載入對話" + "正在載入訊息" + "查看更多對話" + "查看更多訊息" + "對話群組已刪除" + "已刪除對話,輕觸即可查看其他「短訊」對話" + "已封鎖" + "已解除封鎖" + "儲存空間不足。部分資料可能會遺失。" + "選取附件" + "確認選取" + "已選取 %d 個附件" + "請移除一個或多個附件,然後再試一次。" + "您可以嘗試傳送訊息,但可能要移除一個或多個附件才可送達。" + "每封郵件只可傳送一部影片。請移除額外的影片,然後再試一次。" + "「短訊」無法載入附件。" + "直接傳送" + "無法開始對話" + "%1$s (%2$s)" + "已選取 %s" + diff --git a/res/values-zh-rTW/arrays.xml b/res/values-zh-rTW/arrays.xml new file mode 100644 index 0000000..3226b70 --- /dev/null +++ b/res/values-zh-rTW/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "無主旨" + "無主旨" + + + "是" + "否" + "確定" + "呵呵" + "謝謝" + "我同意" + "很好" + "我正在路上" + "好,我稍後再回覆您" + ":)" + ":(" + + diff --git a/res/values-zh-rTW/strings.xml b/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..957d943 --- /dev/null +++ b/res/values-zh-rTW/strings.xml @@ -0,0 +1,519 @@ + + + + + "簡訊" + "簡訊" + "選取對話" + "設定" + "傳送訊息" + "新增附件" + "説明" + "歡迎使用" + "略過" + "繼續 >" + "繼續" + "結束" + "設定 >" + "設定" + "「簡訊」需要取得簡訊、電話和聯絡人應用程式的存取權限。" + "如要變更權限,請依序前往 [設定] > [應用程式] > [簡訊] > [權限]。" + "如要變更權限,請依序前往 [設定]、[應用程式]、[簡訊] 及 [權限]。" + "常用聯絡人" + "所有聯絡人" + "傳送至 %s" + "拍照或錄影" + "選擇這個裝置上的圖片" + "錄音" + "選擇相片" + "已選取媒體。" + "已取消選取媒體。" + "已選取 %d 個項目" + "圖片 (%1$tB %1$te %1$tY %1$tl %1$tM %1$tp)" + "圖片" + "錄音" + "分享" + "剛剛" + "最新" + + %d 分鐘 + %d 分鐘 + + + %d 小時 + %d 小時 + + + %d + %d + + + %d + 1 週 + + + %d 個月 + 1 個月 + + + %d + 1 年 + + "等級 0 訊息" + "儲存" + "裝置儲存空間不足,「簡訊」會自動刪除較舊簡訊以釋出空間。" + "儲存空間不足" + "在裝置可用空間增加前,「簡訊」可能無法收發訊息。" + "簡訊儲存空間即將用完,建議您刪除部分訊息。" + "驗證您的手機號碼" + "這個一次性步驟可確保「簡訊」正確傳送群組訊息。" + "手機號碼" + "刪除所有內含媒體的訊息" + "刪除超過 %s的訊息" + "自動刪除超過 %s的訊息" + "忽略" + "刪除所有內含媒體的訊息?" + "刪除超過 %s的訊息?" + "刪除超過 %s的訊息並開啟自動刪除設定?" + "%s說了" + "您說了" + "來自%s的訊息" + "您傳送了訊息" + "傳送中…" + "傳送失敗,輕觸即可再試一次。" + "傳送失敗,正在嘗試重新傳送…" + "重新傳送或刪除" + "目前無法傳送您的簡訊,請撥打語音電話聯絡緊急服務。" + "失敗" + "可下載新的多媒體訊息" + "新的多媒體訊息" + "無法下載" + "輕觸即可再試一次" + "輕觸即可下載" + "下載或刪除" + "下載中…" + "訊息已過期或無法存取" + "大小:%1$s,期限:%2$s" + "收件者無效,因此無法傳送訊息。" + "尚未透過網路啟用服務" + "網路發生問題,因此無法傳送" + "訊息已過期或無法存取" + "(無主旨)" + "寄件者不明" + "已傳送" + "無法下載來自 %2$s 的這則訊息:%1$s。" + "記憶體不足,因此無法完成資料庫作業" + "訊息未傳送" + "「簡訊」未傳送部分訊息" + + %d 個對話中的 %d 則訊息 + 1 個對話中的 %d 則訊息 + + "訊息未順利下載" + "「簡訊」未下載部分訊息" + + %d 個對話中的 %d 則訊息 + 1 個對話中的 %d 則訊息 + + "無法傳送訊息給 %1$s" + "目前無法將您的簡訊傳送至 %1$s,請撥打語音電話聯絡緊急服務。" + + %d 則新訊息 + 新訊息 + + "發起" + "無法使用相機" + "無法使用相機" + "無法錄影" + "無法儲存媒體" + "無法拍照" + "返回" + "已封存" + "刪除" + "封存" + "取消封存" + "關閉通知" + "開啟通知" + "新增聯絡人" + "下載" + "傳送" + "刪除" + "刪除這則訊息?" + "這項操作無法復原。" + "刪除" + + 要刪除這些對話嗎? + 要刪除這個對話嗎? + + "刪除" + "取消" + "收件者" + "選取多張圖片" + "確認選取" + "超過 %d 個項目" + "無法錄製音訊,請再試一次。" + "無法播放音訊,請再試一次。" + "無法儲存音訊,請再試一次。" + "輕觸並按住" + "、 " + " " + ": " + " " + "圖片" + "音訊剪輯" + "影片" + "聯絡人卡片" + "下載" + "透過簡訊回覆" + "透過多媒體訊息回覆" + "回覆" + + %d 位參與者 + %d 位參與者 + + "我" + "已封鎖並封存聯絡人" + "已解除封鎖及解除封存聯絡人" + "已封存 %d 個" + "已解除封存 %d 個" + "已關閉通知功能" + "已開啟通知功能" + "設定程序已完成,請再次輕觸 [傳送]。" + "已成功將「簡訊」設為預設的簡訊應用程式。" + + 捨棄附件 + 捨棄附件 + + "音訊附件" + "播放音訊附件" + "暫停" + "收到%s傳送的訊息,內容如下:%s。" + "%s嘗試傳送訊息但未成功:%s。時間:%s。" + "%s傳送了訊息:%s。時間:%s。" + "給%s的訊息尚未送出:%s。時間:%s。" + "正在傳送訊息給%s%s。時間:%s。" + "無法傳送訊息給%s%s。時間:%s。" + "已傳送訊息給%s%s。時間:%s。" + "%s嘗試傳送訊息但未成功:%s。時間:%s%s。" + "%s傳送了訊息:%s。時間:%s%s。" + "給%s的訊息尚未送出:%s。時間:%s。" + "正在傳送訊息給%s%s。時間:%s。" + "無法傳送訊息給%s%s。時間:%s。" + "已傳送訊息給%s%s。時間:%s。" + "訊息傳送失敗,輕觸即可重試。" + "與%s的對話" + "刪除主旨" + "錄影" + "拍攝靜態影像" + "拍照" + "開始錄影" + "切換至全螢幕相機" + "切換使用前置與後置鏡頭" + "停止錄影並附加影片" + "停止錄影" + "簡訊相片" + + %d 張相片已儲存到「%s」相簿 + %d 張相片已儲存到「%s」相簿 + + + %d 段影片已儲存到「%s」相簿 + %d 段影片已儲存到「%s」相簿 + + + %d 份附件已儲存到「%s」相簿 + %d 份附件已儲存到「%s」相簿 + + + %d 份附件已儲存到「下載內容」 + %d 份附件已儲存到「下載內容」 + + + 已儲存 %d 個附件 + 已儲存 %d 個附件 + + + 無法儲存 %d 個附件 + 無法儲存 %d 個附件 + + "已儲存的多媒體訊息附件" + "設定" + "已封存" + "關閉" + "多媒體訊息" + "進階" + "偵錯" + "通知" + "音效" + "靜音" + "震動" + "已封鎖" + "簡訊傳送報告" + "傳送簡訊後要求傳送回條" + "自動擷取" + "自動擷取多媒體訊息" + "漫遊時自動擷取" + "漫遊時自動擷取多媒體訊息" + "群組簡訊" + "有多位收件者時,也使用多媒體訊息傳送訊息" + "預設簡訊應用程式" + "預設簡訊應用程式" + + + "您的電話號碼" + "不明" + "訊息傳送音效" + "傾印簡訊" + "將收到的簡訊原始資料傾印到外部儲存空間檔案" + "傾印多媒體訊息" + "將收到的多媒體訊息原始資料傾印到外部儲存空間檔案" + "無線災害示警" + "訊息選項" + "複製文字" + "查看詳細資料" + "刪除" + "轉寄" + "訊息詳細資料" + "類型: " + "簡訊" + "多媒體訊息" + "寄件者: " + "收件者: " + "傳送時間: " + "接收時間: " + "主旨: " + "大小: " + "優先順序: " + "SIM 卡: " + "高" + "一般" + "低" + "SIM 卡 %s" + "隱藏的寄件者地址" + "載入附件時無法傳送訊息。" + "無法載入附件,請再試一次。" + "網路尚未就緒,請再試一次。" + "刪除文字" + "切換輸入的文字和數字" + "新增更多參與者" + "確認參與者" + "發起新對話" + "選取此項目" + "播放影片" + "參與者和選項" + "偵錯" + "參與者和選項" + "一般" + "這個對話的參與者" + "進行通話" + "傳送簡訊" + "<small>透過 %s</small><br> 傳送訊息" + + 傳送相片 + 傳送相片 + + + 傳送音訊 + 傳送音訊 + + + 傳送影片 + 傳送影片 + + + 傳送聯絡人卡片 + 傳送聯絡人卡片 + + + 傳送附件 + 傳送附件 + + + %d 個附件可隨郵件傳送 + 有 1 個附件可隨郵件傳送 + + "提供意見" + "前往 Google Play 商店查看" + "版本資訊" + "版本 %1$s" + "開放原始碼授權" + "通知" + "已達附件數量上限" + "無法載入附件。" + "加到聯絡人?" + "新增聯絡人" + "主旨" + "主旨: " + "%s%s" + "正在載入聯絡人卡片" + "無法載入聯絡人卡片" + "查看聯絡人卡片" + + %d 位聯絡人 + %d 位聯絡人 + + "聯絡人卡片" + "生日" + "附註" + "轉寄郵件" + "回覆" + "還有 1 位參與者" + "還有 %d 位參與者" + "簡訊功能已停用" + "如要傳送訊息,請將「簡訊」設為預設的簡訊應用程式" + "將「簡訊」設為預設的簡訊應用程式" + "變更" + "如要接收訊息,請將「簡訊」設為預設的簡訊應用程式" + "尚未選取傳送簡訊時慣用的 SIM 卡" + "裝置擁有者不允許這個應用程式的要求。" + "確定" + "對話參與者人數過多" + + 聯絡人無效 + 聯絡人無效 + + "無法載入相機圖片" + "您: " + "%s: " + "草稿" + "這裡會列出您發起的新對話" + "封存的對話會顯示在這裡" + "正在載入對話…" + "圖片" + "音訊剪輯" + "影片" + "聯絡人卡片" + "多媒體訊息" + "復原" + "重試" + "輸入聯絡人名稱或電話號碼,即可開始傳送新訊息" + "封鎖" + "封鎖 %s" + "解除封鎖 %s" + "封鎖 %s?" + "您將持續接收來自這個號碼的訊息,但不會再收到通知。此對話將由系統封存。" + "封鎖的聯絡人" + "解除封鎖" + "封鎖的聯絡人" + "選擇文件庫中的圖片" + "正在傳送訊息" + "訊息成功送出" + "行動數據已關閉。請檢查您的設定。" + "飛航模式下無法傳送訊息" + "無法傳送訊息" + "訊息下載完成" + "行動數據已關閉。請檢查您的設定。" + "在飛航模式下無法下載訊息" + "無法下載訊息" + "0" + "1" + "2" + "3" + "4" + "5" + "6" + "7" + "8" + "9" + "無法透過%1$s傳送訊息 (錯誤代碼:%2$d)" + "無法透過不明行動通訊業者傳送訊息 (錯誤代碼:%1$d)" + "轉寄:%s" + "訊息未傳送:未在網路上啟用服務" + "無法傳送訊息:目的地位址無效" + "無法傳送訊息:訊息無效" + "無法傳送訊息:不支援的內容" + "無法傳送訊息:不支援的訊息" + "訊息未傳送:內容過大" + "新訊息" + "查看" + "圖片" + "找不到適用的應用程式" + "移除收件者" + "新訊息" + "取消" + "編輯存取點" + "未設定" + "名稱" + "APN" + "MMSC" + "多媒體訊息 Proxy" + "多媒體訊息通訊埠" + "MCC" + "MNC" + "APN 類型" + "刪除 APN" + "新增 APN" + "儲存" + "捨棄" + "名稱欄位不可留空。" + "APN 不可留空。" + "MCC 欄位的值必須為 3 碼。" + "MNC 欄位的值必須為 2 或 3 碼。" + "正在還原預設 APN 設定。" + "重設為預設值" + "預設 APN 設定的重設程序已完成。" + "未命名" + "存取點名稱" + "APN" + "新增 APN" + "這位使用者無法修改存取點名稱設定" + "複製到剪貼簿?" + "複製" + "傳送至 %s" + "一般" + "進階" + "一般設定" + "進階設定" + "「%s」SIM 卡" + "分別傳送簡訊給所有收件者 (如有任何回覆,只有您會收到)" + "傳送一則多媒體訊息給所有收件者" + "不明號碼" + "新訊息" + "新訊息。" + "SIM 卡選取器" + "已選取「%1$s」,SIM 卡選取器" + "編輯主旨" + "選取 SIM 卡或編輯主旨" + "輕觸並按住即可錄製音訊" + "發起新對話" + "簡訊" + "訊息清單" + "簡訊" + "新訊息" + "會話群組清單" + "正在載入對話" + "正在載入訊息" + "查看更多對話" + "查看更多訊息" + "對話已刪除" + "已刪除對話。輕觸即可顯示其他「簡訊」對話" + "已封鎖" + "已解除封鎖" + "儲存空間即將用盡,部分資料可能會遺失。" + "選取附件" + "確認選取項目" + "選取了 %d 個附件" + "請移除一或多個附件,然後再試一次。" + "您可以嘗試傳送訊息,但可能必須移除一或多個附件才能順利傳送。" + "每則訊息只能傳送一部影片。請移除額外的影片,然後再試一次。" + "「簡訊」無法載入附件。" + "確定傳送" + "無法發起對話" + "%1$s (%2$s)" + "已選取「%s」" + diff --git a/res/values-zu/arrays.xml b/res/values-zu/arrays.xml new file mode 100644 index 0000000..50eafe1 --- /dev/null +++ b/res/values-zu/arrays.xml @@ -0,0 +1,37 @@ + + + + + + "asikho isihloko" + "asikho isihloko" + + + "Yebo" + "Cha" + "KULUNGILE" + "Hehe" + "Siyabonga" + "Ngiyavuma" + "I-Nice" + "Ngisendleleni" + "KULUNGILE, mangibuye kuwe emuva kwesikhathi" + ":)" + ":(" + + diff --git a/res/values-zu/strings.xml b/res/values-zu/strings.xml new file mode 100644 index 0000000..02e21dd --- /dev/null +++ b/res/values-zu/strings.xml @@ -0,0 +1,519 @@ + + + + + "Imilayezo" + "Imilayezo" + "Khetha ingxoxo" + "Izilungiselelo" + "Thumela umlayezo" + "Engeza okunamathiselwe kwi-imeyili" + "Usizo" + "Siyakwamukela" + "Yeqa" + "Okulandelayo >" + "Okulandelayo" + "Phuma" + "Izilungiselelo >" + "Izilungiselelo" + "Ukulayeza kudinga imvume ku-SMS, ifoni noxhumana nabo." + "Ungaguqula izimvume ku-Izilungiselelo > Izinhlelo zokusebenza > Isilayezi > Izimvume." + "Ungaguqula izimvume ku-Izilungiselelo, Izinhlelo zokusebenza, Isilayezi, Izimvume." + "Imvamisa" + "Bonke oxhumana nabo" + "Thumela ku-%s" + "Thwebula izithombe noma ividiyo" + "Khetha izithombe kusuka kule divayisi" + "Rekhoda umsindo" + "Khetha isithombe" + "Imidiya ikhethiwe." + "Imidiya isuswe ukukhetha." + "%d okukhethiwe" + "isithombe %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" + "Isithombe" + "Rekhoda umsindo" + "Yaba" + "Khona manje" + "Manje" + + %d amaminithi + %d amaminithi + + + %d amahora + %d amahora + + + %d izinsuku + %d izinsuku + + + %d amaviki + %d amaviki + + + %dizinyanga + %dizinyanga + + + %d iminyaka + %d iminyaka + + "Umlayezo wesigaba esingu-0" + "Londoloza" + "Idivayisi manje inesikhala esincane. Imilayezo izosusa ngokuzenzakalelayo imilayezo emidala ukukhulula isikhala." + "Isikhala sesitoreji siyaphela" + "Ukulayeza kungenzeka kungathumeli noma kungamukeli imilayezo kuze kube ukuthi isikhala esiningi sitholakala kudivayisi yakho." + "Isitoreji se-SMS esiphansi. Kungahle kudingeke ukuba ususe imilayezo." + "Qinisekisa inombolo yakho yefoni" + "Lesi sinyathelo sesikhathi esisodwa sizoqinisekisa ukuthi Ukulayeza kuzothumela imilayezo yakho yeqembu ngokufanelekile." + "Inombolo yefoni" + "Susa yonke imilayezo ngemidiya" + "Susa imilayezo emidala kuno-%s" + "Susa ngokuzenzakalelayo imilayezo emidala kuno-%s" + "Ziba" + "Susa yonke imilayezo ngemidiya?" + "Sula imilayezo emidala kuno-%s?" + "Susa imilayezo emidala kuno-%s bese uvule ukususa ngokuzenzakalelayo?" + "%s uthe" + "Wena uthe" + "Umlayezo ovela ku-%s" + "Uthumele umlayezo" + "Iyathumela..." + "Akuthunyelwe. Thinta ukuze uzame futhi." + "Akuthunyelwe. Iyazama futhi…" + "Thumela kabusha noma susa" + "Sicela wenze ikholi yezwi kumasevisi wesimo esiphuthumayo. Umlayezo wakho wombhalo awukwazanga ukulethwa ngalesi sikhathi." + "Yehlulekile" + "Umlayezo we-MMS omusha ongalandwa" + "Umlayezo we-MMS omusha" + "Ayikwazanga ukulanda." + "Thinta ukuze uzame futhi" + "Thinta ukuze ulande" + "Landa noma susa" + "Iyalanda..." + "Umlayezo uphelelwe yisikhathi noma awutholakali" + "usayizi: %1$s, ukuphela kwesikhathi: %2$s" + "Ayikwazi ukuthumela. Umamukeli awuvumelekile." + "Isevisi ayisebenzi kunethiwekhi" + "Ayikwazanga ukuthumela ngenxa yenkinga yenethiwekhi" + "Umlayezo uphelelwe yisikhathi noma awutholakali" + "(Asikho isihloko)" + "Umthumeli ongaziwa" + "Kuthunyelwe" + "Ayikwazanga ukulanda umlayezo we-%1$s kusuka ku-%2$s." + "Ayikwazanga ukuqedela umsebenzi wemininingo egciniwe ngenxa yememori ephansi" + "Umlayezo awuthunyelwanga" + "Eminye imilayezo ayithunyelwe Ekulayezeni" + + %d imilayezo kwizingxoxo ezingu-%d + %d imilayezo kwizingxoxo ezingu-%d + + "Umlayezo awuzange ulandwe" + "Eminye imilayezo ayilandiwe Ekulayezeni" + + %d imilayezo kwizingxoxo ezingu-%d + %d imilayezo kwizingxoxo ezingu-%d + + "Umlayezo oya ku-%1$s awuthunyelwanga" + "Sicela uwenze ikholi yezwi kumasevisi wesimo esiphuthumayo. Umlayezo wakho wobhalo ku-%1$s awukwazanga ukulethwa ngalesi sikhathi." + + %d imilayezo emisha + %d imilayezo emisha + + "Qala" + "Ikhamera ayitholakali" + "Ikhamera ayitholakali" + "Ukuthwebula kwevidiyo akutholakali" + "Ayikwazi ukulondoloza imidiya" + "Ayikwazi ukuthatha isithombe" + "Phindela emuva" + "Okufakwe kungobo yomlando" + "Susa" + "Faka kungobo yomlando" + "Ungafaki kungobo yomlando" + "Vala izaziso" + "Vula isaziso" + "Engeza oxhumana naye" + "Landa" + "Thumela" + "Susa" + "Susa lo mlayezo?" + "Lesi senzo asikwazi ukuhlehliswa." + "Susa" + + Susa lezi zingxoxo? + Susa lezi zingxoxo? + + "Susa" + "Khansela" + "Ku-" + "Khetha izithombe eziningi" + "Qinisekisa ukukhetha" + "+%d" + "Ayikwazi ukurekhoda umsindo. Zama futhi." + "Ayikwazi ukudlala umsindo. Zama futhi." + "Ayikwazanga ukulondoloza umsindo. Zama futhi." + "Thinta bese ubambe" + ", " + " " + ": " + " " + "Isithombe" + "Isiqeshana somsindo" + "Ividiyo" + "Ikhadi loxhumana naye" + "Landa" + "Phendula nge-SMS" + "Phendula nge-MMS" + "Phendula" + + %d ababambiqhaza + %d ababambiqhaza + + "Mina" + "Oxhumana naye uvinjiwe futhi wafakwa kungobo yomlando" + "Oxhumana naye uvulelwe futhi wakhishwa kungobo yomlando" + "%d kufakwe kungobo yomlando" + "%d kukhishwe kungobo yomlando" + "Izaziso zivaliwe" + "Izaziso zivuliwe" + "Konke kusethiwe. Thinta u-Thumela futhi." + "Ukulayeza kusethwe ngempumelelo njengohlelo lokusebenza oluzenzakalelayo lwe-SMS." + + Lahla okunamathiselwe + Lahla okunamathiselwe + + "Ukunamathiselwa komsindo" + "Dala okunamathiselwe komsindo" + "Misa isikhashana" + "Umlayezo kusukela ku-%s: %s." + "Umlayezo ohlulekile ophuma ku-%s: %s. Isikhathi: %s." + "Umlayezo ophuma ku-%s: %s. Isikhathi: %s." + "Umlayezo ongathunyelwe ku-%s: %s. Isikhathi: %s." + "Ithumela umlayezo ku-%s: %s. Isikhathi: %s." + "Umlayezo ohlulekile oya ku-%s: %s. Isikhathi: %s." + "Umlayezo oya ku-%s: %s. Isikhathi: %s." + "Umlayezo ohlulekile kusukela ku-%s: %s. Isikhathi: %s. %s." + "Umlayezo ophuma ku-%s: %s. Isikhathi: %s. %s." + "Umlayezo ongathunyelwe ku-%s: %s. Isikhathi: %s." + "Ithumela umlayezo ku-%s: %s. Isikhathi: %s." + "Umlayezo ohlulekile oya ku-%s: %s. Isikhathi: %s." + "Umlayezo oya ku-%s: %s. Isikhathi: %s." + "Umlayezo ohlulekile. Thinta ukuze uphinde uzame." + "Ingxoxo no-%s" + "Sula isihloko" + "Thwebula ividiyo" + "Thwebula isithombe esimile" + "Thatha isithombe" + "Qala ukuqopha ividiyo" + "Shintshela kukhamela yesikrini esigcwele" + "Shintsha phakathi kwekhamera yaphambilini neyasemuva" + "Misa ukurekhoda uphinde ushumeke ividiyo" + "Misa ukurekhoda amavidiyo" + "Ilayeza izithombe" + + %d izithombe zilondolozwe ku-albhamu \"%s\" + %d izithombe zilondolozwe ku-albhamu \"%s\" + + + %d amavidiyo alondolozwe ku-albhamu \"%s\" + %d amavidiyo alondolozwe ku-albhamu \"%s\" + + + %d okunamathiselwe kulondolozwe ku-albhamu \"%s\" + %d okunamathiselwe kulondolozwe ku-albhamu \"%s\" + + + %d Okunamathiselwe kulondolozwe kokuthi \"Okulandiwe\" + %d Okunamathiselwe kulondolozwe kokuthi \"Okulandiwe\" + + + %d okunamathiselwe kulondoloziwe + %d okunamathiselwe kulondoloziwe + + + Ayikwazanga ukulondoloza okunamathiselwe okungu-%d + Ayikwazanga ukulondoloza okunamathiselwe okungu-%d + + "Ilondoloze okunamathiselwa kwe-MMS" + "Izilungiselelo" + "Okufakwe kungobo yomlando" + "Vala" + "I-MMS" + "Okuthuthukisiwe" + "Lungisa iphutha" + "Izaziso" + "Umsindo" + "Thulile" + "Dlidlizela" + "Ivinjelwe" + "Imibiko yokulethwa kwe-SMS" + "Cela umbiko wokuthumela we-SMS ngayinye oyithumelayo" + "Thola ngokuzenzakalekayo" + "Thola ngokuzenzakalela imilayezo ye-MMS" + "Ukuthola okuzenzakalelayo kokuzula" + "Thola ngokuzenzakalela i-MMS uma uzula" + "Imilayezo yeqembu" + "Sebenzisa i-MMS ukuthumela umlayezo owodwa uma kunabamukeli abaningi" + "Uhlelo lokusebenza oluzenzakalelayo le-SMS" + "Uhlelo lokusebenza oluzenzakalelayo le-SMS" + + + "Inombolo yakho yefoni" + "Akwaziwa" + "Imisindo yemilayezo ephumayo" + "Lahla i-SMS" + "Lahla idatha engahluziwe ye-SMS etholiwe kufayela lesitoreji sangaphandle" + "Lahla i-MMS" + "Lahla idatha engahluziwe ye-MMS etholiwe kufayela lesitoreji sangaphandle" + "Izexwayiso ezingenantambo" + "Izinketho zomlayezo" + "Kopisha umbhalo" + "Buka imininingwane" + "Susa" + "Dlulisela phambili" + "Imininingwane yomlayezo" + "Uhlobo: " + "Umlayezo wombhalo" + "Umlayezo wokuxhumana okuxubile" + "Kusuka ku: " + "Ku: " + "Okuthunyelwe: " + "Okutholiwe: " + "Isihloko: " + "Usayizi: " + "Okubalulekile: " + "SIM: " + "Okuphezulu" + "Okuvamile" + "Okuphansi" + "SIM %s" + "Ikheli lomthumeli lifihliwe" + "Ayikwazi ukuthumela umlayezo ngenkathi ilayisha izinamathiseli." + "Ayikwazi ukulayisha okunamathiselwe. Zama futhi." + "Inethiwekhi ayilungile. Zama futhi." + "Susa umbhalo" + "Shintsha phakathi kokufaka umbhalo nezinombolo" + "Engeza ababambiqhaza abaningi" + "Qinisekisa ababambiqhazi" + "Qala ingxoxo entsha" + "Khetha into" + "Dlala ividiyo" + "Abantu nezinketho" + "Lungisa iphutha" + "Abantu nezinketho" + "Okuvamile" + "Abantu kule ngxoxo" + "Yenza ikholi" + "Thumela umlayezo" + "Thumela umlayezo<br/><small>kusukela ku-%s</small>" + + Thumela izithombe + Thumela izithombe + + + Thumela imisindo + Thumela imisindo + + + Thumela amavidiyo + Thumela amavidiyo + + + Thumela amakhadi oxhumana nabo + Thumela amakhadi oxhumana nabo + + + Thumela okunamathiselwe + Thumela okunamathiselwe + + + %d okunamathiselwe kukulungele ukuthunyelwa + %d okunamathiselwe kukulungele ukuthunyelwa + + "Thumela impendulo" + "Buka ku-Google Play Isitolo" + "Ulwazi lwenguqulo" + "Inguqulo %1$s" + "Amalayisense womthombo ovulekile" + "Izaziso" + "Kufinyelelwe kumkhawulo wokunamathiselwe kwi-imeyili" + "Ihlulekile ukulayisha okunamathiselwe kwi-imeyili." + "Engeza oxhumana nabo" + "Engeza oxhumana naye" + "Isihloko" + "Isihloko: " + "%s%s" + "Ilayisha ikhadi loxhumana naye" + "Ayikwazi ukulayisha ikhadi loxhumana naye" + "Buka ikhadi lokuxhumana" + + %d oxhumana nabo + %d oxhumana nabo + + "Amakhadi wokuxhumana" + "Usuku lokuzalwa" + "Amanothi" + "Dlulisa umlayezo" + "Phendula" + "+1" + "+%d" + "I-SMS ikhutshaziwe" + "Ukuze uthumele, setha Ukulayeza njengohlelo lwakho lokusebenza oluzenzakalelayo lwe-SMS" + "Setha Ukulayeza njengohlelo lokusebenza oluzenzakalelayo lwe-SMS" + "Guqula" + "Ukuze uthole imilayezo, setha Ukulayeza njengohlelo lokusebenza oluzenzakalelayo lwe-SMS" + "Ayikho i-SIM ethandwayo ekhethiwe yokuthumela imilayezo ye-SMS" + "Lolu hlelo lokusebenza aluvumelekile umnikazi wedivayisi." + "KULUNGILE" + "Ababambiqhaza abaningi kakhulu engxoxweni" + + Oxhumana nabo abangavumelekile + Oxhumana nabo abangavumelekile + + "Ayikwazanga ukulayisha isithombe sekhamera" + "Wena: " + "%s: " + "Okusalungiswa" + "Uma uqala ingxoxo entsha, uzoyibona kuhlu lapha" + "Izingxoxo ezifakwe kungobo yomlando zivela lapha" + "Ilayisha izingxoxo..." + "Isithombe" + "Isiqeshana somsindo" + "Ividiyo" + "Ikhadi loxhumana naye" + "i-MMS" + "Hlehlisa" + "Zama futhi" + "Faka igama loxhumana naye noma inombolo eyefoni ukuze uqale umlayezo omusha" + "Vimba" + "Vimbela u-%s" + "Susa ukuvimbela u-%s" + "Vumba e-%s?" + "Uzoqhubeka nokuthola imilayezo kusuka kule nombolo kodwa ngeke usaziswa. Le ngxoxo izofakwa kungobo yomlando." + "Oxhumana nabo abavinjiwe" + "VULELA" + "Oxhumana nabo abavinjiwe" + "Khetha isithombe kusuka kulabhulali yedokhumenti" + "Ithumela umlayezo" + "Umyalezo uthunyelwe" + "Idatha yeselula ivaliwe. Hlola izilungiselelo zakho." + "Ayikwazi ukuthumela imilayezo kumodi yendiza" + "Umlayezo awukwazanga ukuthunyelwa" + "Umlayezo ulandiwe" + "Idatha yeselula ivaliwe. Hlola izilungiselelo zakho." + "Ayikwazi ukulanda imilayezo kumodi ye-Ndiza" + "Umlayezo awukwazanga ukulandwa" + "Iqanda" + "Kunye" + "Kubili" + "Kuthathu" + "Kune" + "Kuhlanu" + "Isithupha" + "Yisikhombisa" + "Isishiyagalombili" + "Isishiyagalolunye" + "Ayikwazi ukuthumela umlayezo nge-%1$s, iphutha %2$d" + "Ayikwazi ukuthumela umlayezo ngenkampani yenethiwekhi engaziwa, iphutha %1$d" + "Dlulisa: %s" + "Umlayezo awuthunyelwe: isevisi ayisebenzi kunethiwekhi" + "Umlayezo awuthunyelwe: ikheli lendawo okuya kulo alivumelekile" + "Umlayezo awuthunyelwe: umlayezo ongavumelekile" + "Umlayezo awuthunyelwe: okuqukethwe okungasekelwe" + "Umlayezo awuthunyelwe: umlayezo ongasekelwe" + "Umlayezo awuthunyelwe: mkhulu kakhulu" + "Umlayezo omusha" + "Buka" + "Isithombe" + "Ayikwazanga ukuthola uhlelo lokusebenza olufanelekile" + "Susa umamukeli" + "Umlayezo omusha" + "Khansela" + "Hlela iphoyinti lokufinyelela" + "Ayisethiwe" + "Igama" + "I-APN" + "I-MMSC" + "Ummeleli we-MMS" + "Imbobo ye-MMS" + "I-MCC" + "I-MNC" + "Uhlobo lwe-APN" + "Susa i-APN" + "I-APN Entsha" + "Londoloza" + "Lahla" + "Igama lenkambu akumele lingabi nalutho." + "I-APN akumele ingabi nalutho." + "Inkundla ye-MCC kumele ibe namadijithi angu-3." + "Inkambu ye-MNC kumelwe ibe amadijithi angu-2 noma angu-3." + "Ibuyisela izilungiselelo ze-APN zokuzenzakalelayo." + "Setha kabusha kube ngokuzenzakalelayo" + "Izilungiselelo ze-APN zokusethwa kabusha okuzenzakalelayo ziqediwe." + "Akunasihloko" + "Amagama wephoyinti lokufinyelela" + "Ama-APN" + "I-APN Entsha" + "Izilungiselelo zegama le-Phoyinti Lokufinyelela azitholakali kulo msebenzisi" + "Kopisha kubhodi lokunamathisela?" + "Kopisha" + "ukuya ku-%s" + "Okuvamile" + "Okuthuthukisiwe" + "Izilungiselelo ezijwayelekile" + "Izilungiselelo ezithuthukisiwe" + "\"%s\" SIM" + "Thumela imilayezo ye-SIM ngamunye kubo bonke abamukeli. Uwena kuphela ozothla izimpendulo" + "Thumela i-SMS eyodwa kubo bonke abamukeli" + "Inombolo engaziwa" + "Umlayezo omusha." + "Umlayezo omusha." + "Iskhethi se-SIM" + "%1$s okukhethiwe, isikhethi se-SIM" + "Hlela isihloko" + "Khetha i-SIM noma hlela isihloko" + "Thinta futhi ubambe ukuze urekhode umsindo" + "Qala ingxoxo entsha" + "Imilayezo" + "Uhlu lokulayeza" + "Imilayezo" + "Umlayezo omusha" + "Uhlu lwengxoxo" + "Ilayisha izingxoxo" + "Ilayisha" + "Buka izingxoxo ezengeziwe" + "Buka eminye imilayezo" + "Ingxoxo isusiwe" + "Ingxoxo isusiwe. Thinta ukuze ubonise ingxoxo ehlukile yokulayeza" + "Uvinjelwe" + "Uvulelwe" + "Isikhala sesitoreji siphansi. Enye idatha ingalahleka." + "Khetha okunamathiselwe" + "Qinisekisa ukukhetha" + "%d okukhethiwe" + "Sicela ususe okunamathiselwe kwi-imeyili okukodwa noma okuningi uphinde uzame futhi." + "Ungazama ukuthumela umlayezo wakho, kodwa kungenzeka ungathunyelwa ngaphandle kokuthi ususe okunamathiselwe kwi-imeyili okukodwa noma okuningi." + "Ungathumela ividiyo eyodwa kuphela ngomlayezo. Sicela ususe amavidiyo angeziwe uphinde uzame futhi." + "Ukulayeza kuhlulekile ukulayisha okunamathiselwe kwi-imeyili." + "Thumela noma kunjalo" + "Ayikwazanga ukuqala ingxoxo" + "%1$s (%2$s)" + "I-%s ikhethiwe" + diff --git a/res/values/arrays.xml b/res/values/arrays.xml new file mode 100644 index 0000000..59e3206 --- /dev/null +++ b/res/values/arrays.xml @@ -0,0 +1,42 @@ + + + + + + no subject + nosubject + + + + + Yes + No + OK + Hehe + Thanks + I agree + Nice + On my way + OK, let me get back to you later + :) + :( + + diff --git a/res/values/attrs.xml b/res/values/attrs.xml new file mode 100644 index 0000000..233cf9c --- /dev/null +++ b/res/values/attrs.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/values/colors.xml b/res/values/colors.xml new file mode 100644 index 0000000..f33e105 --- /dev/null +++ b/res/values/colors.xml @@ -0,0 +1,174 @@ + + + + #689F38 + @color/primary_color + + + #ffffff + @color/primary_color + #537F2D + #ffffff + #9D9D9D + #838383 + + + #636363 + #323232 + #F05A24 + #DE000000 + #8A000000 + + #44000000 + + #000000 + #99323232 + #99ffffff + #99ff0000 + #000000 + + #ff323232 + #000000 + #99ffffff + #6a6a6a + #007fce + #99ff0000 + #ffffffff + #99ffffff + #ffffffff + + @android:color/transparent + #670099cc + #cc0099cc + #6733b5e5 + + #ffffffff + #ffffffff + #6a6a6a + #ff323232 + #eeeeee + #9d9d9d + @color/primary_color + #999999 + #ffffffff + #e2e2e2 + #ffffffff + #8BC34A + #80689F38 + #ff808080 + + + #ff0000 + + + #7f040000 + + #d2d2d2 + #40000000 + #4d4d4d + #999999 + + @android:color/white + #a0ffffff + #44000000 + #ddffffff + @android:color/white + @color/primary_color + #ffffff + #4285f4 + #ededed + #33000000 + @android:color/white + + #80000000 + + #80ffffff + + #eeeeee + #6733b5e5 + #44000000 + + #ffffff + + #29000000 + #30000000 + #40000000 + #323232 + #323232 + @color/primary_color + + #9A9A9A + #FFFFFF + #FFFFFF + #a2a2a2 + @color/primary_color + #ff0000 + #99aaaaaa + + #4d4d4d + #6d6d6d + #6d6d6d + #cccccc + + @color/primary_color + #3ea4dc + #40ffffff + + #555555 + #8BC34A + #6d6d6d + #ff000000 + @color/primary_color + #40000000 + @color/primary_color + + #4d4d4d + #999999 + #cceeeeee + #ffeeeeee + #ffffff + #323232 + + #80689F38 + #b0dddddd + + #03a9f4 + #4d4d4d + + #4d4d4d + #6d6d6d + #333333 + #000000 + + @color/primary_color + + #424242 + @android:color/white + #424242 + #ffffff + #40000000 + #FFFFFF + + #eeeeee + #eeeeee + + #1e000000 + #42000000 + #ffffff + + #F1F1F1 + diff --git a/res/values/config.xml b/res/values/config.xml new file mode 100644 index 0000000..954f5ed --- /dev/null +++ b/res/values/config.xml @@ -0,0 +1,21 @@ + + + + + + 0 + diff --git a/res/values/constants.xml b/res/values/constants.xml new file mode 100644 index 0000000..8985fd1 --- /dev/null +++ b/res/values/constants.xml @@ -0,0 +1,95 @@ + + + + + + + notifications_category + mms_messaging_category + advanced_category + notifications_enabled + true + notification_sound + notification_vibration + true + sms_disabled + sms_enabled + send_sound + true + advanced_prefs + + + buglesub_delivery_reports + false + buglesub_auto_retrieve_mms + true + buglesub_auto_retrieve_mms_when_roaming + false + buglesub_group_messaging + true + buglesub_mms_phone_number + buglesub_wireless_alerts_key + buglesub_apn_list + + + debug_category + dump_sms + false + dump_mms + false + + + + + sms_apns_key + use_local_apn_pref_key + false + + 600 + 300 + 300 + 200 + 70% + + 300 + + + notifications_group_children + + + "'IMG'_yyyyMMdd_HHmmss" + + "yyyyMMdd_HHmmss" + + + 300 + 4000 + + + 300 + + + 300 + + + 300 + diff --git a/res/values/dimens.xml b/res/values/dimens.xml new file mode 100644 index 0000000..270ea9e --- /dev/null +++ b/res/values/dimens.xml @@ -0,0 +1,191 @@ + + + + + 56dp + 8dp + 20sp + 16sp + 12sp + 12sp + 12sp + 16sp + 42dp + 56dp + 12dp + 16dp + 8dp + 3dp + 56dp + 56dp + 42dp + 96dp + 4dp + 30dp + 16dp + + 9dp + 12dp + 10dp + 14dp + 18dp + 2dp + 4dp + 12dp + + 215dp + 4dp + 42dp + 36dp + 3dp + 16dp + 16sp + 20dp + 10dp + 133dp + 52dp + 64dp + 4dp + 6dp + -12dp + 12dp + 2dp + 12dp + 8dp + + + 240dp + 240dp + + + 16sp + 14sp + 42dp + 20sp + 14sp + 2dp + 26dp + + + 150dp + + 1000dp + 110dp + 50dp + 150dp + 130dp + 260dp + 1dp + 22sp + + 67% + 28% + 75% + + 16sp + 14sp + 6dp + + 320dp + 3dp + 2dp + 12dp + 18sp + 14sp + 16sp + 240dp + 240dp + + 60dp + 4dp + 2dp + 6dp + 12dp + 14dp + 12sp + + 40dp + 12sp + 10dp + 14sp + 20dp + 60dp + 16sp + + 24dp + 18dp + + 24dp + 20dp + 14dp + 20dp + + 6dp + + 14sp + 14sp + 42dp + + 25dp + 10dp + 5dp + + 16sp + 14sp + 60dp + 14sp + + + 40dp + 30dp + 20dp + 8dp + 12dp + 3dp + 2dp + + 20dp + + + 8dp + 0dp + 2dp + 48dp + 48dp + 18sp + 12dp + 16dp + + 110dp + 15dp + + + 120dp + + + 8dp + 8dp + 48dp + 88dp + 88dp + 44dp + 24dp + 8dp + 8dp + 24sp + + diff --git a/res/values/strings.xml b/res/values/strings.xml new file mode 100644 index 0000000..ec675ef --- /dev/null +++ b/res/values/strings.xml @@ -0,0 +1,976 @@ + + + + + Messaging + + + Messaging + + Select conversation + + Settings + Send Message + Add an attachment + Help + + Welcome + Skip + Next > + Next + Exit + Settings > + Settings + + Messaging needs permission to SMS, Phone and Contacts. + You can change permissions in Settings > Apps > Messaging > Permissions. + You can change permissions in Settings, Apps, Messaging, Permissions. + + + Frequents + + + All contacts + + + Send to %s + + Capture pictures or video + Choose images from this device + Record audio + Choose photo + The media is selected. + The media is unselected. + %d selected + + image %1$tB %1$te %1$tY %1$tl %1$tM %1$tp + image + Record audio + + Share + + "Just now" + "Now" + + + + %d min + %d mins + + + + + %d hour + %d hours + + + + + %d day + %d days + + + + + + a week + + %d weeks + + + + a month + + %d months + + + + a year + + %d years + + + + Class 0 message + Save + + + Device is low on space. Messaging will automatically delete older messages to free up space. + + Storage space running out + + Messaging might not send or receive messages until more space is available on your device. + + Low SMS storage. You may need to delete messages. + + + Confirm your phone number + + This one-time step will ensure Messaging will deliver your group messages properly. + + Phone number + + + Delete all messages with media + Delete messages older than %s + Auto-delete messages older than %s + Ignore + + + Delete all messages with media? + Delete messages older than %s? + Delete messages older than %s and turn on auto-delete? + + + + + %s said + You said + + + Message from %s + You sent a message + + + Sending… + + Not sent. Touch to try again. + + Not sent. Trying again… + + Resend or delete + + + Please make a voice call to emergency services. Your text message could not be delivered at this time. + + + Failed + + + New MMS message to download + + New MMS message + + Couldn\'t download + + Touch to try again + + Touch to download + + Download or delete + + Downloading… + + Message expired or not available + + size: %1$s, expiration: %2$s + + Can\'t send. Recipient not valid. + + Service not activated on network + + Couldn\'t send due to network problem + + Message expired or not available + + + (No subject) + + Unknown sender + Delivered + + Couldn\'t download message %1$s from %2$s. + + Couldn\'t complete database operation due to low memory + + Message not sent + + Some messages not sent in Messaging + + %d messages in one conversation + %d messages in %d conversations + + + Message not downloaded + + Some messages not downloaded in Messaging + + %d messages in one conversation + %d messages in %d conversations + + + Message to %1$s not sent + + + Please make a voice call to emergency services. Your text message to + %1$s could not be delivered at this time. + + + + New message + %d new messages + + + + Start + + Camera not available + Camera not available + Video capture not available + + Can\'t save media + Can\'t take picture + + + Back + + Archived + + Delete + + Archive + + Unarchive + + Turn off notifications + + Turn on notifications + + Add contact + + Download + + Send + + Delete + + Delete this message? + This action cannot be undone. + Delete + + + + Delete this conversation? + Delete these conversations? + + + + Delete + + Cancel + + + To + + Select multiple images + + Confirm selection + + +%d + + Can\'t record audio. Try again. + + Can\'t play audio. Try again. + + Couldn\'t save audio. Try again. + + Touch & hold + + + ,\u0020 + + \u0020\u0020 + + :\u0020 + + \u0020\u0020 + + Picture + + Audio clip + + Video + + Contact card + + Download + + Reply via SMS + + Reply via MMS + + Reply + ... + + + %d participant + %d participants + + + Me + + + Contact blocked & archived + + Contact unblocked & unarchived + + %d archived + + %d unarchived + + Notifications turned off + + Notifications turned on + + + All set. Touch Send again. + + Messaging successfully set as the default SMS app. + + + + Discard attachment + Discard attachments + + Audio attachment + Play audio attachment + Pause + + + Message from %s: %s. + + Failed message from %s: %s. Time: %s. + Message from %s: %s. Time: %s. + Unsent message to %s: %s. Time: %s. + Sending message to %s: %s. Time: %s. + Failed message to %s: %s. Time: %s. + Message to %s: %s. Time: %s. + + Failed message from %s: %s. Time: %s. %s. + Message from %s: %s. Time: %s. %s. + Unsent message to %s: %s. Time: %s. + Sending message to %s: %s. Time: %s. + Failed message to %s: %s. Time: %s. + Message to %s: %s. Time: %s. + + Failed message. Touch to retry. + + + Conversation with %s + + + Delete subject + + Capture video + Capture a still image + Take picture + Start recording video + Switch to full screen camera + Switch between front and back camera + Stop recording and attach video + Stop recording video + + Messaging photos + + %d photo saved to \"%s\" album + %d photos saved to \"%s\" album + + + %d video saved to \"%s\" album + %d videos saved to \"%s\" album + + + %d attachment saved to \"%s\" album + %d attachments saved to \"%s\" album + + + %d attachment saved to \"Downloads\" + %d attachments saved to \"Downloads\" + + + %d attachment saved + %d attachments saved + + + Couldn\'t save %d attachment + Couldn\'t save %d attachments + + + Saved MMS attachment + + + Settings + + Archived + + Close + + MMS + + Advanced + + Debug + + Notifications + + Sound + + Silent + + Vibrate + Blocked + + SMS delivery reports + Request a delivery report for each SMS you send + Auto-retrieve + Automatically retrieve MMS messages + + Roaming auto-retrieve + Automatically retrieve MMS when roaming + Group messaging + Use MMS to send a single message when there are multiple recipients + + Default SMS app + + Default SMS app + + %s + + Your phone number + + Unknown + + Outgoing message sounds + + Dump SMS + Dump received SMS raw data into external storage file + Dump MMS + Dump received MMS raw data into external storage file + + SMS dump file + Email SMS dump + Load dump file + Email dump file + Wireless alerts + + + Message options + + Copy text + + View details + + Delete + + Forward + + Message details + + Type:\u0020 + + Text message + + Multimedia message + + From:\u0020 + + To:\u0020 + + Sent:\u0020 + + Received:\u0020 + + Subject:\u0020 + + Size:\u0020 + + Priority:\u0020 + + SIM:\u0020 + + High + + Normal + + Low + + SIM %s + + Hidden sender address + + Can\'t send message while loading attachments. + + Can\'t load attachment. Try again. + + Network is not ready. Try again. + + Delete text + Switch between entering text and numbers + Add more participants + Confirm participants + Start new conversation + + Select this item + Play video + + People & options + + Debug + + People & options + + General + + People in this conversation + + Make a call + + Send message + + Send message<br/><small>from %s</small> + + + Send photo + Send photos + + + + Send audio + Send audios + + + + Send video + Send videos + + + + Send contact card + Send contact cards + + + + Send attachment + Send attachments + + + + No attachments selected + One attachment ready to send + %d attachments ready to send + + + Send feedback + + View in Google Play Store + + Version info + + Version %1$s + + + Open source licenses + + Notifications + + Attachment limit reached + + Failed to load attachment. + + Add to Contacts? + + Add Contact + + Subject + + Subject:\u0020 + + %s%s + + + Loading contact card + + Couldn\'t load contact card + + View contact card + + + + %d contact + %d contacts + + + Contact cards + + Birthday + + Notes + + Forward message + + Reply + + + +1 + + + +%d + + + SMS disabled + + + To send, set Messaging as the default SMS app + + + Set Messaging as the default SMS app + + + Change + + + To receive messages, set Messaging as the default SMS app + + + No preferred SIM selected for sending SMS messages + + + This app isn\'t allowed by the device owner. + + OK + + + Too many participants in a conversation + + + Invalid contact + Invalid contacts + + + Couldn\'t load camera image + + + You:\u0020 + + + %s:\u0020 + + + Draft + + + Once you start a new conversation, you’ll see it listed here + + Archived conversations appear here + + Loading conversations… + + + Picture + + Audio clip + + Video + + Contact card + + + MMS + + + Undo + Retry + + + Enter a contact name or phone number to start a new message + + + Block + + + Block %s + + + Unblock %s + + + Block %s? + + + You\'ll continue to receive messages from this number but won\'t be notified anymore. This conversation will be archived. + + + Blocked contacts + + + UNBLOCK + + + Blocked contacts + + + Choose image from document library + + Sending message + + Message sent + + Cellular data is turned off. Check your settings. + + Can\'t send messages in Airplane mode + + Message could not be sent + + + Message downloaded + + Cellular data is turned off. Check your settings. + + Can\'t download messages in Airplane mode + + Message could not be downloaded + + + Zero + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + + + Can\'t send message with %1$s, error %2$d + Can\'t send message with unknown carrier, error %1$d + + + Fwd: %s + + + Message not sent: service not activated on network + Message not sent: invalid destination address + Message not sent: invalid message + Message not sent: unsupported content + Message not sent: unsupported message + Message not sent: too large + + + New message + View + + + Image + + + Could not find an appropriate application + + SUB Id + + + Remove recipient + + + New message + + + Cancel + + + Edit access point + + Not set + + Name + + APN + + MMSC + + MMS proxy + + MMS port + + MCC + + MNC + + APN type + + Delete APN + + New APN + + Save + + Discard + + The Name field can\'t be empty. + + The APN can\'t be empty. + + MCC field must be 3 digits. + + MNC field must be 2 or 3 digits. + + Restoring default APN settings. + + Reset to default + + Reset default APN settings completed. + + Untitled + + Access Point Names + + APNs + + New APN + + Access Point Name settings are not available for this user + + Copy to clipboard? + + Copy + + to %s + + + General + + + Advanced + + + General settings + + + Advanced settings + + + \"%s\" SIM + + + Send individual SMS messages to all recipients. Only you will get any replies + Send a single MMS to all recipients + + + Unknown number + + + New message + + New message. + + + SIM selector + + %1$s selected, SIM selector + + + Edit subject + + + Select SIM or edit subject + + + Touch and hold to record audio + + + Start new conversation + + Messaging + + Messaging List + + Messaging + + New message + + Conversation list + + Loading conversations + + Loading messages + + View more conversations + + View more messages + + Conversation deleted + + Conversation deleted. Touch to show a different Messaging conversation + + + Blocked + + Unblocked + Storage space is low. Some data may be lost. + + + Select attachments + + + Confirm selection + + + %d selected + + + Please remove one or more attachments and try again. + + + You can try sending your message, but it might not be delivered unless you remove one or more attachments. + + + You can only send one video per message. Please remove additional videos and try again. + + + Messaging failed to load attachment. + + + Send anyway + + + Could not start conversation + + + %1$s (%2$s) + + + %s selected + diff --git a/res/values/styles.xml b/res/values/styles.xml new file mode 100644 index 0000000..d45f2e1 --- /dev/null +++ b/res/values/styles.xml @@ -0,0 +1,626 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/values/versions.xml b/res/values/versions.xml new file mode 100644 index 0000000..e82b79e --- /dev/null +++ b/res/values/versions.xml @@ -0,0 +1,25 @@ + + + + + 1 + + + 1 + diff --git a/res/xml-mcc204-mnc04/mms_config.xml b/res/xml-mcc204-mnc04/mms_config.xml new file mode 100644 index 0000000..96e5b9d --- /dev/null +++ b/res/xml-mcc204-mnc04/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc208-mnc01/mms_config.xml b/res/xml-mcc208-mnc01/mms_config.xml new file mode 100644 index 0000000..486c468 --- /dev/null +++ b/res/xml-mcc208-mnc01/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 614400 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc208-mnc10/mms_config.xml b/res/xml-mcc208-mnc10/mms_config.xml new file mode 100644 index 0000000..486c468 --- /dev/null +++ b/res/xml-mcc208-mnc10/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 614400 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc208-mnc15/mms_config.xml b/res/xml-mcc208-mnc15/mms_config.xml new file mode 100644 index 0000000..486c468 --- /dev/null +++ b/res/xml-mcc208-mnc15/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 614400 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc208-mnc20/mms_config.xml b/res/xml-mcc208-mnc20/mms_config.xml new file mode 100644 index 0000000..486c468 --- /dev/null +++ b/res/xml-mcc208-mnc20/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 614400 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc208-mnc26/mms_config.xml b/res/xml-mcc208-mnc26/mms_config.xml new file mode 100644 index 0000000..486c468 --- /dev/null +++ b/res/xml-mcc208-mnc26/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 614400 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc214-mnc01/mms_config.xml b/res/xml-mcc214-mnc01/mms_config.xml new file mode 100644 index 0000000..d6fad05 --- /dev/null +++ b/res/xml-mcc214-mnc01/mms_config.xml @@ -0,0 +1,26 @@ + + + + + + + + false + diff --git a/res/xml-mcc214-mnc03/mms_config.xml b/res/xml-mcc214-mnc03/mms_config.xml new file mode 100644 index 0000000..96e5b9d --- /dev/null +++ b/res/xml-mcc214-mnc03/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc214-mnc07/mms_config.xml b/res/xml-mcc214-mnc07/mms_config.xml new file mode 100644 index 0000000..a90a26d --- /dev/null +++ b/res/xml-mcc214-mnc07/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 512000 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc218-mnc05/mms_config.xml b/res/xml-mcc218-mnc05/mms_config.xml new file mode 100644 index 0000000..96e5b9d --- /dev/null +++ b/res/xml-mcc218-mnc05/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc222-mnc01/mms_config.xml b/res/xml-mcc222-mnc01/mms_config.xml new file mode 100644 index 0000000..96e5b9d --- /dev/null +++ b/res/xml-mcc222-mnc01/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc222-mnc08/mms_config.xml b/res/xml-mcc222-mnc08/mms_config.xml new file mode 100644 index 0000000..96e5b9d --- /dev/null +++ b/res/xml-mcc222-mnc08/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc234-mnc10/mms_config.xml b/res/xml-mcc234-mnc10/mms_config.xml new file mode 100755 index 0000000..9f89f96 --- /dev/null +++ b/res/xml-mcc234-mnc10/mms_config.xml @@ -0,0 +1,27 @@ + + + + + + + + 10 + diff --git a/res/xml-mcc234-mnc11 b/res/xml-mcc234-mnc11 new file mode 120000 index 0000000..e29c9d3 --- /dev/null +++ b/res/xml-mcc234-mnc11 @@ -0,0 +1 @@ +xml-mcc234-mnc10 \ No newline at end of file diff --git a/res/xml-mcc234-mnc15/mms_config.xml b/res/xml-mcc234-mnc15/mms_config.xml new file mode 100755 index 0000000..9f89f96 --- /dev/null +++ b/res/xml-mcc234-mnc15/mms_config.xml @@ -0,0 +1,27 @@ + + + + + + + + 10 + diff --git a/res/xml-mcc238-mnc02/mms_config.xml b/res/xml-mcc238-mnc02/mms_config.xml new file mode 100644 index 0000000..6a27809 --- /dev/null +++ b/res/xml-mcc238-mnc02/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 2097152 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc240-mnc01/mms_config.xml b/res/xml-mcc240-mnc01/mms_config.xml new file mode 100644 index 0000000..486c468 --- /dev/null +++ b/res/xml-mcc240-mnc01/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 614400 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc240-mnc04/mms_config.xml b/res/xml-mcc240-mnc04/mms_config.xml new file mode 100644 index 0000000..486c468 --- /dev/null +++ b/res/xml-mcc240-mnc04/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 614400 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc240-mnc08/mms_config.xml b/res/xml-mcc240-mnc08/mms_config.xml new file mode 100644 index 0000000..486c468 --- /dev/null +++ b/res/xml-mcc240-mnc08/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 614400 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc240-mnc24/mms_config.xml b/res/xml-mcc240-mnc24/mms_config.xml new file mode 100644 index 0000000..486c468 --- /dev/null +++ b/res/xml-mcc240-mnc24/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 614400 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc242-mnc01/mms_config.xml b/res/xml-mcc242-mnc01/mms_config.xml new file mode 100644 index 0000000..6a27809 --- /dev/null +++ b/res/xml-mcc242-mnc01/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 2097152 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc242-mnc02/mms_config.xml b/res/xml-mcc242-mnc02/mms_config.xml new file mode 100644 index 0000000..486c468 --- /dev/null +++ b/res/xml-mcc242-mnc02/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 614400 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc262-mnc07/mms_config.xml b/res/xml-mcc262-mnc07/mms_config.xml new file mode 100644 index 0000000..486c468 --- /dev/null +++ b/res/xml-mcc262-mnc07/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 614400 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc274-mnc02/mms_config.xml b/res/xml-mcc274-mnc02/mms_config.xml new file mode 100644 index 0000000..96e5b9d --- /dev/null +++ b/res/xml-mcc274-mnc02/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc274-mnc03/mms_config.xml b/res/xml-mcc274-mnc03/mms_config.xml new file mode 100644 index 0000000..96e5b9d --- /dev/null +++ b/res/xml-mcc274-mnc03/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc286-mnc01/mms_config.xml b/res/xml-mcc286-mnc01/mms_config.xml new file mode 100644 index 0000000..a90a26d --- /dev/null +++ b/res/xml-mcc286-mnc01/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 512000 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc286-mnc03/mms_config.xml b/res/xml-mcc286-mnc03/mms_config.xml new file mode 100644 index 0000000..a90a26d --- /dev/null +++ b/res/xml-mcc286-mnc03/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 512000 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc294-mnc01/mms_config.xml b/res/xml-mcc294-mnc01/mms_config.xml new file mode 100644 index 0000000..03ff16c --- /dev/null +++ b/res/xml-mcc294-mnc01/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 102400 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc294-mnc02/mms_config.xml b/res/xml-mcc294-mnc02/mms_config.xml new file mode 100644 index 0000000..51af512 --- /dev/null +++ b/res/xml-mcc294-mnc02/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 131072 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc294-mnc03/mms_config.xml b/res/xml-mcc294-mnc03/mms_config.xml new file mode 100644 index 0000000..208a5a2 --- /dev/null +++ b/res/xml-mcc294-mnc03/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 256000 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc302-mnc220/mms_config.xml b/res/xml-mcc302-mnc220/mms_config.xml new file mode 100644 index 0000000..4267322 --- /dev/null +++ b/res/xml-mcc302-mnc220/mms_config.xml @@ -0,0 +1,46 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + + + 10 + + + 5 + + + 80 + diff --git a/res/xml-mcc302-mnc221/mms_config.xml b/res/xml-mcc302-mnc221/mms_config.xml new file mode 100644 index 0000000..4267322 --- /dev/null +++ b/res/xml-mcc302-mnc221/mms_config.xml @@ -0,0 +1,46 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + + + 10 + + + 5 + + + 80 + diff --git a/res/xml-mcc302-mnc270/mms_config.xml b/res/xml-mcc302-mnc270/mms_config.xml new file mode 100644 index 0000000..7f92daa --- /dev/null +++ b/res/xml-mcc302-mnc270/mms_config.xml @@ -0,0 +1,46 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + + + 20 + + + 6 + + + 80 + diff --git a/res/xml-mcc302-mnc290/mms_config.xml b/res/xml-mcc302-mnc290/mms_config.xml new file mode 100644 index 0000000..96e5b9d --- /dev/null +++ b/res/xml-mcc302-mnc290/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc302-mnc320/mms_config.xml b/res/xml-mcc302-mnc320/mms_config.xml new file mode 100644 index 0000000..0dc7956 --- /dev/null +++ b/res/xml-mcc302-mnc320/mms_config.xml @@ -0,0 +1,39 @@ + + + + + + 614400 + + + 1944 + + + 2592 + + + 20 + + + 6 + + + 80 + diff --git a/res/xml-mcc302-mnc370/mms_config.xml b/res/xml-mcc302-mnc370/mms_config.xml new file mode 100644 index 0000000..7f92daa --- /dev/null +++ b/res/xml-mcc302-mnc370/mms_config.xml @@ -0,0 +1,46 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + + + 20 + + + 6 + + + 80 + diff --git a/res/xml-mcc302-mnc490/mms_config.xml b/res/xml-mcc302-mnc490/mms_config.xml new file mode 100644 index 0000000..7f92daa --- /dev/null +++ b/res/xml-mcc302-mnc490/mms_config.xml @@ -0,0 +1,46 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + + + 20 + + + 6 + + + 80 + diff --git a/res/xml-mcc302-mnc500/mms_config.xml b/res/xml-mcc302-mnc500/mms_config.xml new file mode 100644 index 0000000..0216774 --- /dev/null +++ b/res/xml-mcc302-mnc500/mms_config.xml @@ -0,0 +1,46 @@ + + + + + + + + 3072000 + + + 1944 + + + 2592 + + + 20 + + + 6 + + + 80 + diff --git a/res/xml-mcc302-mnc510/mms_config.xml b/res/xml-mcc302-mnc510/mms_config.xml new file mode 100644 index 0000000..0216774 --- /dev/null +++ b/res/xml-mcc302-mnc510/mms_config.xml @@ -0,0 +1,46 @@ + + + + + + + + 3072000 + + + 1944 + + + 2592 + + + 20 + + + 6 + + + 80 + diff --git a/res/xml-mcc302-mnc520/mms_config.xml b/res/xml-mcc302-mnc520/mms_config.xml new file mode 100644 index 0000000..21b15bb --- /dev/null +++ b/res/xml-mcc302-mnc520/mms_config.xml @@ -0,0 +1,39 @@ + + + + + + 3072000 + + + 1944 + + + 2592 + + + 20 + + + 6 + + + 80 + diff --git a/res/xml-mcc302-mnc610/mms_config.xml b/res/xml-mcc302-mnc610/mms_config.xml new file mode 100644 index 0000000..576b5c1 --- /dev/null +++ b/res/xml-mcc302-mnc610/mms_config.xml @@ -0,0 +1,55 @@ + + + + + + + + 614400 + + + 1944 + + + 2592 + + + 20 + + + 8 + + + 80 + + + 6245 + + + false + + + false + diff --git a/res/xml-mcc302-mnc660/mms_config.xml b/res/xml-mcc302-mnc660/mms_config.xml new file mode 100644 index 0000000..7f92daa --- /dev/null +++ b/res/xml-mcc302-mnc660/mms_config.xml @@ -0,0 +1,46 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + + + 20 + + + 6 + + + 80 + diff --git a/res/xml-mcc302-mnc720/mms_config.xml b/res/xml-mcc302-mnc720/mms_config.xml new file mode 100644 index 0000000..7f92daa --- /dev/null +++ b/res/xml-mcc302-mnc720/mms_config.xml @@ -0,0 +1,46 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + + + 20 + + + 6 + + + 80 + diff --git a/res/xml-mcc302-mnc780/mms_config.xml b/res/xml-mcc302-mnc780/mms_config.xml new file mode 100644 index 0000000..486c468 --- /dev/null +++ b/res/xml-mcc302-mnc780/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 614400 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc310-mnc004/mms_config.xml b/res/xml-mcc310-mnc004/mms_config.xml new file mode 100644 index 0000000..346218b --- /dev/null +++ b/res/xml-mcc310-mnc004/mms_config.xml @@ -0,0 +1,83 @@ + + + + + + true + + + true + + + 1258291 + + + Profile + + + 20 + + + x-up-calling-line-id: 1##LINE1NOCOUNTRYCODE##|X-VzW-MDN: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + + 6245 + + + true + + + 2 + + + 48 + + + false + + + false + + + 80 + + + true + + + 7 + + diff --git a/res/xml-mcc310-mnc005/mms_config.xml b/res/xml-mcc310-mnc005/mms_config.xml new file mode 100644 index 0000000..346218b --- /dev/null +++ b/res/xml-mcc310-mnc005/mms_config.xml @@ -0,0 +1,83 @@ + + + + + + true + + + true + + + 1258291 + + + Profile + + + 20 + + + x-up-calling-line-id: 1##LINE1NOCOUNTRYCODE##|X-VzW-MDN: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + + 6245 + + + true + + + 2 + + + 48 + + + false + + + false + + + 80 + + + true + + + 7 + + diff --git a/res/xml-mcc310-mnc010/mms_config.xml b/res/xml-mcc310-mnc010/mms_config.xml new file mode 100644 index 0000000..571bc07 --- /dev/null +++ b/res/xml-mcc310-mnc010/mms_config.xml @@ -0,0 +1,55 @@ + + + + + + 1048576 + + + 1944 + + + 2592 + + + false + + + true + + + false + + + -1 + + false + + false + + false + + + false + + diff --git a/res/xml-mcc310-mnc026/mms_config.xml b/res/xml-mcc310-mnc026/mms_config.xml new file mode 100644 index 0000000..91481f9 --- /dev/null +++ b/res/xml-mcc310-mnc026/mms_config.xml @@ -0,0 +1,28 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc310-mnc040/mms_config.xml b/res/xml-mcc310-mnc040/mms_config.xml new file mode 100644 index 0000000..9622857 --- /dev/null +++ b/res/xml-mcc310-mnc040/mms_config.xml @@ -0,0 +1,83 @@ + + + + + + + + true + + + 1048576 + + + 2432 + + + 4320 + + + 500 + + + 50 + + + 10 + + + 5000 + + + 20 + + + true + + + 1 + + + true + + + -1 + + + 80 + + + X-MDN: ##LINE1## + diff --git a/res/xml-mcc310-mnc070/mms_config.xml b/res/xml-mcc310-mnc070/mms_config.xml new file mode 100755 index 0000000..e9404e8 --- /dev/null +++ b/res/xml-mcc310-mnc070/mms_config.xml @@ -0,0 +1,32 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + + + 10 + diff --git a/res/xml-mcc310-mnc090/mms_config.xml b/res/xml-mcc310-mnc090/mms_config.xml new file mode 100755 index 0000000..0c8f8de --- /dev/null +++ b/res/xml-mcc310-mnc090/mms_config.xml @@ -0,0 +1,28 @@ + + + + + + + + 614400 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc310-mnc120/mms_config.xml b/res/xml-mcc310-mnc120/mms_config.xml new file mode 100644 index 0000000..8c1ae11 --- /dev/null +++ b/res/xml-mcc310-mnc120/mms_config.xml @@ -0,0 +1,75 @@ + + + + + + true + + + 1048576 + + + 1944 + + + 2592 + + + 40 + + + true + + + 6 + + + false + + + -1 + + + 80 + + + 6245 + + false + + false + + false + + + X-MDN: ##LINE1##|Proxy-Authorization: Basic ##NAI## + + + :pcs + + + false + diff --git a/res/xml-mcc310-mnc130/mms_config.xml b/res/xml-mcc310-mnc130/mms_config.xml new file mode 100644 index 0000000..9622857 --- /dev/null +++ b/res/xml-mcc310-mnc130/mms_config.xml @@ -0,0 +1,83 @@ + + + + + + + + true + + + 1048576 + + + 2432 + + + 4320 + + + 500 + + + 50 + + + 10 + + + 5000 + + + 20 + + + true + + + 1 + + + true + + + -1 + + + 80 + + + X-MDN: ##LINE1## + diff --git a/res/xml-mcc310-mnc150/mms_config.xml b/res/xml-mcc310-mnc150/mms_config.xml new file mode 100644 index 0000000..96e5b9d --- /dev/null +++ b/res/xml-mcc310-mnc150/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc310-mnc170/mms_config.xml b/res/xml-mcc310-mnc170/mms_config.xml new file mode 100644 index 0000000..6705cbc --- /dev/null +++ b/res/xml-mcc310-mnc170/mms_config.xml @@ -0,0 +1,32 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + + + 10 + diff --git a/res/xml-mcc310-mnc260/mms_config.xml b/res/xml-mcc310-mnc260/mms_config.xml new file mode 100644 index 0000000..9bf9d70 --- /dev/null +++ b/res/xml-mcc310-mnc260/mms_config.xml @@ -0,0 +1,28 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc310-mnc360/mms_config.xml b/res/xml-mcc310-mnc360/mms_config.xml new file mode 100644 index 0000000..eb8da80 --- /dev/null +++ b/res/xml-mcc310-mnc360/mms_config.xml @@ -0,0 +1,83 @@ + + + + + + + + true + + + 1228800 + + + 2432 + + + 4320 + + + 500 + + + 50 + + + 10 + + + 5000 + + + 20 + + + true + + + 1 + + + true + + + -1 + + + 80 + + + X-MDN: ##LINE1## + diff --git a/res/xml-mcc310-mnc380/mms_config.xml b/res/xml-mcc310-mnc380/mms_config.xml new file mode 100755 index 0000000..0ea83d1 --- /dev/null +++ b/res/xml-mcc310-mnc380/mms_config.xml @@ -0,0 +1,37 @@ + + + + + + + + + 1048576 + + + 1944 + + + 2592 + + + 10 + diff --git a/res/xml-mcc310-mnc410/mms_config.xml b/res/xml-mcc310-mnc410/mms_config.xml new file mode 100755 index 0000000..4e42f3a --- /dev/null +++ b/res/xml-mcc310-mnc410/mms_config.xml @@ -0,0 +1,32 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + + + 10 + diff --git a/res/xml-mcc310-mnc420/mms_config.xml b/res/xml-mcc310-mnc420/mms_config.xml new file mode 100644 index 0000000..96e5b9d --- /dev/null +++ b/res/xml-mcc310-mnc420/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc310-mnc450/mms_config.xml b/res/xml-mcc310-mnc450/mms_config.xml new file mode 100644 index 0000000..96e5b9d --- /dev/null +++ b/res/xml-mcc310-mnc450/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc310-mnc490/mms_config.xml b/res/xml-mcc310-mnc490/mms_config.xml new file mode 100644 index 0000000..06da531 --- /dev/null +++ b/res/xml-mcc310-mnc490/mms_config.xml @@ -0,0 +1,28 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc310-mnc560/mms_config.xml b/res/xml-mcc310-mnc560/mms_config.xml new file mode 100755 index 0000000..a12d640 --- /dev/null +++ b/res/xml-mcc310-mnc560/mms_config.xml @@ -0,0 +1,32 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + + + 10 + diff --git a/res/xml-mcc310-mnc580/mms_config.xml b/res/xml-mcc310-mnc580/mms_config.xml new file mode 100644 index 0000000..eb8da80 --- /dev/null +++ b/res/xml-mcc310-mnc580/mms_config.xml @@ -0,0 +1,83 @@ + + + + + + + + true + + + 1228800 + + + 2432 + + + 4320 + + + 500 + + + 50 + + + 10 + + + 5000 + + + 20 + + + true + + + 1 + + + true + + + -1 + + + 80 + + + X-MDN: ##LINE1## + diff --git a/res/xml-mcc310-mnc600/mms_config.xml b/res/xml-mcc310-mnc600/mms_config.xml new file mode 100644 index 0000000..f76c945 --- /dev/null +++ b/res/xml-mcc310-mnc600/mms_config.xml @@ -0,0 +1,90 @@ + + + + + + + + + true + + + 8388608 + + + 2432 + + + 4320 + + + 500 + + + 50 + + + 10 + + + 5000 + + + 20 + + + true + + + 1 + + + true + + + -1 + + + 80 + + + false + + + false + + + X-MDN: ##LINE1## + diff --git a/res/xml-mcc310-mnc680/mms_config.xml b/res/xml-mcc310-mnc680/mms_config.xml new file mode 100755 index 0000000..b42fa02 --- /dev/null +++ b/res/xml-mcc310-mnc680/mms_config.xml @@ -0,0 +1,32 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + + + 10 + diff --git a/res/xml-mcc310-mnc750/mms_config.xml b/res/xml-mcc310-mnc750/mms_config.xml new file mode 100644 index 0000000..eb8da80 --- /dev/null +++ b/res/xml-mcc310-mnc750/mms_config.xml @@ -0,0 +1,83 @@ + + + + + + + + true + + + 1228800 + + + 2432 + + + 4320 + + + 500 + + + 50 + + + 10 + + + 5000 + + + 20 + + + true + + + 1 + + + true + + + -1 + + + 80 + + + X-MDN: ##LINE1## + diff --git a/res/xml-mcc310-mnc770/mms_config.xml b/res/xml-mcc310-mnc770/mms_config.xml new file mode 100644 index 0000000..96e5b9d --- /dev/null +++ b/res/xml-mcc310-mnc770/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc310-mnc920/mms_config.xml b/res/xml-mcc310-mnc920/mms_config.xml new file mode 100644 index 0000000..4319cb0 --- /dev/null +++ b/res/xml-mcc310-mnc920/mms_config.xml @@ -0,0 +1,83 @@ + + + + + + + + true + + + 1258291 + + + 2432 + + + 4320 + + + 500 + + + 50 + + + 10 + + + 5000 + + + 20 + + + true + + + 1 + + + true + + + -1 + + + 80 + + + X-MDN: ##LINE1## + diff --git a/res/xml-mcc310-mnc980/mms_config.xml b/res/xml-mcc310-mnc980/mms_config.xml new file mode 100644 index 0000000..96e5b9d --- /dev/null +++ b/res/xml-mcc310-mnc980/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc311-mnc012/mms_config.xml b/res/xml-mcc311-mnc012/mms_config.xml new file mode 100644 index 0000000..346218b --- /dev/null +++ b/res/xml-mcc311-mnc012/mms_config.xml @@ -0,0 +1,83 @@ + + + + + + true + + + true + + + 1258291 + + + Profile + + + 20 + + + x-up-calling-line-id: 1##LINE1NOCOUNTRYCODE##|X-VzW-MDN: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + + 6245 + + + true + + + 2 + + + 48 + + + false + + + false + + + 80 + + + true + + + 7 + + diff --git a/res/xml-mcc311-mnc070/mms_config.xml b/res/xml-mcc311-mnc070/mms_config.xml new file mode 100644 index 0000000..9622857 --- /dev/null +++ b/res/xml-mcc311-mnc070/mms_config.xml @@ -0,0 +1,83 @@ + + + + + + + + true + + + 1048576 + + + 2432 + + + 4320 + + + 500 + + + 50 + + + 10 + + + 5000 + + + 20 + + + true + + + 1 + + + true + + + -1 + + + 80 + + + X-MDN: ##LINE1## + diff --git a/res/xml-mcc311-mnc100/mms_config.xml b/res/xml-mcc311-mnc100/mms_config.xml new file mode 100755 index 0000000..dd671f6 --- /dev/null +++ b/res/xml-mcc311-mnc100/mms_config.xml @@ -0,0 +1,95 @@ + + + + + + + + true + + + 1048576 + + + 2432 + + + 4320 + + + 500 + + + 50 + + + 10 + + + 5000 + + + 10 + + + false + + + -1 + + + false + + + -1 + + + 80 + + + 6245 + + + false + + + false + + + false + + + X-MDN: ##LINE1## + diff --git a/res/xml-mcc311-mnc180/mms_config.xml b/res/xml-mcc311-mnc180/mms_config.xml new file mode 100644 index 0000000..891618b --- /dev/null +++ b/res/xml-mcc311-mnc180/mms_config.xml @@ -0,0 +1,37 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + + + 10 + diff --git a/res/xml-mcc311-mnc220/mms_config.xml b/res/xml-mcc311-mnc220/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc220/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc221/mms_config.xml b/res/xml-mcc311-mnc221/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc221/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc222/mms_config.xml b/res/xml-mcc311-mnc222/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc222/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc223/mms_config.xml b/res/xml-mcc311-mnc223/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc223/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc224/mms_config.xml b/res/xml-mcc311-mnc224/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc224/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc225/mms_config.xml b/res/xml-mcc311-mnc225/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc225/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc226/mms_config.xml b/res/xml-mcc311-mnc226/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc226/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc227/mms_config.xml b/res/xml-mcc311-mnc227/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc227/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc228/mms_config.xml b/res/xml-mcc311-mnc228/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc228/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc229/mms_config.xml b/res/xml-mcc311-mnc229/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc229/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc230/mms_config.xml b/res/xml-mcc311-mnc230/mms_config.xml new file mode 100755 index 0000000..b82f1d6 --- /dev/null +++ b/res/xml-mcc311-mnc230/mms_config.xml @@ -0,0 +1,95 @@ + + + + + + + + true + + + 1048576 + + + 2432 + + + 4320 + + + 500 + + + 50 + + + 10 + + + 5000 + + + 20 + + + false + + + -1 + + + false + + + -1 + + + 80 + + + 6245 + + + false + + + false + + + false + + + X-CS3G-MDN: 1##LINE1NOCOUNTRYCODE## + + diff --git a/res/xml-mcc311-mnc340/mms_config.xml b/res/xml-mcc311-mnc340/mms_config.xml new file mode 100644 index 0000000..eb8da80 --- /dev/null +++ b/res/xml-mcc311-mnc340/mms_config.xml @@ -0,0 +1,83 @@ + + + + + + + + true + + + 1228800 + + + 2432 + + + 4320 + + + 500 + + + 50 + + + 10 + + + 5000 + + + 20 + + + true + + + 1 + + + true + + + -1 + + + 80 + + + X-MDN: ##LINE1## + diff --git a/res/xml-mcc311-mnc370/mms_config.xml b/res/xml-mcc311-mnc370/mms_config.xml new file mode 100644 index 0000000..96e5b9d --- /dev/null +++ b/res/xml-mcc311-mnc370/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc311-mnc410/mms_config.xml b/res/xml-mcc311-mnc410/mms_config.xml new file mode 100644 index 0000000..9622857 --- /dev/null +++ b/res/xml-mcc311-mnc410/mms_config.xml @@ -0,0 +1,83 @@ + + + + + + + + true + + + 1048576 + + + 2432 + + + 4320 + + + 500 + + + 50 + + + 10 + + + 5000 + + + 20 + + + true + + + 1 + + + true + + + -1 + + + 80 + + + X-MDN: ##LINE1## + diff --git a/res/xml-mcc311-mnc430/mms_config.xml b/res/xml-mcc311-mnc430/mms_config.xml new file mode 100644 index 0000000..9622857 --- /dev/null +++ b/res/xml-mcc311-mnc430/mms_config.xml @@ -0,0 +1,83 @@ + + + + + + + + true + + + 1048576 + + + 2432 + + + 4320 + + + 500 + + + 50 + + + 10 + + + 5000 + + + 20 + + + true + + + 1 + + + true + + + -1 + + + 80 + + + X-MDN: ##LINE1## + diff --git a/res/xml-mcc311-mnc440/mms_config.xml b/res/xml-mcc311-mnc440/mms_config.xml new file mode 100644 index 0000000..0f26fab --- /dev/null +++ b/res/xml-mcc311-mnc440/mms_config.xml @@ -0,0 +1,89 @@ + + + + + + + + true + + + 1228800 + + + 2432 + + + 4320 + + + 500 + + + 50 + + + 10 + + + 5000 + + + 20 + + + true + + + 1 + + + true + + + -1 + + + 80 + + + false + + + false + + + X-MDN: ##LINE1## + diff --git a/res/xml-mcc311-mnc480/mms_config.xml b/res/xml-mcc311-mnc480/mms_config.xml new file mode 100644 index 0000000..1929b6a --- /dev/null +++ b/res/xml-mcc311-mnc480/mms_config.xml @@ -0,0 +1,70 @@ + + + + + + true + + + true + + + 1258291 + + + Profile + + + 20 + + + x-up-calling-line-id: 1##LINE1NOCOUNTRYCODE##|X-VzW-MDN: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + + 6245 + + + true + + + 2 + + + 48 + + + false + + + false + + + 80 + + + true + + + 7 + + diff --git a/res/xml-mcc311-mnc490/mms_config.xml b/res/xml-mcc311-mnc490/mms_config.xml new file mode 100644 index 0000000..8c1ae11 --- /dev/null +++ b/res/xml-mcc311-mnc490/mms_config.xml @@ -0,0 +1,75 @@ + + + + + + true + + + 1048576 + + + 1944 + + + 2592 + + + 40 + + + true + + + 6 + + + false + + + -1 + + + 80 + + + 6245 + + false + + false + + false + + + X-MDN: ##LINE1##|Proxy-Authorization: Basic ##NAI## + + + :pcs + + + false + diff --git a/res/xml-mcc311-mnc580/mms_config.xml b/res/xml-mcc311-mnc580/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc580/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc581/mms_config.xml b/res/xml-mcc311-mnc581/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc581/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc582/mms_config.xml b/res/xml-mcc311-mnc582/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc582/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc583/mms_config.xml b/res/xml-mcc311-mnc583/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc583/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc584/mms_config.xml b/res/xml-mcc311-mnc584/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc584/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc585/mms_config.xml b/res/xml-mcc311-mnc585/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc585/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc586/mms_config.xml b/res/xml-mcc311-mnc586/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc586/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc587/mms_config.xml b/res/xml-mcc311-mnc587/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc587/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc588/mms_config.xml b/res/xml-mcc311-mnc588/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc588/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc589/mms_config.xml b/res/xml-mcc311-mnc589/mms_config.xml new file mode 100644 index 0000000..196d4f7 --- /dev/null +++ b/res/xml-mcc311-mnc589/mms_config.xml @@ -0,0 +1,65 @@ + + + + + + true + + + true + + + 1048576 + + + x-wap-profile + + + 20 + + + x-vzw-mdn: 1##LINE1NOCOUNTRYCODE## + + + 1944 + + + 2592 + + + false + + + + 6245 + + true + + + false + + + 80 + + + diff --git a/res/xml-mcc311-mnc590/mms_config.xml b/res/xml-mcc311-mnc590/mms_config.xml new file mode 100644 index 0000000..9622857 --- /dev/null +++ b/res/xml-mcc311-mnc590/mms_config.xml @@ -0,0 +1,83 @@ + + + + + + + + true + + + 1048576 + + + 2432 + + + 4320 + + + 500 + + + 50 + + + 10 + + + 5000 + + + 20 + + + true + + + 1 + + + true + + + -1 + + + 80 + + + X-MDN: ##LINE1## + diff --git a/res/xml-mcc311-mnc870/mms_config.xml b/res/xml-mcc311-mnc870/mms_config.xml new file mode 100644 index 0000000..8c1ae11 --- /dev/null +++ b/res/xml-mcc311-mnc870/mms_config.xml @@ -0,0 +1,75 @@ + + + + + + true + + + 1048576 + + + 1944 + + + 2592 + + + 40 + + + true + + + 6 + + + false + + + -1 + + + 80 + + + 6245 + + false + + false + + false + + + X-MDN: ##LINE1##|Proxy-Authorization: Basic ##NAI## + + + :pcs + + + false + diff --git a/res/xml-mcc312-mnc160/mms_config.xml b/res/xml-mcc312-mnc160/mms_config.xml new file mode 100644 index 0000000..9622857 --- /dev/null +++ b/res/xml-mcc312-mnc160/mms_config.xml @@ -0,0 +1,83 @@ + + + + + + + + true + + + 1048576 + + + 2432 + + + 4320 + + + 500 + + + 50 + + + 10 + + + 5000 + + + 20 + + + true + + + 1 + + + true + + + -1 + + + 80 + + + X-MDN: ##LINE1## + diff --git a/res/xml-mcc312-mnc530/mms_config.xml b/res/xml-mcc312-mnc530/mms_config.xml new file mode 100755 index 0000000..7b1b093 --- /dev/null +++ b/res/xml-mcc312-mnc530/mms_config.xml @@ -0,0 +1,75 @@ + + + + + + true + + + 1048576 + + + 1944 + + + 2592 + + + 40 + + + true + + + 6 + + + false + + + -1 + + + 80 + + + 6245 + + false + + false + + false + + + X-MDN: ##LINE1##|Proxy-Authorization: Basic ##NAI## + + + :pcs + + + false + diff --git a/res/xml-mcc334-mnc020/mms_config.xml b/res/xml-mcc334-mnc020/mms_config.xml new file mode 100644 index 0000000..96e5b9d --- /dev/null +++ b/res/xml-mcc334-mnc020/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc418-mnc05/mms_config.xml b/res/xml-mcc418-mnc05/mms_config.xml new file mode 100644 index 0000000..03ff16c --- /dev/null +++ b/res/xml-mcc418-mnc05/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 102400 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc418-mnc20/mms_config.xml b/res/xml-mcc418-mnc20/mms_config.xml new file mode 100644 index 0000000..9ef0dd8 --- /dev/null +++ b/res/xml-mcc418-mnc20/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 153600 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc418-mnc30/mms_config.xml b/res/xml-mcc418-mnc30/mms_config.xml new file mode 100644 index 0000000..9ef0dd8 --- /dev/null +++ b/res/xml-mcc418-mnc30/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 153600 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc420-mnc04/mms_config.xml b/res/xml-mcc420-mnc04/mms_config.xml new file mode 100644 index 0000000..614ba8e --- /dev/null +++ b/res/xml-mcc420-mnc04/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 409600 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc440-mnc10/mms_config.xml b/res/xml-mcc440-mnc10/mms_config.xml new file mode 100644 index 0000000..486c468 --- /dev/null +++ b/res/xml-mcc440-mnc10/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 614400 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc440-mnc20/mms_config.xml b/res/xml-mcc440-mnc20/mms_config.xml new file mode 100644 index 0000000..7f383c4 --- /dev/null +++ b/res/xml-mcc440-mnc20/mms_config.xml @@ -0,0 +1,56 @@ + + + + + + + + + true + + + 307200 + + + 1944 + + + 2592 + + + 10000 + + + 1000 + + + 10 + + + 5000 + + + -1 + + + true + + + -1 + + + false + diff --git a/res/xml-mcc450-mnc00/mms_config.xml b/res/xml-mcc450-mnc00/mms_config.xml new file mode 100644 index 0000000..96e5b9d --- /dev/null +++ b/res/xml-mcc450-mnc00/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc450-mnc02/mms_config.xml b/res/xml-mcc450-mnc02/mms_config.xml new file mode 100644 index 0000000..2a7231c --- /dev/null +++ b/res/xml-mcc450-mnc02/mms_config.xml @@ -0,0 +1,39 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + + false + + false + + false + diff --git a/res/xml-mcc450-mnc05/mms_config.xml b/res/xml-mcc450-mnc05/mms_config.xml new file mode 100644 index 0000000..2a7231c --- /dev/null +++ b/res/xml-mcc450-mnc05/mms_config.xml @@ -0,0 +1,39 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + + false + + false + + false + diff --git a/res/xml-mcc450-mnc06/mms_config.xml b/res/xml-mcc450-mnc06/mms_config.xml new file mode 100644 index 0000000..5123984 --- /dev/null +++ b/res/xml-mcc450-mnc06/mms_config.xml @@ -0,0 +1,42 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + + + 80 + + false + + false + + false + diff --git a/res/xml-mcc450-mnc08/mms_config.xml b/res/xml-mcc450-mnc08/mms_config.xml new file mode 100644 index 0000000..2a7231c --- /dev/null +++ b/res/xml-mcc450-mnc08/mms_config.xml @@ -0,0 +1,39 @@ + + + + + + + + 1048576 + + + 1944 + + + 2592 + + false + + false + + false + diff --git a/res/xml-mcc505-mnc01/mms_config.xml b/res/xml-mcc505-mnc01/mms_config.xml new file mode 100644 index 0000000..6a27809 --- /dev/null +++ b/res/xml-mcc505-mnc01/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 2097152 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc530-mnc05/mms_config.xml b/res/xml-mcc530-mnc05/mms_config.xml new file mode 100644 index 0000000..fabcf3c --- /dev/null +++ b/res/xml-mcc530-mnc05/mms_config.xml @@ -0,0 +1,36 @@ + + + + + + + + 614400 + + + 1944 + + + 2592 + + + false + diff --git a/res/xml-mcc604-mnc00/mms_config.xml b/res/xml-mcc604-mnc00/mms_config.xml new file mode 100644 index 0000000..03ff16c --- /dev/null +++ b/res/xml-mcc604-mnc00/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 102400 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc604-mnc02/mms_config.xml b/res/xml-mcc604-mnc02/mms_config.xml new file mode 100644 index 0000000..03ff16c --- /dev/null +++ b/res/xml-mcc604-mnc02/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 102400 + + + 1944 + + + 2592 + diff --git a/res/xml-mcc647-mnc10/mms_config.xml b/res/xml-mcc647-mnc10/mms_config.xml new file mode 100644 index 0000000..486c468 --- /dev/null +++ b/res/xml-mcc647-mnc10/mms_config.xml @@ -0,0 +1,33 @@ + + + + + + + + 614400 + + + 1944 + + + 2592 + diff --git a/res/xml-v21/preferences_application.xml b/res/xml-v21/preferences_application.xml new file mode 100644 index 0000000..5d8ee4c --- /dev/null +++ b/res/xml-v21/preferences_application.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/xml-v21/preferences_per_subscription.xml b/res/xml-v21/preferences_per_subscription.xml new file mode 100644 index 0000000..d0ae3e6 --- /dev/null +++ b/res/xml-v21/preferences_per_subscription.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/xml-v23/preferences_application.xml b/res/xml-v23/preferences_application.xml new file mode 100644 index 0000000..8fbadc4 --- /dev/null +++ b/res/xml-v23/preferences_application.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/xml/apn_editor.xml b/res/xml/apn_editor.xml new file mode 100644 index 0000000..5afaeb5 --- /dev/null +++ b/res/xml/apn_editor.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + diff --git a/res/xml/apn_settings.xml b/res/xml/apn_settings.xml new file mode 100644 index 0000000..61a8b5b --- /dev/null +++ b/res/xml/apn_settings.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/res/xml/apns.xml b/res/xml/apns.xml new file mode 100644 index 0000000..57e1163 --- /dev/null +++ b/res/xml/apns.xml @@ -0,0 +1,3284 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/xml/mms_config.xml b/res/xml/mms_config.xml new file mode 100644 index 0000000..e5a9338 --- /dev/null +++ b/res/xml/mms_config.xml @@ -0,0 +1,75 @@ + + + + + + + + true + + + 307200 + + + 480 + + + 640 + + + -1 + + + true + + + -1 + + + -1 + + + + + + + + + + + diff --git a/res/xml/preferences_application.xml b/res/xml/preferences_application.xml new file mode 100644 index 0000000..7a18d09 --- /dev/null +++ b/res/xml/preferences_application.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/xml/preferences_per_subscription.xml b/res/xml/preferences_per_subscription.xml new file mode 100644 index 0000000..5e00086 --- /dev/null +++ b/res/xml/preferences_per_subscription.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/xml/widget_conversation.xml b/res/xml/widget_conversation.xml new file mode 100644 index 0000000..d1a573b --- /dev/null +++ b/res/xml/widget_conversation.xml @@ -0,0 +1,29 @@ + + + + + diff --git a/res/xml/widget_conversation_list.xml b/res/xml/widget_conversation_list.xml new file mode 100644 index 0000000..b294acb --- /dev/null +++ b/res/xml/widget_conversation_list.xml @@ -0,0 +1,28 @@ + + + + + diff --git a/src/android/support/v7/mms/ApnException.java b/src/android/support/v7/mms/ApnException.java new file mode 100644 index 0000000..075d92d --- /dev/null +++ b/src/android/support/v7/mms/ApnException.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +/** + * APN exception + */ +public class ApnException extends Exception { + + public ApnException() { + super(); + } + + public ApnException(String message) { + super(message); + } + + public ApnException(Throwable cause) { + super(cause); + } + + public ApnException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/android/support/v7/mms/ApnSettingsLoader.java b/src/android/support/v7/mms/ApnSettingsLoader.java new file mode 100644 index 0000000..ff8ed12 --- /dev/null +++ b/src/android/support/v7/mms/ApnSettingsLoader.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +import java.util.List; + +/** + * Interface for loading APNs for default SMS SIM + */ +public interface ApnSettingsLoader { + /** + * Interface to represent the minimal information MMS lib needs from an APN + */ + interface Apn { + /** + * Get the MMSC URL string + * + * @return MMSC URL + */ + String getMmsc(); + + /** + * Get the MMS proxy host address + * + * @return MMS proxy + */ + String getMmsProxy(); + + /** + * Get the MMS proxy host port + * + * @return the port of MMS proxy + */ + int getMmsProxyPort(); + + /** + * Flag the APN as a successful APN to use + */ + void setSuccess(); + } + + /** + * Get a list possible APN matching the subId and APN name + * + * @param apnName the APN name + * @return a list of possible APNs + */ + List get(String apnName); +} diff --git a/src/android/support/v7/mms/ApnsXmlParser.java b/src/android/support/v7/mms/ApnsXmlParser.java new file mode 100644 index 0000000..eeafcf6 --- /dev/null +++ b/src/android/support/v7/mms/ApnsXmlParser.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +import android.content.ContentValues; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; + +/** + * Parser for built-in XML resource file for APN list + */ +class ApnsXmlParser extends MmsXmlResourceParser { + interface ApnProcessor { + void process(ContentValues apnValues); + } + + private static final String TAG_APNS = "apns"; + private static final String TAG_APN = "apn"; + + private final ApnProcessor mApnProcessor; + + private final ContentValues mValues = new ContentValues(); + + ApnsXmlParser(final XmlPullParser parser, final ApnProcessor apnProcessor) { + super(parser); + mApnProcessor = apnProcessor; + } + + // Parse one APN + @Override + protected void parseRecord() throws IOException, XmlPullParserException { + if (TAG_APN.equals(mInputParser.getName())) { + mValues.clear(); + // Collect all the attributes + for (int i = 0; i < mInputParser.getAttributeCount(); i++) { + final String key = mInputParser.getAttributeName(i); + if (key != null) { + mValues.put(key, mInputParser.getAttributeValue(i)); + } + } + // We are done parsing one APN, call the handler + if (mApnProcessor != null) { + mApnProcessor.process(mValues); + } + } + // We are at the end tag + if (mInputParser.next() != XmlPullParser.END_TAG) { + throw new XmlPullParserException("Expecting end tag @" + xmlParserDebugContext()); + } + } + + @Override + protected String getRootTag() { + return TAG_APNS; + } +} diff --git a/src/android/support/v7/mms/CarrierConfigValuesLoader.java b/src/android/support/v7/mms/CarrierConfigValuesLoader.java new file mode 100644 index 0000000..ad94fb2 --- /dev/null +++ b/src/android/support/v7/mms/CarrierConfigValuesLoader.java @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +import android.os.Bundle; + +/** + * Loader for carrier dependent configuration values + */ +public interface CarrierConfigValuesLoader { + /** + * Get the carrier config values in a bundle + * + * @param subId the associated subscription ID for the carrier configuration + * @return a bundle of all the values + */ + Bundle get(int subId); + + // Configuration keys and default values + + /** Boolean value: if MMS is enabled */ + public static final String CONFIG_ENABLED_MMS = "enabledMMS"; + public static final boolean CONFIG_ENABLED_MMS_DEFAULT = true; + /** + * Boolean value: if transaction ID should be appended to + * the download URL of a single segment WAP push message + */ + public static final String CONFIG_ENABLED_TRANS_ID = "enabledTransID"; + public static final boolean CONFIG_ENABLED_TRANS_ID_DEFAULT = false; + /** + * Boolean value: if acknowledge or notify response to a download + * should be sent to the WAP push message's download URL + */ + public static final String CONFIG_ENABLED_NOTIFY_WAP_MMSC = "enabledNotifyWapMMSC"; + public static final boolean CONFIG_ENABLED_NOTIFY_WAP_MMSC_DEFAULT = false; + /** + * Boolean value: if phone number alias can be used + */ + public static final String CONFIG_ALIAS_ENABLED = "aliasEnabled"; + public static final boolean CONFIG_ALIAS_ENABLED_DEFAULT = false; + /** + * Boolean value: if audio is allowed in attachment + */ + public static final String CONFIG_ALLOW_ATTACH_AUDIO = "allowAttachAudio"; + public static final boolean CONFIG_ALLOW_ATTACH_AUDIO_DEFAULT = true; + /** + * Boolean value: if true, long sms messages are always sent as multi-part sms + * messages, with no checked limit on the number of segments. If false, then + * as soon as the user types a message longer than a single segment (i.e. 140 chars), + * the message will turn into and be sent as an mms message or separate, + * independent SMS messages (dependent on CONFIG_SEND_MULTIPART_SMS_AS_SEPARATE_MESSAGES flag). + * This feature exists for carriers that don't support multi-part sms. + */ + public static final String CONFIG_ENABLE_MULTIPART_SMS = "enableMultipartSMS"; + public static final boolean CONFIG_ENABLE_MULTIPART_SMS_DEFAULT = true; + /** + * Boolean value: if SMS delivery report is supported + */ + public static final String CONFIG_ENABLE_SMS_DELIVERY_REPORTS = "enableSMSDeliveryReports"; + public static final boolean CONFIG_ENABLE_SMS_DELIVERY_REPORTS_DEFAULT = true; + /** + * Boolean value: if group MMS is supported + */ + public static final String CONFIG_ENABLE_GROUP_MMS = "enableGroupMms"; + public static final boolean CONFIG_ENABLE_GROUP_MMS_DEFAULT = true; + /** + * Boolean value: if the content_disposition field of an MMS part should be parsed + * Check wap-230-wsp-20010705-a.pdf, chapter 8.4.2.21. Most carriers support it except some. + */ + public static final String CONFIG_SUPPORT_MMS_CONTENT_DISPOSITION = + "supportMmsContentDisposition"; + public static final boolean CONFIG_SUPPORT_MMS_CONTENT_DISPOSITION_DEFAULT = true; + /** + * Boolean value: if the sms app should support a link to the system settings + * where amber alerts are configured. + */ + public static final String CONFIG_CELL_BROADCAST_APP_LINKS = "config_cellBroadcastAppLinks"; + public static final boolean CONFIG_CELL_BROADCAST_APP_LINKS_DEFAULT = true; + /** + * Boolean value: if multipart SMS should be sent as separate SMS messages + */ + public static final String CONFIG_SEND_MULTIPART_SMS_AS_SEPARATE_MESSAGES = + "sendMultipartSmsAsSeparateMessages"; + public static final boolean CONFIG_SEND_MULTIPART_SMS_AS_SEPARATE_MESSAGES_DEFAULT = false; + /** + * Boolean value: if MMS read report is supported + */ + public static final String CONFIG_ENABLE_MMS_READ_REPORTS = "enableMMSReadReports"; + public static final boolean CONFIG_ENABLE_MMS_READ_REPORTS_DEFAULT = false; + /** + * Boolean value: if MMS delivery report is supported + */ + public static final String CONFIG_ENABLE_MMS_DELIVERY_REPORTS = "enableMMSDeliveryReports"; + public static final boolean CONFIG_ENABLE_MMS_DELIVERY_REPORTS_DEFAULT = false; + /** + * Boolean value: if "charset" value is supported in the "Content-Type" HTTP header + */ + public static final String CONFIG_SUPPORT_HTTP_CHARSET_HEADER = "supportHttpCharsetHeader"; + public static final boolean CONFIG_SUPPORT_HTTP_CHARSET_HEADER_DEFAULT = false; + /** + * Integer value: maximal MMS message size in bytes + */ + public static final String CONFIG_MAX_MESSAGE_SIZE = "maxMessageSize"; + public static final int CONFIG_MAX_MESSAGE_SIZE_DEFAULT = 300 * 1024; + /** + * Integer value: maximal MMS image height in pixels + */ + public static final String CONFIG_MAX_IMAGE_HEIGHT = "maxImageHeight"; + public static final int CONFIG_MAX_IMAGE_HEIGHT_DEFAULT = 480; + /** + * Integer value: maximal MMS image width in pixels + */ + public static final String CONFIG_MAX_IMAGE_WIDTH = "maxImageWidth"; + public static final int CONFIG_MAX_IMAGE_WIDTH_DEFAULT = 640; + /** + * Integer value: limit on recipient list of an MMS message + */ + public static final String CONFIG_RECIPIENT_LIMIT = "recipientLimit"; + public static final int CONFIG_RECIPIENT_LIMIT_DEFAULT = Integer.MAX_VALUE; + /** + * Integer value: HTTP socket timeout in milliseconds for MMS + */ + public static final String CONFIG_HTTP_SOCKET_TIMEOUT = "httpSocketTimeout"; + public static final int CONFIG_HTTP_SOCKET_TIMEOUT_DEFAULT = 60 * 1000; + /** + * Integer value: minimal number of characters of an alias + */ + public static final String CONFIG_ALIAS_MIN_CHARS = "aliasMinChars"; + public static final int CONFIG_ALIAS_MIN_CHARS_DEFAULT = 2; + /** + * Integer value: maximal number of characters of an alias + */ + public static final String CONFIG_ALIAS_MAX_CHARS = "aliasMaxChars"; + public static final int CONFIG_ALIAS_MAX_CHARS_DEFAULT = 48; + /** + * Integer value: the threshold of number of SMS parts when an multipart SMS will be + * converted into an MMS, e.g. if this is "4", when an multipart SMS message has 5 + * parts, then it will be sent as MMS message instead. "-1" indicates no such conversion + * can happen. + */ + public static final String CONFIG_SMS_TO_MMS_TEXT_THRESHOLD = "smsToMmsTextThreshold"; + public static final int CONFIG_SMS_TO_MMS_TEXT_THRESHOLD_DEFAULT = -1; + /** + * Integer value: the threshold of SMS length when it will be converted into an MMS. + * "-1" indicates no such conversion can happen. + */ + public static final String CONFIG_SMS_TO_MMS_TEXT_LENGTH_THRESHOLD = + "smsToMmsTextLengthThreshold"; + public static final int CONFIG_SMS_TO_MMS_TEXT_LENGTH_THRESHOLD_DEFAULT = -1; + /** + * Integer value: maximal length in bytes of SMS message + */ + public static final String CONFIG_MAX_MESSAGE_TEXT_SIZE = "maxMessageTextSize"; + public static final int CONFIG_MAX_MESSAGE_TEXT_SIZE_DEFAULT = -1; + /** + * Integer value: maximum number of characters allowed for mms subject + */ + public static final String CONFIG_MAX_SUBJECT_LENGTH = "maxSubjectLength"; + public static final int CONFIG_MAX_SUBJECT_LENGTH_DEFAULT = 40; + /** + * String value: name for the user agent profile HTTP header + */ + public static final String CONFIG_UA_PROF_TAG_NAME = "mUaProfTagName"; + public static final String CONFIG_UA_PROF_TAG_NAME_DEFAULT = "x-wap-profile"; + /** + * String value: additional HTTP headers for MMS HTTP requests. + * The format is + * header_1:header_value_1|header_2:header_value_2|... + * Each value can contain macros. + */ + public static final String CONFIG_HTTP_PARAMS = "httpParams"; + public static final String CONFIG_HTTP_PARAMS_DEFAULT = null; + /** + * String value: number of email gateway + */ + public static final String CONFIG_EMAIL_GATEWAY_NUMBER = "emailGatewayNumber"; + public static final String CONFIG_EMAIL_GATEWAY_NUMBER_DEFAULT = null; + /** + * String value: suffix for the NAI HTTP header value, e.g. ":pcs" + * (NAI is used as authentication in HTTP headers for some carriers) + */ + public static final String CONFIG_NAI_SUFFIX = "naiSuffix"; + public static final String CONFIG_NAI_SUFFIX_DEFAULT = null; +} diff --git a/src/android/support/v7/mms/CarrierConfigXmlParser.java b/src/android/support/v7/mms/CarrierConfigXmlParser.java new file mode 100644 index 0000000..42e7c21 --- /dev/null +++ b/src/android/support/v7/mms/CarrierConfigXmlParser.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; + +/** + * XML parser for carrier config (i.e. mms_config) + */ +class CarrierConfigXmlParser extends MmsXmlResourceParser { + interface KeyValueProcessor { + void process(String type, String key, String value); + } + + private static final String TAG_MMS_CONFIG = "mms_config"; + + private final KeyValueProcessor mKeyValueProcessor; + + CarrierConfigXmlParser(final XmlPullParser parser, final KeyValueProcessor keyValueProcessor) { + super(parser); + mKeyValueProcessor = keyValueProcessor; + } + + // Parse one key/value + @Override + protected void parseRecord() throws IOException, XmlPullParserException { + final String key = mInputParser.getAttributeValue(null, "name"); + // We are at the start tag, the name of the tag is the type + // e.g. value + final String type = mInputParser.getName(); + int nextEvent = mInputParser.next(); + String value = null; + if (nextEvent == XmlPullParser.TEXT) { + value = mInputParser.getText(); + nextEvent = mInputParser.next(); + } + if (nextEvent != XmlPullParser.END_TAG) { + throw new XmlPullParserException("Expecting end tag @" + xmlParserDebugContext()); + } + // We are done parsing one mms_config key/value, call the handler + if (mKeyValueProcessor != null) { + mKeyValueProcessor.process(type, key, value); + } + } + + @Override + protected String getRootTag() { + return TAG_MMS_CONFIG; + } + +} diff --git a/src/android/support/v7/mms/DefaultApnSettingsLoader.java b/src/android/support/v7/mms/DefaultApnSettingsLoader.java new file mode 100644 index 0000000..83f2fd3 --- /dev/null +++ b/src/android/support/v7/mms/DefaultApnSettingsLoader.java @@ -0,0 +1,497 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +import android.content.ContentValues; +import android.content.Context; +import android.content.res.Resources; +import android.content.res.XmlResourceParser; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import android.net.Uri; +import android.provider.Telephony; +import android.text.TextUtils; +import android.util.Log; +import android.util.SparseArray; + +import com.android.messaging.R; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; + +/** + * Default implementation of APN settings loader + */ +class DefaultApnSettingsLoader implements ApnSettingsLoader { + /** + * The base implementation of an APN + */ + private static class BaseApn implements Apn { + /** + * Create a base APN from parameters + * + * @param typesIn the APN type field + * @param mmscIn the APN mmsc field + * @param proxyIn the APN mmsproxy field + * @param portIn the APN mmsport field + * @return an instance of base APN, or null if any of the parameter is invalid + */ + public static BaseApn from(final String typesIn, final String mmscIn, final String proxyIn, + final String portIn) { + if (!isValidApnType(trimWithNullCheck(typesIn), APN_TYPE_MMS)) { + return null; + } + String mmsc = trimWithNullCheck(mmscIn); + if (TextUtils.isEmpty(mmsc)) { + return null; + } + mmsc = trimV4AddrZeros(mmsc); + try { + new URI(mmsc); + } catch (final URISyntaxException e) { + return null; + } + String mmsProxy = trimWithNullCheck(proxyIn); + int mmsProxyPort = 80; + if (!TextUtils.isEmpty(mmsProxy)) { + mmsProxy = trimV4AddrZeros(mmsProxy); + final String portString = trimWithNullCheck(portIn); + if (portString != null) { + try { + mmsProxyPort = Integer.parseInt(portString); + } catch (final NumberFormatException e) { + // Ignore, just use 80 to try + } + } + } + return new BaseApn(mmsc, mmsProxy, mmsProxyPort); + } + + private final String mMmsc; + private final String mMmsProxy; + private final int mMmsProxyPort; + + public BaseApn(final String mmsc, final String proxy, final int port) { + mMmsc = mmsc; + mMmsProxy = proxy; + mMmsProxyPort = port; + } + + @Override + public String getMmsc() { + return mMmsc; + } + + @Override + public String getMmsProxy() { + return mMmsProxy; + } + + @Override + public int getMmsProxyPort() { + return mMmsProxyPort; + } + + @Override + public void setSuccess() { + // Do nothing + } + + public boolean equals(final BaseApn other) { + return TextUtils.equals(mMmsc, other.getMmsc()) && + TextUtils.equals(mMmsProxy, other.getMmsProxy()) && + mMmsProxyPort == other.getMmsProxyPort(); + } + } + + /** + * An in-memory implementation of an APN. These APNs are organized into an in-memory list. + * The order of the list can be changed by the setSuccess method. + */ + private static class MemoryApn implements Apn { + /** + * Create an in-memory APN loaded from resources + * + * @param apns the in-memory APN list + * @param typesIn the APN type field + * @param mmscIn the APN mmsc field + * @param proxyIn the APN mmsproxy field + * @param portIn the APN mmsport field + * @return an in-memory APN instance, null if there is invalid parameter + */ + public static MemoryApn from(final List apns, final String typesIn, + final String mmscIn, final String proxyIn, final String portIn) { + if (apns == null) { + return null; + } + final BaseApn base = BaseApn.from(typesIn, mmscIn, proxyIn, portIn); + if (base == null) { + return null; + } + for (final Apn apn : apns) { + if (apn instanceof MemoryApn && ((MemoryApn) apn).equals(base)) { + return null; + } + } + return new MemoryApn(apns, base); + } + + private final List mApns; + private final BaseApn mBase; + + public MemoryApn(final List apns, final BaseApn base) { + mApns = apns; + mBase = base; + } + + @Override + public String getMmsc() { + return mBase.getMmsc(); + } + + @Override + public String getMmsProxy() { + return mBase.getMmsProxy(); + } + + @Override + public int getMmsProxyPort() { + return mBase.getMmsProxyPort(); + } + + @Override + public void setSuccess() { + // If this is being marked as a successful APN, move it to the top of the list so + // next time it will be tried first + boolean moved = false; + synchronized (mApns) { + if (mApns.get(0) != this) { + mApns.remove(this); + mApns.add(0, this); + moved = true; + } + } + if (moved) { + Log.d(MmsService.TAG, "Set APN [" + + "MMSC=" + getMmsc() + ", " + + "PROXY=" + getMmsProxy() + ", " + + "PORT=" + getMmsProxyPort() + "] to be first"); + } + } + + public boolean equals(final BaseApn other) { + if (other == null) { + return false; + } + return mBase.equals(other); + } + } + + /** + * APN_TYPE_ALL is a special type to indicate that this APN entry can + * service all data connections. + */ + public static final String APN_TYPE_ALL = "*"; + /** APN type for MMS traffic */ + public static final String APN_TYPE_MMS = "mms"; + + private static final String[] APN_PROJECTION = { + Telephony.Carriers.TYPE, + Telephony.Carriers.MMSC, + Telephony.Carriers.MMSPROXY, + Telephony.Carriers.MMSPORT, + }; + private static final int COLUMN_TYPE = 0; + private static final int COLUMN_MMSC = 1; + private static final int COLUMN_MMSPROXY = 2; + private static final int COLUMN_MMSPORT = 3; + + private static final String APN_MCC = "mcc"; + private static final String APN_MNC = "mnc"; + private static final String APN_APN = "apn"; + private static final String APN_TYPE = "type"; + private static final String APN_MMSC = "mmsc"; + private static final String APN_MMSPROXY = "mmsproxy"; + private static final String APN_MMSPORT = "mmsport"; + + private final Context mContext; + + // Cached APNs for subIds + private final SparseArray> mApnsCache; + + DefaultApnSettingsLoader(final Context context) { + mContext = context; + mApnsCache = new SparseArray<>(); + } + + @Override + public List get(final String apnName) { + final int subId = Utils.getEffectiveSubscriptionId(MmsManager.DEFAULT_SUB_ID); + List apns; + boolean didLoad = false; + synchronized (this) { + apns = mApnsCache.get(subId); + if (apns == null) { + apns = new ArrayList<>(); + mApnsCache.put(subId, apns); + loadLocked(subId, apnName, apns); + didLoad = true; + } + } + if (didLoad) { + Log.i(MmsService.TAG, "Loaded " + apns.size() + " APNs"); + } + return apns; + } + + private void loadLocked(final int subId, final String apnName, final List apns) { + // Try system APN table first + loadFromSystem(subId, apnName, apns); + if (apns.size() > 0) { + return; + } + // Try loading from apns.xml in resources + loadFromResources(subId, apnName, apns); + if (apns.size() > 0) { + return; + } + // Try resources but without APN name + loadFromResources(subId, null/*apnName*/, apns); + } + + /** + * Load matching APNs from telephony provider. + * We try different combinations of the query to work around some platform quirks. + * + * @param subId the SIM subId + * @param apnName the APN name to match + * @param apns the list used to return results + */ + private void loadFromSystem(final int subId, final String apnName, final List apns) { + Uri uri; + if (Utils.supportMSim() && subId != MmsManager.DEFAULT_SUB_ID) { + uri = Uri.withAppendedPath(Telephony.Carriers.CONTENT_URI, "/subId/" + subId); + } else { + uri = Telephony.Carriers.CONTENT_URI; + } + Cursor cursor = null; + try { + for (; ; ) { + // Try different combinations of queries. Some would work on some platforms. + // So we query each combination until we find one returns non-empty result. + cursor = querySystem(uri, true/*checkCurrent*/, apnName); + if (cursor != null) { + break; + } + cursor = querySystem(uri, false/*checkCurrent*/, apnName); + if (cursor != null) { + break; + } + cursor = querySystem(uri, true/*checkCurrent*/, null/*apnName*/); + if (cursor != null) { + break; + } + cursor = querySystem(uri, false/*checkCurrent*/, null/*apnName*/); + break; + } + } catch (final SecurityException e) { + // Can't access platform APN table, return directly + return; + } + if (cursor == null) { + return; + } + try { + if (cursor.moveToFirst()) { + final Apn apn = BaseApn.from( + cursor.getString(COLUMN_TYPE), + cursor.getString(COLUMN_MMSC), + cursor.getString(COLUMN_MMSPROXY), + cursor.getString(COLUMN_MMSPORT)); + if (apn != null) { + apns.add(apn); + } + } + } finally { + cursor.close(); + } + } + + /** + * Query system APN table + * + * @param uri The APN query URL to use + * @param checkCurrent If add "CURRENT IS NOT NULL" condition + * @param apnName The optional APN name for query condition + * @return A cursor of the query result. If a cursor is returned as not null, it is + * guaranteed to contain at least one row. + */ + private Cursor querySystem(final Uri uri, final boolean checkCurrent, String apnName) { + Log.i(MmsService.TAG, "Loading APNs from system, " + + "checkCurrent=" + checkCurrent + " apnName=" + apnName); + final StringBuilder selectionBuilder = new StringBuilder(); + String[] selectionArgs = null; + if (checkCurrent) { + selectionBuilder.append(Telephony.Carriers.CURRENT).append(" IS NOT NULL"); + } + apnName = trimWithNullCheck(apnName); + if (!TextUtils.isEmpty(apnName)) { + if (selectionBuilder.length() > 0) { + selectionBuilder.append(" AND "); + } + selectionBuilder.append(Telephony.Carriers.APN).append("=?"); + selectionArgs = new String[] { apnName }; + } + try { + final Cursor cursor = mContext.getContentResolver().query( + uri, + APN_PROJECTION, + selectionBuilder.toString(), + selectionArgs, + null/*sortOrder*/); + if (cursor == null || cursor.getCount() < 1) { + if (cursor != null) { + cursor.close(); + } + Log.w(MmsService.TAG, "Query " + uri + " with apn " + apnName + " and " + + (checkCurrent ? "checking CURRENT" : "not checking CURRENT") + + " returned empty"); + return null; + } + return cursor; + } catch (final SQLiteException e) { + Log.w(MmsService.TAG, "APN table query exception: " + e); + } catch (final SecurityException e) { + Log.w(MmsService.TAG, "Platform restricts APN table access: " + e); + throw e; + } + return null; + } + + /** + * Find matching APNs using builtin APN list resource + * + * @param subId the SIM subId + * @param apnName the APN name to match + * @param apns the list for returning results + */ + private void loadFromResources(final int subId, final String apnName, final List apns) { + Log.i(MmsService.TAG, "Loading APNs from resources, apnName=" + apnName); + final int[] mccMnc = Utils.getMccMnc(mContext, subId); + if (mccMnc[0] == 0 && mccMnc[0] == 0) { + Log.w(MmsService.TAG, "Can not get valid mcc/mnc from system"); + return; + } + // MCC/MNC is good, loading/querying APNs from XML + XmlResourceParser xml = null; + try { + xml = mContext.getResources().getXml(R.xml.apns); + new ApnsXmlParser(xml, new ApnsXmlParser.ApnProcessor() { + @Override + public void process(ContentValues apnValues) { + final String mcc = trimWithNullCheck(apnValues.getAsString(APN_MCC)); + final String mnc = trimWithNullCheck(apnValues.getAsString(APN_MNC)); + final String apn = trimWithNullCheck(apnValues.getAsString(APN_APN)); + try { + if (mccMnc[0] == Integer.parseInt(mcc) && + mccMnc[1] == Integer.parseInt(mnc) && + (TextUtils.isEmpty(apnName) || apnName.equalsIgnoreCase(apn))) { + final String type = apnValues.getAsString(APN_TYPE); + final String mmsc = apnValues.getAsString(APN_MMSC); + final String mmsproxy = apnValues.getAsString(APN_MMSPROXY); + final String mmsport = apnValues.getAsString(APN_MMSPORT); + final Apn newApn = MemoryApn.from(apns, type, mmsc, mmsproxy, mmsport); + if (newApn != null) { + apns.add(newApn); + } + } + } catch (final NumberFormatException e) { + // Ignore + } + } + }).parse(); + } catch (final Resources.NotFoundException e) { + Log.w(MmsService.TAG, "Can not get apns.xml " + e); + } finally { + if (xml != null) { + xml.close(); + } + } + } + + private static String trimWithNullCheck(final String value) { + return value != null ? value.trim() : null; + } + + /** + * Trim leading zeros from IPv4 address strings + * Our base libraries will interpret that as octel.. + * Must leave non v4 addresses and host names alone. + * For example, 192.168.000.010 -> 192.168.0.10 + * + * @param addr a string representing an ip addr + * @return a string propertly trimmed + */ + private static String trimV4AddrZeros(final String addr) { + if (addr == null) { + return null; + } + final String[] octets = addr.split("\\."); + if (octets.length != 4) { + return addr; + } + final StringBuilder builder = new StringBuilder(16); + String result = null; + for (int i = 0; i < 4; i++) { + try { + if (octets[i].length() > 3) { + return addr; + } + builder.append(Integer.parseInt(octets[i])); + } catch (final NumberFormatException e) { + return addr; + } + if (i < 3) { + builder.append('.'); + } + } + result = builder.toString(); + return result; + } + + /** + * Check if the APN contains the APN type we want + * + * @param types The string encodes a list of supported types + * @param requestType The type we want + * @return true if the input types string contains the requestType + */ + public static boolean isValidApnType(final String types, final String requestType) { + // If APN type is unspecified, assume APN_TYPE_ALL. + if (TextUtils.isEmpty(types)) { + return true; + } + for (final String t : types.split(",")) { + if (t.equals(requestType) || t.equals(APN_TYPE_ALL)) { + return true; + } + } + return false; + } +} diff --git a/src/android/support/v7/mms/DefaultCarrierConfigValuesLoader.java b/src/android/support/v7/mms/DefaultCarrierConfigValuesLoader.java new file mode 100644 index 0000000..1d45f8f --- /dev/null +++ b/src/android/support/v7/mms/DefaultCarrierConfigValuesLoader.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.XmlResourceParser; +import android.os.Bundle; +import android.util.Log; +import android.util.SparseArray; + +import com.android.messaging.R; + +/** + * The default implementation of loader for carrier config values + */ +class DefaultCarrierConfigValuesLoader implements CarrierConfigValuesLoader { + /* + * Key types + */ + public static final String KEY_TYPE_INT = "int"; + public static final String KEY_TYPE_BOOL = "bool"; + public static final String KEY_TYPE_STRING = "string"; + + private final Context mContext; + + // Cached values for subIds + private final SparseArray mValuesCache; + + DefaultCarrierConfigValuesLoader(final Context context) { + mContext = context; + mValuesCache = new SparseArray<>(); + } + + @Override + public Bundle get(int subId) { + subId = Utils.getEffectiveSubscriptionId(subId); + Bundle values; + boolean didLoad = false; + synchronized (this) { + values = mValuesCache.get(subId); + if (values == null) { + values = new Bundle(); + mValuesCache.put(subId, values); + loadLocked(subId, values); + didLoad = true; + } + } + if (didLoad) { + Log.i(MmsService.TAG, "Carrier configs loaded: " + values); + } + return values; + } + + private void loadLocked(final int subId, final Bundle values) { + // For K and earlier, load from resources + loadFromResources(subId, values); + if (Utils.hasMmsApi()) { + // For L and later, also load from system MMS service + loadFromSystem(subId, values); + } + } + + /** + * Load from system, using MMS API + * + * @param subId which SIM to load for + * @param values the result to add to + */ + private static void loadFromSystem(final int subId, final Bundle values) { + try { + final Bundle systemValues = Utils.getSmsManager(subId).getCarrierConfigValues(); + if (systemValues != null) { + values.putAll(systemValues); + } + } catch (final Exception e) { + Log.w(MmsService.TAG, "Calling system getCarrierConfigValues exception", e); + } + } + + private void loadFromResources(final int subId, final Bundle values) { + // Get a subscription-dependent context for loading the mms_config.xml + final Context subContext = Utils.getSubDepContext(mContext, subId); + XmlResourceParser xml = null; + try { + xml = subContext.getResources().getXml(R.xml.mms_config); + new CarrierConfigXmlParser(xml, new CarrierConfigXmlParser.KeyValueProcessor() { + @Override + public void process(String type, String key, String value) { + try { + if (KEY_TYPE_INT.equals(type)) { + values.putInt(key, Integer.parseInt(value)); + } else if (KEY_TYPE_BOOL.equals(type)) { + values.putBoolean(key, Boolean.parseBoolean(value)); + } else if (KEY_TYPE_STRING.equals(type)) { + values.putString(key, value); + } + } catch (final NumberFormatException e) { + Log.w(MmsService.TAG, "Load carrier value from resources: " + + "invalid " + key + "," + value + "," + type); + } + } + }).parse(); + } catch (final Resources.NotFoundException e) { + Log.w(MmsService.TAG, "Can not get mms_config.xml"); + } finally { + if (xml != null) { + xml.close(); + } + } + } +} diff --git a/src/android/support/v7/mms/DefaultUserAgentInfoLoader.java b/src/android/support/v7/mms/DefaultUserAgentInfoLoader.java new file mode 100644 index 0000000..e59a234 --- /dev/null +++ b/src/android/support/v7/mms/DefaultUserAgentInfoLoader.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +import android.content.Context; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import android.util.Log; + +/** + * The default implementation of loader of UA and UAProfUrl + */ +class DefaultUserAgentInfoLoader implements UserAgentInfoLoader { + // Default values to be used as user agent info + private static final String DEFAULT_USER_AGENT = "Android MmsLib/1.0"; + private static final String DEFAULT_UA_PROF_URL = + "http://www.gstatic.com/android/sms/mms_ua_profile.xml"; + + private Context mContext; + private boolean mLoaded; + + private String mUserAgent; + private String mUAProfUrl; + + DefaultUserAgentInfoLoader(final Context context) { + mContext = context; + } + + @Override + public String getUserAgent() { + load(); + return mUserAgent; + } + + @Override + public String getUAProfUrl() { + load(); + return mUAProfUrl; + } + + private void load() { + if (mLoaded) { + return; + } + boolean didLoad = false; + synchronized (this) { + if (!mLoaded) { + loadLocked(); + mLoaded = true; + didLoad = true; + } + } + if (didLoad) { + Log.i(MmsService.TAG, "Loaded user agent info: " + + "UA=" + mUserAgent + ", UAProfUrl=" + mUAProfUrl); + } + } + + private void loadLocked() { + if (Utils.hasUserAgentApi()) { + // load the MMS User agent and UaProfUrl from TelephonyManager APIs + final TelephonyManager telephonyManager = (TelephonyManager) mContext.getSystemService( + Context.TELEPHONY_SERVICE); + mUserAgent = telephonyManager.getMmsUserAgent(); + mUAProfUrl = telephonyManager.getMmsUAProfUrl(); + } + if (TextUtils.isEmpty(mUserAgent)) { + mUserAgent = DEFAULT_USER_AGENT; + } + if (TextUtils.isEmpty(mUAProfUrl)) { + mUAProfUrl = DEFAULT_UA_PROF_URL; + } + } +} diff --git a/src/android/support/v7/mms/DownloadRequest.java b/src/android/support/v7/mms/DownloadRequest.java new file mode 100644 index 0000000..254c8ca --- /dev/null +++ b/src/android/support/v7/mms/DownloadRequest.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +import android.app.PendingIntent; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcel; +import android.os.ParcelFileDescriptor; +import android.os.Parcelable; +import android.text.TextUtils; +import android.util.Log; + +import java.io.IOException; +import java.util.concurrent.Callable; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +/** + * Request to download an MMS + */ +class DownloadRequest extends MmsRequest { + + DownloadRequest(final String locationUrl, final Uri pduUri, + final PendingIntent sentIntent) { + super(locationUrl, pduUri, sentIntent); + } + + @Override + protected boolean loadRequest(final Context context, final Bundle mmsConfig) { + // No need to load PDU from app. Always true. + return true; + } + + @Override + protected boolean transferResponse(Context context, Intent fillIn, byte[] response) { + return writePduToContentUri(context, mPduUri, response); + } + + @Override + protected byte[] doHttp(Context context, MmsNetworkManager netMgr, ApnSettingsLoader.Apn apn, + Bundle mmsConfig, String userAgent, String uaProfUrl) throws MmsHttpException { + final MmsHttpClient httpClient = netMgr.getHttpClient(); + return httpClient.execute(getHttpRequestUrl(apn), null/*pdu*/, MmsHttpClient.METHOD_GET, + !TextUtils.isEmpty(apn.getMmsProxy()), apn.getMmsProxy(), apn.getMmsProxyPort(), + mmsConfig, userAgent, uaProfUrl); + + } + + @Override + protected String getHttpRequestUrl(final ApnSettingsLoader.Apn apn) { + return mLocationUrl; + } + + /** + * Write pdu bytes to content provider uri + * + * @param contentUri content provider uri to which bytes should be written + * @param pdu Bytes to write + * @return true if all bytes successfully written else false + */ + public boolean writePduToContentUri(final Context context, final Uri contentUri, + final byte[] pdu) { + if (contentUri == null || pdu == null) { + return false; + } + final Callable copyDownloadedPduToOutput = new Callable() { + public Boolean call() { + ParcelFileDescriptor.AutoCloseOutputStream outStream = null; + try { + final ContentResolver cr = context.getContentResolver(); + final ParcelFileDescriptor pduFd = cr.openFileDescriptor(contentUri, "w"); + outStream = new ParcelFileDescriptor.AutoCloseOutputStream(pduFd); + outStream.write(pdu); + return true; + } catch (IOException e) { + Log.e(MmsService.TAG, "Writing PDU to downloader: IO exception", e); + return false; + } finally { + if (outStream != null) { + try { + outStream.close(); + } catch (IOException ex) { + // Ignore + } + } + } + } + }; + final Future pendingResult = + mPduTransferExecutor.submit(copyDownloadedPduToOutput); + try { + return pendingResult.get(TASK_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (Exception e) { + // Typically a timeout occurred - cancel task + pendingResult.cancel(true); + } + return false; + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + public DownloadRequest createFromParcel(Parcel in) { + return new DownloadRequest(in); + } + + public DownloadRequest[] newArray(int size) { + return new DownloadRequest[size]; + } + }; + + private DownloadRequest(Parcel in) { + super(in); + } +} diff --git a/src/android/support/v7/mms/MmsHttpClient.java b/src/android/support/v7/mms/MmsHttpClient.java new file mode 100644 index 0000000..8ca34da --- /dev/null +++ b/src/android/support/v7/mms/MmsHttpClient.java @@ -0,0 +1,523 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +import android.content.Context; +import android.os.Bundle; +import android.telephony.SmsManager; +import android.telephony.SubscriptionInfo; +import android.telephony.SubscriptionManager; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import android.util.Base64; +import android.util.Log; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.lang.reflect.Method; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.net.MalformedURLException; +import java.net.ProtocolException; +import java.net.Proxy; +import java.net.URL; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * MMS HTTP client for sending and downloading MMS messages + */ +public class MmsHttpClient { + static final String METHOD_POST = "POST"; + static final String METHOD_GET = "GET"; + + private static final String HEADER_CONTENT_TYPE = "Content-Type"; + private static final String HEADER_ACCEPT = "Accept"; + private static final String HEADER_ACCEPT_LANGUAGE = "Accept-Language"; + private static final String HEADER_USER_AGENT = "User-Agent"; + + // The "Accept" header value + private static final String HEADER_VALUE_ACCEPT = + "*/*, application/vnd.wap.mms-message, application/vnd.wap.sic"; + // The "Content-Type" header value + private static final String HEADER_VALUE_CONTENT_TYPE_WITH_CHARSET = + "application/vnd.wap.mms-message; charset=utf-8"; + private static final String HEADER_VALUE_CONTENT_TYPE_WITHOUT_CHARSET = + "application/vnd.wap.mms-message"; + + /* + * Macro names + */ + // The raw phone number + private static final String MACRO_LINE1 = "LINE1"; + // The phone number without country code + private static final String MACRO_LINE1NOCOUNTRYCODE = "LINE1NOCOUNTRYCODE"; + // NAI (Network Access Identifier) + private static final String MACRO_NAI = "NAI"; + + // The possible NAI system property name + private static final String NAI_PROPERTY = "persist.radio.cdma.nai"; + + private final Context mContext; + private final TelephonyManager mTelephonyManager; + + /** + * Constructor + * + * @param context The Context object + */ + MmsHttpClient(Context context) { + mContext = context; + mTelephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + } + + /** + * Execute an MMS HTTP request, either a POST (sending) or a GET (downloading) + * + * @param urlString The request URL, for sending it is usually the MMSC, and for downloading + * it is the message URL + * @param pdu For POST (sending) only, the PDU to send + * @param method HTTP method, POST for sending and GET for downloading + * @param isProxySet Is there a proxy for the MMSC + * @param proxyHost The proxy host + * @param proxyPort The proxy port + * @param mmsConfig The MMS config to use + * @param userAgent The user agent header value + * @param uaProfUrl The UA Prof URL header value + * @return The HTTP response body + * @throws MmsHttpException For any failures + */ + public byte[] execute(String urlString, byte[] pdu, String method, boolean isProxySet, + String proxyHost, int proxyPort, Bundle mmsConfig, String userAgent, String uaProfUrl) + throws MmsHttpException { + Log.d(MmsService.TAG, "HTTP: " + method + " " + Utils.redactUrlForNonVerbose(urlString) + + (isProxySet ? (", proxy=" + proxyHost + ":" + proxyPort) : "") + + ", PDU size=" + (pdu != null ? pdu.length : 0)); + checkMethod(method); + HttpURLConnection connection = null; + try { + Proxy proxy = Proxy.NO_PROXY; + if (isProxySet) { + proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)); + } + final URL url = new URL(urlString); + // Now get the connection + connection = (HttpURLConnection) url.openConnection(proxy); + connection.setDoInput(true); + connection.setConnectTimeout( + mmsConfig.getInt(CarrierConfigValuesLoader.CONFIG_HTTP_SOCKET_TIMEOUT, + CarrierConfigValuesLoader.CONFIG_HTTP_SOCKET_TIMEOUT_DEFAULT)); + // ------- COMMON HEADERS --------- + // Header: Accept + connection.setRequestProperty(HEADER_ACCEPT, HEADER_VALUE_ACCEPT); + // Header: Accept-Language + connection.setRequestProperty( + HEADER_ACCEPT_LANGUAGE, getCurrentAcceptLanguage(Locale.getDefault())); + // Header: User-Agent + Log.i(MmsService.TAG, "HTTP: User-Agent=" + userAgent); + connection.setRequestProperty(HEADER_USER_AGENT, userAgent); + // Header: x-wap-profile + final String uaProfUrlTagName = mmsConfig.getString( + CarrierConfigValuesLoader.CONFIG_UA_PROF_TAG_NAME, + CarrierConfigValuesLoader.CONFIG_UA_PROF_TAG_NAME_DEFAULT); + if (uaProfUrl != null) { + Log.i(MmsService.TAG, "HTTP: UaProfUrl=" + uaProfUrl); + connection.setRequestProperty(uaProfUrlTagName, uaProfUrl); + } + // Add extra headers specified by mms_config.xml's httpparams + addExtraHeaders(connection, mmsConfig); + // Different stuff for GET and POST + if (METHOD_POST.equals(method)) { + if (pdu == null || pdu.length < 1) { + Log.e(MmsService.TAG, "HTTP: empty pdu"); + throw new MmsHttpException(0/*statusCode*/, "Sending empty PDU"); + } + connection.setDoOutput(true); + connection.setRequestMethod(METHOD_POST); + if (mmsConfig.getBoolean( + CarrierConfigValuesLoader.CONFIG_SUPPORT_HTTP_CHARSET_HEADER, + CarrierConfigValuesLoader.CONFIG_SUPPORT_HTTP_CHARSET_HEADER_DEFAULT)) { + connection.setRequestProperty(HEADER_CONTENT_TYPE, + HEADER_VALUE_CONTENT_TYPE_WITH_CHARSET); + } else { + connection.setRequestProperty(HEADER_CONTENT_TYPE, + HEADER_VALUE_CONTENT_TYPE_WITHOUT_CHARSET); + } + if (Log.isLoggable(MmsService.TAG, Log.VERBOSE)) { + logHttpHeaders(connection.getRequestProperties()); + } + connection.setFixedLengthStreamingMode(pdu.length); + // Sending request body + final OutputStream out = + new BufferedOutputStream(connection.getOutputStream()); + out.write(pdu); + out.flush(); + out.close(); + } else if (METHOD_GET.equals(method)) { + if (Log.isLoggable(MmsService.TAG, Log.VERBOSE)) { + logHttpHeaders(connection.getRequestProperties()); + } + connection.setRequestMethod(METHOD_GET); + } + // Get response + final int responseCode = connection.getResponseCode(); + final String responseMessage = connection.getResponseMessage(); + Log.d(MmsService.TAG, "HTTP: " + responseCode + " " + responseMessage); + if (Log.isLoggable(MmsService.TAG, Log.VERBOSE)) { + logHttpHeaders(connection.getHeaderFields()); + } + if (responseCode / 100 != 2) { + throw new MmsHttpException(responseCode, responseMessage); + } + final InputStream in = new BufferedInputStream(connection.getInputStream()); + final ByteArrayOutputStream byteOut = new ByteArrayOutputStream(); + final byte[] buf = new byte[4096]; + int count = 0; + while ((count = in.read(buf)) > 0) { + byteOut.write(buf, 0, count); + } + in.close(); + final byte[] responseBody = byteOut.toByteArray(); + Log.d(MmsService.TAG, "HTTP: response size=" + + (responseBody != null ? responseBody.length : 0)); + return responseBody; + } catch (MalformedURLException e) { + final String redactedUrl = Utils.redactUrlForNonVerbose(urlString); + Log.e(MmsService.TAG, "HTTP: invalid URL " + redactedUrl, e); + throw new MmsHttpException(0/*statusCode*/, "Invalid URL " + redactedUrl, e); + } catch (ProtocolException e) { + final String redactedUrl = Utils.redactUrlForNonVerbose(urlString); + Log.e(MmsService.TAG, "HTTP: invalid URL protocol " + redactedUrl, e); + throw new MmsHttpException(0/*statusCode*/, "Invalid URL protocol " + redactedUrl, e); + } catch (IOException e) { + Log.e(MmsService.TAG, "HTTP: IO failure", e); + throw new MmsHttpException(0/*statusCode*/, e); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + private static void logHttpHeaders(Map> headers) { + final StringBuilder sb = new StringBuilder(); + if (headers != null) { + for (Map.Entry> entry : headers.entrySet()) { + final String key = entry.getKey(); + final List values = entry.getValue(); + if (values != null) { + for (String value : values) { + sb.append(key).append('=').append(value).append('\n'); + } + } + } + Log.v(MmsService.TAG, "HTTP: headers\n" + sb.toString()); + } + } + + private static void checkMethod(String method) throws MmsHttpException { + if (!METHOD_GET.equals(method) && !METHOD_POST.equals(method)) { + throw new MmsHttpException(0/*statusCode*/, "Invalid method " + method); + } + } + + private static final String ACCEPT_LANG_FOR_US_LOCALE = "en-US"; + + /** + * Return the Accept-Language header. Use the current locale plus + * US if we are in a different locale than US. + * This code copied from the browser's WebSettings.java + * + * @return Current AcceptLanguage String. + */ + public static String getCurrentAcceptLanguage(Locale locale) { + final StringBuilder buffer = new StringBuilder(); + addLocaleToHttpAcceptLanguage(buffer, locale); + + if (!Locale.US.equals(locale)) { + if (buffer.length() > 0) { + buffer.append(", "); + } + buffer.append(ACCEPT_LANG_FOR_US_LOCALE); + } + + return buffer.toString(); + } + + /** + * Convert obsolete language codes, including Hebrew/Indonesian/Yiddish, + * to new standard. + */ + private static String convertObsoleteLanguageCodeToNew(String langCode) { + if (langCode == null) { + return null; + } + if ("iw".equals(langCode)) { + // Hebrew + return "he"; + } else if ("in".equals(langCode)) { + // Indonesian + return "id"; + } else if ("ji".equals(langCode)) { + // Yiddish + return "yi"; + } + return langCode; + } + + private static void addLocaleToHttpAcceptLanguage(StringBuilder builder, Locale locale) { + final String language = convertObsoleteLanguageCodeToNew(locale.getLanguage()); + if (language != null) { + builder.append(language); + final String country = locale.getCountry(); + if (country != null) { + builder.append("-"); + builder.append(country); + } + } + } + + private static final Pattern MACRO_P = Pattern.compile("##(\\S+)##"); + /** + * Resolve the macro in HTTP param value text + * For example, "something##LINE1##something" is resolved to "something9139531419something" + * + * @param value The HTTP param value possibly containing macros + * @return The HTTP param with macro resolved to real value + */ + private String resolveMacro(String value, Bundle mmsConfig) { + if (TextUtils.isEmpty(value)) { + return value; + } + final Matcher matcher = MACRO_P.matcher(value); + int nextStart = 0; + StringBuilder replaced = null; + while (matcher.find()) { + if (replaced == null) { + replaced = new StringBuilder(); + } + final int matchedStart = matcher.start(); + if (matchedStart > nextStart) { + replaced.append(value.substring(nextStart, matchedStart)); + } + final String macro = matcher.group(1); + final String macroValue = getHttpParamMacro(macro, mmsConfig); + if (macroValue != null) { + replaced.append(macroValue); + } + nextStart = matcher.end(); + } + if (replaced != null && nextStart < value.length()) { + replaced.append(value.substring(nextStart)); + } + return replaced == null ? value : replaced.toString(); + } + + /** + * Add extra HTTP headers from mms_config.xml's httpParams, which is a list of key/value + * pairs separated by "|". Each key/value pair is separated by ":". Value may contain + * macros like "##LINE1##" or "##NAI##" which is resolved with methods in this class + * + * @param connection The HttpURLConnection that we add headers to + * @param mmsConfig The MmsConfig object + */ + private void addExtraHeaders(HttpURLConnection connection, Bundle mmsConfig) { + final String extraHttpParams = mmsConfig.getString( + CarrierConfigValuesLoader.CONFIG_HTTP_PARAMS); + if (!TextUtils.isEmpty(extraHttpParams)) { + // Parse the parameter list + String paramList[] = extraHttpParams.split("\\|"); + for (String paramPair : paramList) { + String splitPair[] = paramPair.split(":", 2); + if (splitPair.length == 2) { + final String name = splitPair[0].trim(); + final String value = resolveMacro(splitPair[1].trim(), mmsConfig); + if (!TextUtils.isEmpty(name) && !TextUtils.isEmpty(value)) { + // Add the header if the param is valid + connection.setRequestProperty(name, value); + } + } + } + } + } + + /** + * Return the HTTP param macro value. + * Example: LINE1 returns the phone number, etc. + * + * @param macro The macro name + * @param mmsConfig The carrier configuration values + * @return The value of the defined macro + */ + private String getHttpParamMacro(final String macro, final Bundle mmsConfig) { + if (MACRO_LINE1.equals(macro)) { + return getSelfNumber(); + } else if (MACRO_LINE1NOCOUNTRYCODE.equals(macro)) { + return PhoneNumberHelper.getNumberNoCountryCode( + getSelfNumber(), getSimOrLocaleCountry()); + } else if (MACRO_NAI.equals(macro)) { + return getEncodedNai(mmsConfig.getString( + CarrierConfigValuesLoader.CONFIG_NAI_SUFFIX, + CarrierConfigValuesLoader.CONFIG_NAI_SUFFIX_DEFAULT)); + } + return null; + } + + /** + * Get the device phone number + * + * @return the phone number text + */ + private String getSelfNumber() { + if (Utils.supportMSim()) { + final SubscriptionManager subscriptionManager = SubscriptionManager.from(mContext); + final SubscriptionInfo info = subscriptionManager.getActiveSubscriptionInfo( + SmsManager.getDefaultSmsSubscriptionId()); + if (info != null) { + return info.getNumber(); + } else { + return null; + } + } else { + return mTelephonyManager.getLine1Number(); + } + } + + /** + * Get the country ISO code from SIM or system locale + * + * @return the country ISO code + */ + private String getSimOrLocaleCountry() { + String country = null; + if (Utils.supportMSim()) { + final SubscriptionManager subscriptionManager = SubscriptionManager.from(mContext); + final SubscriptionInfo info = subscriptionManager.getActiveSubscriptionInfo( + SmsManager.getDefaultSmsSubscriptionId()); + if (info != null) { + country = info.getCountryIso(); + } + } else { + country = mTelephonyManager.getSimCountryIso(); + } + if (!TextUtils.isEmpty(country)) { + return country.toUpperCase(); + } else { + return Locale.getDefault().getCountry(); + } + } + + /** + * Get encoded NAI string to use as the HTTP header for some carriers. + * On L-MR1+, we call the hidden system API to get this + * On L-MR1-, we try to find it via system property. + * + * @param naiSuffix the suffix to append to NAI before encoding + * @return the Base64 encoded NAI string to use as HTTP header + */ + private String getEncodedNai(final String naiSuffix) { + String nai; + if (Utils.supportMSim()) { + nai = getNaiBySystemApi( + getSlotId(Utils.getEffectiveSubscriptionId(MmsManager.DEFAULT_SUB_ID))); + } else { + nai = getNaiBySystemProperty(); + } + if (!TextUtils.isEmpty(nai)) { + Log.i(MmsService.TAG, "NAI is not empty"); + if (!TextUtils.isEmpty(naiSuffix)) { + nai = nai + naiSuffix; + } + byte[] encoded = null; + try { + encoded = Base64.encode(nai.getBytes("UTF-8"), Base64.NO_WRAP); + } catch (UnsupportedEncodingException e) { + encoded = Base64.encode(nai.getBytes(), Base64.NO_WRAP); + } + try { + return new String(encoded, "UTF-8"); + } catch (UnsupportedEncodingException e) { + return new String(encoded); + } + } + return null; + } + + /** + * Invoke hidden SubscriptionManager.getSlotId(int) + * + * @param subId the subId + * @return the SIM slot ID + */ + private static int getSlotId(final int subId) { + try { + final Method method = SubscriptionManager.class.getMethod("getSlotId", Integer.TYPE); + if (method != null) { + return (Integer) method.invoke(null, subId); + } + } catch (Exception e) { + Log.w(MmsService.TAG, "SubscriptionManager.getSlotId failed " + e); + } + return -1; + } + + /** + * Get NAI using hidden TelephonyManager.getNai(int) + * + * @param slotId the SIM slot ID + * @return the NAI string + */ + private String getNaiBySystemApi(final int slotId) { + try { + final Method method = mTelephonyManager.getClass().getMethod("getNai", Integer.TYPE); + if (method != null) { + return (String) method.invoke(mTelephonyManager, slotId); + } + } catch (Exception e) { + Log.w(MmsService.TAG, "TelephonyManager.getNai failed " + e); + } + return null; + } + + /** + * Get NAI using hidden SystemProperties.get(String) + * + * @return the NAI string as system property + */ + private static String getNaiBySystemProperty() { + try { + final Class systemPropertiesClass = Class.forName("android.os.SystemProperties"); + if (systemPropertiesClass != null) { + final Method method = systemPropertiesClass.getMethod("get", String.class); + if (method != null) { + return (String) method.invoke(null, NAI_PROPERTY); + } + } + } catch (Exception e) { + Log.w(MmsService.TAG, "SystemProperties.get failed " + e); + } + return null; + } +} diff --git a/src/android/support/v7/mms/MmsHttpException.java b/src/android/support/v7/mms/MmsHttpException.java new file mode 100644 index 0000000..913ba55 --- /dev/null +++ b/src/android/support/v7/mms/MmsHttpException.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +/** + * HTTP exception + */ +public class MmsHttpException extends Exception { + // Optional HTTP status code. 0 means ignore. Otherwise this + // should be a valid HTTP status code. + private final int mStatusCode; + + public MmsHttpException(int statusCode) { + super(); + mStatusCode = statusCode; + } + + public MmsHttpException(int statusCode, String message) { + super(message); + mStatusCode = statusCode; + } + + public MmsHttpException(int statusCode, Throwable cause) { + super(cause); + mStatusCode = statusCode; + } + + public MmsHttpException(int statusCode, String message, Throwable cause) { + super(message, cause); + mStatusCode = statusCode; + } + + public int getStatusCode() { + return mStatusCode; + } +} diff --git a/src/android/support/v7/mms/MmsManager.java b/src/android/support/v7/mms/MmsManager.java new file mode 100644 index 0000000..f9c0a91 --- /dev/null +++ b/src/android/support/v7/mms/MmsManager.java @@ -0,0 +1,256 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +import android.app.PendingIntent; +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.telephony.SmsManager; +import android.util.SparseArray; + +/** + * The public interface of MMS library + */ +public class MmsManager { + /** + * Default subscription ID + */ + public static final int DEFAULT_SUB_ID = -1; + + // Whether to force legacy MMS sending + private static volatile boolean sForceLegacyMms = false; + + // Cached computed overrides for carrier configuration values + private static SparseArray sConfigOverridesMap = new SparseArray<>(); + + /** + * Set the flag about whether to force to use legacy system APIs instead of system MMS API + * + * @param forceLegacyMms value to set + */ + public static void setForceLegacyMms(boolean forceLegacyMms) { + sForceLegacyMms = forceLegacyMms; + } + + /** + * Set the size of thread pool for request execution. + * + * Default is 4 + * + * Note: if system MMS API is used, this has no effect + * + * @param size thread pool size + */ + public static void setThreadPoolSize(int size) { + MmsService.setThreadPoolSize(size); + } + + /** + * Set whether to use wake lock while sending or downloading MMS. + * + * Default value is true + * + * Note: if system MMS API is used, this has no effect + * + * @param useWakeLock true to use wake lock, false otherwise + */ + public static void setUseWakeLock(final boolean useWakeLock) { + MmsService.setUseWakeLock(useWakeLock); + } + + /** + * Set the optional carrier config values loader + * + * Note: if system MMS API is used, this is used to compute the overrides + * of carrier configuration values + * + * @param loader the carrier config values loader + */ + public static void setCarrierConfigValuesLoader(CarrierConfigValuesLoader loader) { + if (loader == null) { + throw new IllegalArgumentException("Carrier configuration loader can not be empty"); + } + synchronized (sConfigOverridesMap) { + MmsService.setCarrierConfigValuesLoader(loader); + sConfigOverridesMap.clear(); + } + } + + /** + * Set the optional APN settings loader + * + * Note: if system MMS API is used, this has no effect + * + * @param loader the APN settings loader + */ + public static void setApnSettingsLoader(ApnSettingsLoader loader) { + if (loader == null) { + throw new IllegalArgumentException("APN settings loader can not be empty"); + } + MmsService.setApnSettingsLoader(loader); + } + + /** + * Set user agent info loader + * + * Note: if system MMS API is used, this is used to compute the overrides + * of carrier configuration values + + * @param loader the user agent info loader + */ + public static void setUserAgentInfoLoader(final UserAgentInfoLoader loader) { + if (loader == null) { + throw new IllegalArgumentException("User agent info loader can not be empty"); + } + synchronized (sConfigOverridesMap) { + MmsService.setUserAgentInfoLoader(loader); + sConfigOverridesMap.clear(); + } + } + + /** + * Send MMS via platform MMS API (if platform supports and not forced to + * use legacy APIs) or legacy APIs + * + * @param subId the subscription ID of the SIM to use + * @param context the Context to use + * @param contentUri the content URI of the PDU to be sent + * @param locationUrl the optional location URL to use for sending + * @param sentIntent the pending intent for returning results + */ + public static void sendMultimediaMessage(int subId, Context context, Uri contentUri, + String locationUrl, PendingIntent sentIntent) { + if (Utils.hasMmsApi() && !sForceLegacyMms) { + subId = Utils.getEffectiveSubscriptionId(subId); + final SmsManager smsManager = Utils.getSmsManager(subId); + smsManager.sendMultimediaMessage(context, contentUri, locationUrl, + getConfigOverrides(subId), sentIntent); + } else { + MmsService.startRequest(context, new SendRequest(locationUrl, contentUri, sentIntent)); + } + } + + /** + * Download MMS via platform MMS API (if platform supports and not forced to + * use legacy APIs) or legacy APIs + * + * @param subId the subscription ID of the SIM to use + * @param context the Context to use + * @param contentUri the content URI of the PDU to be sent + * @param locationUrl the optional location URL to use for sending + * @param downloadedIntent the pending intent for returning results + */ + public static void downloadMultimediaMessage(int subId, Context context, String locationUrl, + Uri contentUri, PendingIntent downloadedIntent) { + if (Utils.hasMmsApi() && !sForceLegacyMms) { + subId = Utils.getEffectiveSubscriptionId(subId); + final SmsManager smsManager = Utils.getSmsManager(subId); + smsManager.downloadMultimediaMessage(context, locationUrl, contentUri, + getConfigOverrides(subId), downloadedIntent); + } else { + MmsService.startRequest(context, + new DownloadRequest(locationUrl, contentUri, downloadedIntent)); + } + } + + /** + * Get carrier configuration values overrides when platform MMS API is called. + * We only need to compute this if customized carrier config values loader or + * user agent info loader are set + * + * @param subId the ID of the SIM to use + * @return a Bundle containing the overrides + */ + private static Bundle getConfigOverrides(final int subId) { + if (!Utils.hasMmsApi()) { + // If MMS API is not present, it is not necessary to compute overrides + return null; + } + Bundle overrides = null; + synchronized (sConfigOverridesMap) { + overrides = sConfigOverridesMap.get(subId); + if (overrides == null) { + overrides = new Bundle(); + sConfigOverridesMap.put(subId, overrides); + computeOverridesLocked(subId, overrides); + } + } + return overrides; + } + + /** + * Compute the overrides, incorporating the user agent info + * + * @param subId the subId of the SIM to use + * @param overrides the computed values overrides + */ + private static void computeOverridesLocked(final int subId, final Bundle overrides) { + // Overrides not computed yet + final CarrierConfigValuesLoader carrierConfigValuesLoader = + MmsService.getCarrierConfigValuesLoader(); + if (carrierConfigValuesLoader != null && + !(carrierConfigValuesLoader instanceof DefaultCarrierConfigValuesLoader)) { + // Compute the overrides for carrier config values first if the config loader + // is not the default one. + final Bundle systemValues = Utils.getSmsManager(subId).getCarrierConfigValues(); + final Bundle callerValues = + MmsService.getCarrierConfigValuesLoader().get(subId); + if (systemValues != null && callerValues != null) { + computeConfigDelta(systemValues, callerValues, overrides); + } else if (systemValues == null && callerValues != null) { + overrides.putAll(callerValues); + } + } + final UserAgentInfoLoader userAgentInfoLoader = MmsService.getUserAgentInfoLoader(); + if (userAgentInfoLoader != null && + !(userAgentInfoLoader instanceof DefaultUserAgentInfoLoader)) { + // Also set the user agent and ua prof url via the overrides + // if the user agent loader is not the default one. + overrides.putString(UserAgentInfoLoader.CONFIG_USER_AGENT, + userAgentInfoLoader.getUserAgent()); + overrides.putString(UserAgentInfoLoader.CONFIG_UA_PROF_URL, + userAgentInfoLoader.getUAProfUrl()); + } + } + + /** + * Compute the delta between two sets of carrier configuration values: system and caller + * + * @param systemValues the system config values + * @param callerValues the caller's config values + * @param delta the delta of values (caller - system), using caller value to override system's + */ + private static void computeConfigDelta(final Bundle systemValues, final Bundle callerValues, + final Bundle delta) { + for (final String key : callerValues.keySet()) { + final Object callerValue = callerValues.get(key); + final Object systemValue = systemValues.get(key); + if ((callerValue != null && systemValue != null && !callerValue.equals(systemValue)) || + (callerValue != null && systemValue == null) || + (callerValue == null && systemValue != null)) { + if (callerValue == null || callerValue instanceof String) { + delta.putString(key, (String) callerValue); + } else if (callerValue instanceof Integer) { + delta.putInt(key, (Integer) callerValue); + } else if (callerValue instanceof Boolean) { + delta.putBoolean(key, (Boolean) callerValue); + } + } + } + } +} diff --git a/src/android/support/v7/mms/MmsNetworkException.java b/src/android/support/v7/mms/MmsNetworkException.java new file mode 100644 index 0000000..776d8ee --- /dev/null +++ b/src/android/support/v7/mms/MmsNetworkException.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +/** + * MMS network exception + */ +class MmsNetworkException extends Exception { + + public MmsNetworkException() { + super(); + } + + public MmsNetworkException(String message) { + super(message); + } + + public MmsNetworkException(Throwable cause) { + super(cause); + } + + public MmsNetworkException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/android/support/v7/mms/MmsNetworkManager.java b/src/android/support/v7/mms/MmsNetworkManager.java new file mode 100644 index 0000000..0630293 --- /dev/null +++ b/src/android/support/v7/mms/MmsNetworkManager.java @@ -0,0 +1,382 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.Build; +import android.os.SystemClock; +import android.util.Log; + +import java.lang.reflect.Method; +import java.util.Timer; +import java.util.TimerTask; + +/** + * Class manages MMS network connectivity using legacy platform APIs + * (deprecated since Android L) on pre-L devices (or when forced to + * be used on L and later) + */ +class MmsNetworkManager { + // Hidden platform constants + private static final String FEATURE_ENABLE_MMS = "enableMMS"; + private static final String REASON_VOICE_CALL_ENDED = "2GVoiceCallEnded"; + private static final int APN_ALREADY_ACTIVE = 0; + private static final int APN_REQUEST_STARTED = 1; + private static final int APN_TYPE_NOT_AVAILABLE = 2; + private static final int APN_REQUEST_FAILED = 3; + private static final int APN_ALREADY_INACTIVE = 4; + // A map from platform APN constant to text string + private static final String[] APN_RESULT_STRING = new String[]{ + "already active", + "request started", + "type not available", + "request failed", + "already inactive", + "unknown", + }; + + private static final long NETWORK_ACQUIRE_WAIT_INTERVAL_MS = 15000; + private static final long DEFAULT_NETWORK_ACQUIRE_TIMEOUT_MS = 180000; + private static final String MMS_NETWORK_EXTENSION_TIMER = "mms_network_extension_timer"; + private static final long MMS_NETWORK_EXTENSION_TIMER_WAIT_MS = 30000; + + private static volatile long sNetworkAcquireTimeoutMs = DEFAULT_NETWORK_ACQUIRE_TIMEOUT_MS; + + /** + * Set the network acquire timeout + * + * @param timeoutMs timeout in millisecond + */ + static void setNetworkAcquireTimeout(final long timeoutMs) { + sNetworkAcquireTimeoutMs = timeoutMs; + } + + private final Context mContext; + private final ConnectivityManager mConnectivityManager; + + // If the connectivity intent receiver is registered + private boolean mReceiverRegistered; + // Count of requests that are using the MMS network + private int mUseCount; + // Count of requests that are waiting for connectivity (i.e. in acquireNetwork wait loop) + private int mWaitCount; + // Timer to extend the network connectivity + private Timer mExtensionTimer; + + private final MmsHttpClient mHttpClient; + + private final IntentFilter mConnectivityIntentFilter; + private final BroadcastReceiver mConnectivityChangeReceiver = new BroadcastReceiver() { + @Override + public void onReceive(final Context context, final Intent intent) { + if (!ConnectivityManager.CONNECTIVITY_ACTION.equals(intent.getAction())) { + return; + } + final int networkType = getConnectivityChangeNetworkType(intent); + if (networkType != ConnectivityManager.TYPE_MOBILE_MMS) { + return; + } + onMmsConnectivityChange(context, intent); + } + }; + + MmsNetworkManager(final Context context) { + mContext = context; + mConnectivityManager = (ConnectivityManager) mContext.getSystemService( + Context.CONNECTIVITY_SERVICE); + mHttpClient = new MmsHttpClient(mContext); + mConnectivityIntentFilter = new IntentFilter(); + mConnectivityIntentFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION); + mUseCount = 0; + mWaitCount = 0; + } + + ConnectivityManager getConnectivityManager() { + return mConnectivityManager; + } + + MmsHttpClient getHttpClient() { + return mHttpClient; + } + + /** + * Synchronously acquire MMS network connectivity + * + * @throws MmsNetworkException If failed permanently or timed out + */ + void acquireNetwork() throws MmsNetworkException { + Log.i(MmsService.TAG, "Acquire MMS network"); + synchronized (this) { + try { + mUseCount++; + mWaitCount++; + if (mWaitCount == 1) { + // Register the receiver for the first waiting request + registerConnectivityChangeReceiverLocked(); + } + long waitMs = sNetworkAcquireTimeoutMs; + final long beginMs = SystemClock.elapsedRealtime(); + do { + if (!isMobileDataEnabled()) { + // Fast fail if mobile data is not enabled + throw new MmsNetworkException("Mobile data is disabled"); + } + // Always try to extend and check the MMS network connectivity + // before we start waiting to make sure we don't miss the change + // of MMS connectivity. As one example, some devices fail to send + // connectivity change intent. So this would make sure we catch + // the state change. + if (extendMmsConnectivityLocked()) { + // Connected + return; + } + try { + wait(Math.min(waitMs, NETWORK_ACQUIRE_WAIT_INTERVAL_MS)); + } catch (final InterruptedException e) { + Log.w(MmsService.TAG, "Unexpected exception", e); + } + // Calculate the remaining time to wait + waitMs = sNetworkAcquireTimeoutMs - (SystemClock.elapsedRealtime() - beginMs); + } while (waitMs > 0); + // Last check + if (extendMmsConnectivityLocked()) { + return; + } else { + // Reaching here means timed out. + throw new MmsNetworkException("Acquiring MMS network timed out"); + } + } finally { + mWaitCount--; + if (mWaitCount == 0) { + // Receiver is used to listen to connectivity change and unblock + // the waiting requests. If nobody's waiting on change, there is + // no need for the receiver. The auto extension timer will try + // to maintain the connectivity periodically. + unregisterConnectivityChangeReceiverLocked(); + } + } + } + } + + /** + * Release MMS network connectivity. This is ref counted. So it only disconnect + * when the ref count is 0. + */ + void releaseNetwork() { + Log.i(MmsService.TAG, "release MMS network"); + synchronized (this) { + mUseCount--; + if (mUseCount == 0) { + stopNetworkExtensionTimerLocked(); + endMmsConnectivity(); + } + } + } + + String getApnName() { + String apnName = null; + final NetworkInfo mmsNetworkInfo = mConnectivityManager.getNetworkInfo( + ConnectivityManager.TYPE_MOBILE_MMS); + if (mmsNetworkInfo != null) { + apnName = mmsNetworkInfo.getExtraInfo(); + } + return apnName; + } + + // Process mobile MMS connectivity change, waking up the waiting request thread + // in certain conditions: + // - Successfully connected + // - Failed permanently + // - Required another kickoff + // We don't initiate connection here but just notifyAll so the waiting request + // would wake up and retry connection before next wait. + private void onMmsConnectivityChange(final Context context, final Intent intent) { + if (mUseCount < 1) { + return; + } + final NetworkInfo mmsNetworkInfo = + mConnectivityManager.getNetworkInfo(ConnectivityManager.TYPE_MOBILE_MMS); + // Check availability of the mobile network. + if (mmsNetworkInfo != null) { + if (REASON_VOICE_CALL_ENDED.equals(mmsNetworkInfo.getReason())) { + // This is a very specific fix to handle the case where the phone receives an + // incoming call during the time we're trying to setup the mms connection. + // When the call ends, restart the process of mms connectivity. + // Once the waiting request is unblocked, before the next wait, we would start + // MMS network again. + unblockWait(); + } else { + final NetworkInfo.State state = mmsNetworkInfo.getState(); + if (state == NetworkInfo.State.CONNECTED || + (state == NetworkInfo.State.DISCONNECTED && !isMobileDataEnabled())) { + // Unblock the waiting request when we either connected + // OR + // disconnected due to mobile data disabled therefore needs to fast fail + // (on some devices if mobile data disabled and starting MMS would cause + // an immediate state change to disconnected, so causing a tight loop of + // trying and failing) + // Once the waiting request is unblocked, before the next wait, we would + // check mobile data and start MMS network again. So we should catch + // both the success and the fast failure. + unblockWait(); + } + } + } + } + + private void unblockWait() { + synchronized (this) { + notifyAll(); + } + } + + private void startNetworkExtensionTimerLocked() { + if (mExtensionTimer == null) { + mExtensionTimer = new Timer(MMS_NETWORK_EXTENSION_TIMER, true/*daemon*/); + mExtensionTimer.schedule( + new TimerTask() { + @Override + public void run() { + synchronized (this) { + if (mUseCount > 0) { + try { + // Try extending the connectivity + extendMmsConnectivityLocked(); + } catch (final MmsNetworkException e) { + // Ignore the exception + } + } + } + } + }, + MMS_NETWORK_EXTENSION_TIMER_WAIT_MS); + } + } + + private void stopNetworkExtensionTimerLocked() { + if (mExtensionTimer != null) { + mExtensionTimer.cancel(); + mExtensionTimer = null; + } + } + + private boolean extendMmsConnectivityLocked() throws MmsNetworkException { + final int result = startMmsConnectivity(); + if (result == APN_ALREADY_ACTIVE) { + // Already active + startNetworkExtensionTimerLocked(); + return true; + } else if (result != APN_REQUEST_STARTED) { + stopNetworkExtensionTimerLocked(); + throw new MmsNetworkException("Cannot acquire MMS network: " + + result + " - " + getMmsConnectivityResultString(result)); + } + return false; + } + + private int startMmsConnectivity() { + Log.i(MmsService.TAG, "Start MMS connectivity"); + try { + final Method method = mConnectivityManager.getClass().getMethod( + "startUsingNetworkFeature", Integer.TYPE, String.class); + if (method != null) { + return (Integer) method.invoke( + mConnectivityManager, ConnectivityManager.TYPE_MOBILE, FEATURE_ENABLE_MMS); + } + } catch (final Exception e) { + Log.w(MmsService.TAG, "ConnectivityManager.startUsingNetworkFeature failed " + e); + } + return APN_REQUEST_FAILED; + } + + private void endMmsConnectivity() { + Log.i(MmsService.TAG, "End MMS connectivity"); + try { + final Method method = mConnectivityManager.getClass().getMethod( + "stopUsingNetworkFeature", Integer.TYPE, String.class); + if (method != null) { + method.invoke( + mConnectivityManager, ConnectivityManager.TYPE_MOBILE, FEATURE_ENABLE_MMS); + } + } catch (final Exception e) { + Log.w(MmsService.TAG, "ConnectivityManager.stopUsingNetworkFeature failed " + e); + } + } + + private void registerConnectivityChangeReceiverLocked() { + if (!mReceiverRegistered) { + mContext.registerReceiver(mConnectivityChangeReceiver, mConnectivityIntentFilter); + mReceiverRegistered = true; + } + } + + private void unregisterConnectivityChangeReceiverLocked() { + if (mReceiverRegistered) { + mContext.unregisterReceiver(mConnectivityChangeReceiver); + mReceiverRegistered = false; + } + } + + /** + * The absence of a connection type. + */ + private static final int TYPE_NONE = -1; + + /** + * Get the network type of the connectivity change + * + * @param intent the broadcast intent of connectivity change + * @return The change's network type + */ + private static int getConnectivityChangeNetworkType(final Intent intent) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + return intent.getIntExtra(ConnectivityManager.EXTRA_NETWORK_TYPE, TYPE_NONE); + } else { + final NetworkInfo info = intent.getParcelableExtra( + ConnectivityManager.EXTRA_NETWORK_INFO); + if (info != null) { + return info.getType(); + } + } + return TYPE_NONE; + } + + private static String getMmsConnectivityResultString(int result) { + if (result < 0 || result >= APN_RESULT_STRING.length) { + result = APN_RESULT_STRING.length - 1; + } + return APN_RESULT_STRING[result]; + } + + private boolean isMobileDataEnabled() { + try { + final Class cmClass = mConnectivityManager.getClass(); + final Method method = cmClass.getDeclaredMethod("getMobileDataEnabled"); + method.setAccessible(true); // Make the method callable + // get the setting for "mobile data" + return (Boolean) method.invoke(mConnectivityManager); + } catch (final Exception e) { + Log.w(MmsService.TAG, "TelephonyManager.getMobileDataEnabled failed", e); + } + return false; + } +} diff --git a/src/android/support/v7/mms/MmsRequest.java b/src/android/support/v7/mms/MmsRequest.java new file mode 100644 index 0000000..edf3606 --- /dev/null +++ b/src/android/support/v7/mms/MmsRequest.java @@ -0,0 +1,391 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +import android.app.Activity; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.net.ConnectivityManager; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.v7.mms.pdu.GenericPdu; +import android.support.v7.mms.pdu.PduHeaders; +import android.support.v7.mms.pdu.PduParser; +import android.support.v7.mms.pdu.SendConf; +import android.telephony.SmsManager; +import android.text.TextUtils; +import android.util.Log; + +import java.lang.reflect.Method; +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * MMS request base class. This handles the execution of any MMS request. + */ +abstract class MmsRequest implements Parcelable { + /** + * Prepare to make the HTTP request - will download message for sending + * + * @param context the Context + * @param mmsConfig carrier config values to use + * @return true if loading request PDU from calling app succeeds, false otherwise + */ + protected abstract boolean loadRequest(Context context, Bundle mmsConfig); + + /** + * Transfer the received response to the caller + * + * @param context the Context + * @param fillIn the content of pending intent to be returned + * @param response the pdu to transfer + * @return true if transferring response PDU to calling app succeeds, false otherwise + */ + protected abstract boolean transferResponse(Context context, Intent fillIn, byte[] response); + + /** + * Making the HTTP request to MMSC + * + * @param context The context + * @param netMgr The current {@link MmsNetworkManager} + * @param apn The APN + * @param mmsConfig The carrier configuration values to use + * @param userAgent The User-Agent header value + * @param uaProfUrl The UA Prof URL header value + * @return The HTTP response data + * @throws MmsHttpException If any network error happens + */ + protected abstract byte[] doHttp(Context context, MmsNetworkManager netMgr, + ApnSettingsLoader.Apn apn, Bundle mmsConfig, String userAgent, String uaProfUrl) + throws MmsHttpException; + + /** + * Get the HTTP request URL for this MMS request + * + * @param apn The APN to use + * @return The HTTP request URL in text + */ + protected abstract String getHttpRequestUrl(ApnSettingsLoader.Apn apn); + + // Maximum time to spend waiting to read data from a content provider before failing with error. + protected static final int TASK_TIMEOUT_MS = 30 * 1000; + + protected final String mLocationUrl; + protected final Uri mPduUri; + protected final PendingIntent mPendingIntent; + // Thread pool for transferring PDU with MMS apps + protected final ExecutorService mPduTransferExecutor = Executors.newCachedThreadPool(); + + // Whether this request should acquire wake lock + private boolean mUseWakeLock; + + protected MmsRequest(final String locationUrl, final Uri pduUri, + final PendingIntent pendingIntent) { + mLocationUrl = locationUrl; + mPduUri = pduUri; + mPendingIntent = pendingIntent; + mUseWakeLock = true; + } + + void setUseWakeLock(final boolean useWakeLock) { + mUseWakeLock = useWakeLock; + } + + boolean getUseWakeLock() { + return mUseWakeLock; + } + + /** + * Run the MMS request. + * + * @param context the context to use + * @param networkManager the MmsNetworkManager to use to setup MMS network + * @param apnSettingsLoader the APN loader + * @param carrierConfigValuesLoader the carrier config loader + * @param userAgentInfoLoader the user agent info loader + */ + void execute(final Context context, final MmsNetworkManager networkManager, + final ApnSettingsLoader apnSettingsLoader, + final CarrierConfigValuesLoader carrierConfigValuesLoader, + final UserAgentInfoLoader userAgentInfoLoader) { + Log.i(MmsService.TAG, "Execute " + this.getClass().getSimpleName()); + int result = SmsManager.MMS_ERROR_UNSPECIFIED; + int httpStatusCode = 0; + byte[] response = null; + final Bundle mmsConfig = carrierConfigValuesLoader.get(MmsManager.DEFAULT_SUB_ID); + if (mmsConfig == null) { + Log.e(MmsService.TAG, "Failed to load carrier configuration values"); + result = SmsManager.MMS_ERROR_CONFIGURATION_ERROR; + } else if (!loadRequest(context, mmsConfig)) { + Log.e(MmsService.TAG, "Failed to load PDU"); + result = SmsManager.MMS_ERROR_IO_ERROR; + } else { + // Everything's OK. Now execute the request. + try { + // Acquire the MMS network + networkManager.acquireNetwork(); + // Load the potential APNs. In most cases there should be only one APN available. + // On some devices on which we can't obtain APN from system, we look up our own + // APN list. Since we don't have exact information, we may get a list of potential + // APNs to try. Whenever we found a successful APN, we signal it and return. + final String apnName = networkManager.getApnName(); + final List apns = apnSettingsLoader.get(apnName); + if (apns.size() < 1) { + throw new ApnException("No valid APN"); + } else { + Log.d(MmsService.TAG, "Trying " + apns.size() + " APNs"); + } + final String userAgent = userAgentInfoLoader.getUserAgent(); + final String uaProfUrl = userAgentInfoLoader.getUAProfUrl(); + MmsHttpException lastException = null; + for (ApnSettingsLoader.Apn apn : apns) { + Log.i(MmsService.TAG, "Using APN [" + + "MMSC=" + apn.getMmsc() + ", " + + "PROXY=" + apn.getMmsProxy() + ", " + + "PORT=" + apn.getMmsProxyPort() + "]"); + try { + final String url = getHttpRequestUrl(apn); + // Request a global route for the host to connect + requestRoute(networkManager.getConnectivityManager(), apn, url); + // Perform the HTTP request + response = doHttp( + context, networkManager, apn, mmsConfig, userAgent, uaProfUrl); + // Additional check of whether this is a success + if (isWrongApnResponse(response, mmsConfig)) { + throw new MmsHttpException(0/*statusCode*/, "Invalid sending address"); + } + // Notify APN loader this is a valid APN + apn.setSuccess(); + result = Activity.RESULT_OK; + break; + } catch (MmsHttpException e) { + Log.w(MmsService.TAG, "HTTP or network failure", e); + lastException = e; + } + } + if (lastException != null) { + throw lastException; + } + } catch (ApnException e) { + Log.e(MmsService.TAG, "MmsRequest: APN failure", e); + result = SmsManager.MMS_ERROR_INVALID_APN; + } catch (MmsNetworkException e) { + Log.e(MmsService.TAG, "MmsRequest: MMS network acquiring failure", e); + result = SmsManager.MMS_ERROR_UNABLE_CONNECT_MMS; + } catch (MmsHttpException e) { + Log.e(MmsService.TAG, "MmsRequest: HTTP or network I/O failure", e); + result = SmsManager.MMS_ERROR_HTTP_FAILURE; + httpStatusCode = e.getStatusCode(); + } catch (Exception e) { + Log.e(MmsService.TAG, "MmsRequest: unexpected failure", e); + result = SmsManager.MMS_ERROR_UNSPECIFIED; + } finally { + // Release MMS network + networkManager.releaseNetwork(); + } + } + // Process result and send back via PendingIntent + returnResult(context, result, response, httpStatusCode); + } + + /** + * Check if the response indicates a failure when we send to wrong APN. + * Sometimes even if you send to the wrong APN, a response in valid PDU format can still + * be sent back but with an error status. Check one specific case here. + * + * TODO: maybe there are other possibilities. + * + * @param response the response data + * @param mmsConfig the carrier configuration values to use + * @return false if we find an invalid response case, otherwise true + */ + static boolean isWrongApnResponse(final byte[] response, final Bundle mmsConfig) { + if (response != null && response.length > 0) { + try { + final GenericPdu pdu = new PduParser( + response, + mmsConfig.getBoolean( + CarrierConfigValuesLoader + .CONFIG_SUPPORT_MMS_CONTENT_DISPOSITION, + CarrierConfigValuesLoader + .CONFIG_SUPPORT_MMS_CONTENT_DISPOSITION_DEFAULT)) + .parse(); + if (pdu != null && pdu instanceof SendConf) { + final SendConf sendConf = (SendConf) pdu; + final int responseStatus = sendConf.getResponseStatus(); + return responseStatus == + PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_SENDING_ADDRESS_UNRESOLVED || + responseStatus == + PduHeaders.RESPONSE_STATUS_ERROR_SENDING_ADDRESS_UNRESOLVED; + } + } catch (RuntimeException e) { + Log.w(MmsService.TAG, "Parsing response failed", e); + } + } + return false; + } + + /** + * Return the result back via pending intent + * + * @param context The context + * @param result The result code of execution + * @param response The response body + * @param httpStatusCode The optional http status code in case of http failure + */ + void returnResult(final Context context, int result, final byte[] response, + final int httpStatusCode) { + if (mPendingIntent == null) { + // Result not needed + return; + } + // Extra information to send back with the pending intent + final Intent fillIn = new Intent(); + if (response != null) { + if (!transferResponse(context, fillIn, response)) { + // Failed to send PDU data back to caller + result = SmsManager.MMS_ERROR_IO_ERROR; + } + } + if (result == SmsManager.MMS_ERROR_HTTP_FAILURE && httpStatusCode != 0) { + // For HTTP failure, fill in the status code for more information + fillIn.putExtra(SmsManager.EXTRA_MMS_HTTP_STATUS, httpStatusCode); + } + try { + mPendingIntent.send(context, result, fillIn); + } catch (PendingIntent.CanceledException e) { + Log.e(MmsService.TAG, "Sending pending intent canceled", e); + } + } + + /** + * Request the route to the APN (either proxy host or the MMSC host) + * + * @param connectivityManager the ConnectivityManager to use + * @param apn the current APN + * @param url the URL to connect to + * @throws MmsHttpException for unknown host or route failure + */ + private static void requestRoute(final ConnectivityManager connectivityManager, + final ApnSettingsLoader.Apn apn, final String url) throws MmsHttpException { + String host = apn.getMmsProxy(); + if (TextUtils.isEmpty(host)) { + final Uri uri = Uri.parse(url); + host = uri.getHost(); + } + boolean success = false; + // Request route to all resolved host addresses + try { + for (final InetAddress addr : InetAddress.getAllByName(host)) { + final boolean requested = requestRouteToHostAddress(connectivityManager, addr); + if (requested) { + success = true; + Log.i(MmsService.TAG, "Requested route to " + addr); + } else { + Log.i(MmsService.TAG, "Could not requested route to " + addr); + } + } + if (!success) { + throw new MmsHttpException(0/*statusCode*/, "No route requested"); + } + } catch (UnknownHostException e) { + Log.w(MmsService.TAG, "Unknown host " + host); + throw new MmsHttpException(0/*statusCode*/, "Unknown host"); + } + } + + private static final Integer TYPE_MOBILE_MMS = + Integer.valueOf(ConnectivityManager.TYPE_MOBILE_MMS); + /** + * Wrapper for platform API requestRouteToHostAddress + * + * We first try the hidden but correct method on ConnectivityManager. If we can't, use + * the old but buggy one + * + * @param connMgr the ConnectivityManager instance + * @param inetAddr the InetAddress to request + * @return true if route is successfully setup, false otherwise + */ + private static boolean requestRouteToHostAddress(final ConnectivityManager connMgr, + final InetAddress inetAddr) { + // First try the good method using reflection + try { + final Method method = connMgr.getClass().getMethod("requestRouteToHostAddress", + Integer.TYPE, InetAddress.class); + if (method != null) { + return (Boolean) method.invoke(connMgr, TYPE_MOBILE_MMS, inetAddr); + } + } catch (Exception e) { + Log.w(MmsService.TAG, "ConnectivityManager.requestRouteToHostAddress failed " + e); + } + // If we fail, try the old but buggy one + if (inetAddr instanceof Inet4Address) { + try { + final Method method = connMgr.getClass().getMethod("requestRouteToHost", + Integer.TYPE, Integer.TYPE); + if (method != null) { + return (Boolean) method.invoke(connMgr, TYPE_MOBILE_MMS, + inetAddressToInt(inetAddr)); + } + } catch (Exception e) { + Log.w(MmsService.TAG, "ConnectivityManager.requestRouteToHost failed " + e); + } + } + return false; + } + + /** + * Convert a IPv4 address from an InetAddress to an integer + * + * @param inetAddr is an InetAddress corresponding to the IPv4 address + * @return the IP address as an integer in network byte order + */ + private static int inetAddressToInt(final InetAddress inetAddr) + throws IllegalArgumentException { + final byte [] addr = inetAddr.getAddress(); + return ((addr[3] & 0xff) << 24) | ((addr[2] & 0xff) << 16) | + ((addr[1] & 0xff) << 8) | (addr[0] & 0xff); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel parcel, int flags) { + parcel.writeByte((byte) (mUseWakeLock ? 1 : 0)); + parcel.writeString(mLocationUrl); + parcel.writeParcelable(mPduUri, 0); + parcel.writeParcelable(mPendingIntent, 0); + } + + protected MmsRequest(final Parcel in) { + final ClassLoader classLoader = MmsRequest.class.getClassLoader(); + mUseWakeLock = in.readByte() != 0; + mLocationUrl = in.readString(); + mPduUri = in.readParcelable(classLoader); + mPendingIntent = in.readParcelable(classLoader); + } +} diff --git a/src/android/support/v7/mms/MmsService.java b/src/android/support/v7/mms/MmsService.java new file mode 100644 index 0000000..650562f --- /dev/null +++ b/src/android/support/v7/mms/MmsService.java @@ -0,0 +1,465 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Handler; +import android.os.IBinder; +import android.os.PowerManager; +import android.os.Process; +import android.telephony.SmsManager; +import android.util.Log; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; + +/** + * Service to execute MMS requests using deprecated legacy APIs on older platform (prior to L) + */ +public class MmsService extends Service { + static final String TAG = "MmsLib"; + + //The default number of threads allowed to run MMS requests + private static final int DEFAULT_THREAD_POOL_SIZE = 4; + // Delay before stopping the service + private static final int SERVICE_STOP_DELAY_MILLIS = 2000; + + private static final String EXTRA_REQUEST = "request"; + private static final String EXTRA_MYPID = "mypid"; + + private static final String WAKELOCK_ID = "mmslib_wakelock"; + + /** + * Thread pool size for each request queue + */ + private static volatile int sThreadPoolSize = DEFAULT_THREAD_POOL_SIZE; + + /** + * Optional wake lock to use + */ + private static volatile boolean sUseWakeLock = true; + private static volatile PowerManager.WakeLock sWakeLock = null; + private static final Object sWakeLockLock = new Object(); + + /** + * Carrier configuration values loader + */ + private static volatile CarrierConfigValuesLoader sCarrierConfigValuesLoader = null; + + /** + * APN loader + */ + private static volatile ApnSettingsLoader sApnSettingsLoader = null; + + /** + * UserAgent and UA Prof URL loader + */ + private static volatile UserAgentInfoLoader sUserAgentInfoLoader = null; + + /** + * Set the size of thread pool for request execution. + * Default is DEFAULT_THREAD_POOL_SIZE + * + * @param size thread pool size + */ + static void setThreadPoolSize(final int size) { + sThreadPoolSize = size; + } + + /** + * Set whether to use wake lock + * + * @param useWakeLock true to use wake lock, false otherwise + */ + static void setUseWakeLock(final boolean useWakeLock) { + sUseWakeLock = useWakeLock; + } + + /** + * Set the optional carrier config values + * + * @param loader the carrier config values loader + */ + static void setCarrierConfigValuesLoader(final CarrierConfigValuesLoader loader) { + sCarrierConfigValuesLoader = loader; + } + + /** + * Get the current carrier config values loader + * + * @return the carrier config values loader currently set + */ + static CarrierConfigValuesLoader getCarrierConfigValuesLoader() { + return sCarrierConfigValuesLoader; + } + + /** + * Set APN settings loader + * + * @param loader the APN settings loader + */ + static void setApnSettingsLoader(final ApnSettingsLoader loader) { + sApnSettingsLoader = loader; + } + + /** + * Get the current APN settings loader + * + * @return the APN settings loader currently set + */ + static ApnSettingsLoader getApnSettingsLoader() { + return sApnSettingsLoader; + } + + /** + * Set user agent info loader + * + * @param loader the user agent info loader + */ + static void setUserAgentInfoLoader(final UserAgentInfoLoader loader) { + sUserAgentInfoLoader = loader; + } + + /** + * Get the current user agent info loader + * + * @return the user agent info loader currently set + */ + static UserAgentInfoLoader getUserAgentInfoLoader() { + return sUserAgentInfoLoader; + } + + /** + * Make sure loaders are not null. Set to default if that's the case + * + * @param context the Context to use + */ + private static void ensureLoaders(final Context context) { + if (sUserAgentInfoLoader == null) { + sUserAgentInfoLoader = new DefaultUserAgentInfoLoader(context); + } + if (sCarrierConfigValuesLoader == null) { + sCarrierConfigValuesLoader = new DefaultCarrierConfigValuesLoader(context); + } + if (sApnSettingsLoader == null) { + sApnSettingsLoader = new DefaultApnSettingsLoader(context); + } + } + + /** + * Acquire the wake lock + * + * @param context the context to use + */ + private static void acquireWakeLock(final Context context) { + synchronized (sWakeLockLock) { + if (sWakeLock == null) { + final PowerManager pm = + (PowerManager) context.getSystemService(Context.POWER_SERVICE); + sWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_ID); + } + sWakeLock.acquire(); + } + } + + /** + * Release the wake lock + */ + private static void releaseWakeLock() { + boolean releasedEmptyWakeLock = false; + synchronized (sWakeLockLock) { + if (sWakeLock != null) { + sWakeLock.release(); + } else { + releasedEmptyWakeLock = true; + } + } + if (releasedEmptyWakeLock) { + Log.w(TAG, "Releasing empty wake lock"); + } + } + + /** + * Check if wake lock is not held (e.g. when service stops) + */ + private static void verifyWakeLockNotHeld() { + boolean wakeLockHeld = false; + synchronized (sWakeLockLock) { + wakeLockHeld = sWakeLock != null && sWakeLock.isHeld(); + } + if (wakeLockHeld) { + Log.e(TAG, "Wake lock still held!"); + } + } + + // Remember my PID to discard restarted intent + private static volatile int sMyPid = -1; + + /** + * Get the current PID + * + * @return the current PID + */ + private static int getMyPid() { + if (sMyPid < 0) { + sMyPid = Process.myPid(); + } + return sMyPid; + } + + /** + * Check if the intent is coming from this process + * + * @param intent the incoming intent for the service + * @return true if the intent is from the current process + */ + private static boolean fromThisProcess(final Intent intent) { + final int pid = intent.getIntExtra(EXTRA_MYPID, -1); + return pid == getMyPid(); + } + + // Request execution thread pools. One thread pool for sending and one for downloading. + // The size of the thread pool controls the parallelism of request execution. + // See {@link setThreadPoolSize} + private ExecutorService[] mExecutors = new ExecutorService[2]; + + // Active request count + private int mActiveRequestCount; + // The latest intent startId, used for safely stopping service + private int mLastStartId; + + private MmsNetworkManager mNetworkManager; + + // Handler for scheduling service stop + private final Handler mHandler = new Handler(); + // Service stop task + private final Runnable mServiceStopRunnable = new Runnable() { + @Override + public void run() { + tryStopService(); + } + }; + + /** + * Start the service with a request + * + * @param context the Context to use + * @param request the request to start + */ + public static void startRequest(final Context context, final MmsRequest request) { + final boolean useWakeLock = sUseWakeLock; + request.setUseWakeLock(useWakeLock); + final Intent intent = new Intent(context, MmsService.class); + intent.putExtra(EXTRA_REQUEST, request); + intent.putExtra(EXTRA_MYPID, getMyPid()); + if (useWakeLock) { + acquireWakeLock(context); + } + if (context.startService(intent) == null) { + if (useWakeLock) { + releaseWakeLock(); + } + } + } + + @Override + public void onCreate() { + super.onCreate(); + + ensureLoaders(this); + + for (int i = 0; i < mExecutors.length; i++) { + mExecutors[i] = Executors.newFixedThreadPool(sThreadPoolSize); + } + + mNetworkManager = new MmsNetworkManager(this); + + synchronized (this) { + mActiveRequestCount = 0; + mLastStartId = -1; + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + + for (ExecutorService executor : mExecutors) { + executor.shutdown(); + } + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + // Always remember the latest startId for use when we try releasing the service + synchronized (this) { + mLastStartId = startId; + } + boolean scheduled = false; + if (intent != null) { + // There is a rare situation that right after a intent is started, + // the service gets killed. Then the service will restart with + // the old intent which we don't want it to run since it will + // break our assumption for wake lock. Check the process ID + // embedded in the intent to make sure it is indeed from the + // the current life of this service. + if (fromThisProcess(intent)) { + final MmsRequest request = intent.getParcelableExtra(EXTRA_REQUEST); + if (request != null) { + try { + retainService(request, new Runnable() { + @Override + public void run() { + try { + request.execute( + MmsService.this, + mNetworkManager, + getApnSettingsLoader(), + getCarrierConfigValuesLoader(), + getUserAgentInfoLoader()); + } catch (Exception e) { + Log.w(TAG, "Unexpected execution failure", e); + } finally { + if (request.getUseWakeLock()) { + releaseWakeLock(); + } + releaseService(); + } + } + }); + scheduled = true; + } catch (RejectedExecutionException e) { + // Rare thing happened. Send back failure using the pending intent + // and also release the wake lock. + Log.w(TAG, "Executing request failed " + e); + request.returnResult(this, SmsManager.MMS_ERROR_UNSPECIFIED, + null/*response*/, 0/*httpStatusCode*/); + if (request.getUseWakeLock()) { + releaseWakeLock(); + } + } + } else { + Log.w(TAG, "Empty request"); + } + } else { + Log.w(TAG, "Got a restarted intent from previous incarnation"); + } + } else { + Log.w(TAG, "Empty intent"); + } + if (!scheduled) { + // If the request is not started successfully, we need to try shutdown the service + // if nobody is using it. + tryScheduleStop(); + } + return START_NOT_STICKY; + } + + /** + * Retain the service for executing the request in service thread pool + * + * @param request The request to execute + * @param runnable The runnable to run the request in thread pool + */ + private void retainService(final MmsRequest request, final Runnable runnable) { + final ExecutorService executor = getRequestExecutor(request); + synchronized (this) { + executor.execute(runnable); + mActiveRequestCount++; + } + } + + /** + * Release the service from the request. If nobody is using it, schedule service stop. + */ + private void releaseService() { + synchronized (this) { + mActiveRequestCount--; + if (mActiveRequestCount <= 0) { + mActiveRequestCount = 0; + rescheduleServiceStop(); + } + } + } + + /** + * Schedule the service stop if there is no active request + */ + private void tryScheduleStop() { + synchronized (this) { + if (mActiveRequestCount == 0) { + rescheduleServiceStop(); + } + } + } + + /** + * Reschedule service stop task + */ + private void rescheduleServiceStop() { + mHandler.removeCallbacks(mServiceStopRunnable); + mHandler.postDelayed(mServiceStopRunnable, SERVICE_STOP_DELAY_MILLIS); + } + + /** + * Really try to stop the service if there is not active request + */ + private void tryStopService() { + Boolean stopped = null; + synchronized (this) { + if (mActiveRequestCount == 0) { + stopped = stopSelfResult(mLastStartId); + } + } + logServiceStop(stopped); + } + + /** + * Log the result of service stopping. Also check wake lock status when service stops. + * + * @param stopped Not empty if service stop is performed: true if really stopped, false + * if cancelled. + */ + private void logServiceStop(final Boolean stopped) { + if (stopped != null) { + if (stopped) { + Log.i(TAG, "Service successfully stopped"); + verifyWakeLockNotHeld(); + } else { + Log.i(TAG, "Service stopping cancelled"); + } + } + } + + private ExecutorService getRequestExecutor(final MmsRequest request) { + if (request instanceof SendRequest) { + // Send + return mExecutors[0]; + } else { + // Download + return mExecutors[1]; + } + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } +} diff --git a/src/android/support/v7/mms/MmsXmlResourceParser.java b/src/android/support/v7/mms/MmsXmlResourceParser.java new file mode 100644 index 0000000..1ee73e2 --- /dev/null +++ b/src/android/support/v7/mms/MmsXmlResourceParser.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +import android.util.Log; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; + +/** + * Base class for a parser of XML resources + */ +abstract class MmsXmlResourceParser { + /** + * Parse the content + * + * @throws IOException + * @throws XmlPullParserException + */ + protected abstract void parseRecord() throws IOException, XmlPullParserException; + + /** + * Get the root tag of the content + * + * @return the text of root tag + */ + protected abstract String getRootTag(); + + private final StringBuilder mLogStringBuilder = new StringBuilder(); + + protected final XmlPullParser mInputParser; + + protected MmsXmlResourceParser(XmlPullParser parser) { + mInputParser = parser; + } + + void parse() { + try { + // Find the first element + if (advanceToNextEvent(XmlPullParser.START_TAG) != XmlPullParser.START_TAG) { + throw new XmlPullParserException("ApnsXmlProcessor: expecting start tag @" + + xmlParserDebugContext()); + } + if (!getRootTag().equals(mInputParser.getName())) { + Log.w(MmsService.TAG, "Carrier config does not start with " + getRootTag()); + return; + } + // We are at the start tag + for (;;) { + int nextEvent; + // Skipping spaces + while ((nextEvent = mInputParser.next()) == XmlPullParser.TEXT); + if (nextEvent == XmlPullParser.START_TAG) { + // Parse one record + parseRecord(); + } else if (nextEvent == XmlPullParser.END_TAG) { + break; + } else { + throw new XmlPullParserException("Expecting start or end tag @" + + xmlParserDebugContext()); + } + } + } catch (IOException e) { + Log.w(MmsService.TAG, "XmlResourceParser: I/O failure", e); + } catch (XmlPullParserException e) { + Log.w(MmsService.TAG, "XmlResourceParser: parsing failure", e); + } + } + + /** + * Move XML parser forward to next event type or the end of doc + * + * @param eventType + * @return The final event type we meet + * @throws XmlPullParserException + * @throws IOException + */ + protected int advanceToNextEvent(int eventType) throws XmlPullParserException, IOException { + for (;;) { + int nextEvent = mInputParser.next(); + if (nextEvent == eventType + || nextEvent == XmlPullParser.END_DOCUMENT) { + return nextEvent; + } + } + } + + /** + * @return The debugging information of the parser's current position + */ + protected String xmlParserDebugContext() { + mLogStringBuilder.setLength(0); + if (mInputParser != null) { + try { + final int eventType = mInputParser.getEventType(); + mLogStringBuilder.append(xmlParserEventString(eventType)); + if (eventType == XmlPullParser.START_TAG + || eventType == XmlPullParser.END_TAG + || eventType == XmlPullParser.TEXT) { + mLogStringBuilder.append('<').append(mInputParser.getName()); + for (int i = 0; i < mInputParser.getAttributeCount(); i++) { + mLogStringBuilder.append(' ') + .append(mInputParser.getAttributeName(i)) + .append('=') + .append(mInputParser.getAttributeValue(i)); + } + mLogStringBuilder.append("/>"); + } + return mLogStringBuilder.toString(); + } catch (XmlPullParserException e) { + Log.w(MmsService.TAG, "XmlResourceParser exception", e); + } + } + return "Unknown"; + } + + private static String xmlParserEventString(int event) { + switch (event) { + case XmlPullParser.START_DOCUMENT: return "START_DOCUMENT"; + case XmlPullParser.END_DOCUMENT: return "END_DOCUMENT"; + case XmlPullParser.START_TAG: return "START_TAG"; + case XmlPullParser.END_TAG: return "END_TAG"; + case XmlPullParser.TEXT: return "TEXT"; + } + return Integer.toString(event); + } +} diff --git a/src/android/support/v7/mms/PhoneNumberHelper.java b/src/android/support/v7/mms/PhoneNumberHelper.java new file mode 100644 index 0000000..99551c0 --- /dev/null +++ b/src/android/support/v7/mms/PhoneNumberHelper.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +import android.text.TextUtils; +import android.util.Log; + +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; + +/** + * Helper methods for phone number formatting + * This is isolated into a standalone class since it depends on libphonenumber + */ +public class PhoneNumberHelper { + /** + * Given a phone number, get its national part without country code + * + * @param number the original number + * @param country the country ISO code + * @return the national number + */ + static String getNumberNoCountryCode(final String number, final String country) { + if (!TextUtils.isEmpty(number)) { + final PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance(); + try { + final Phonenumber.PhoneNumber phoneNumber = phoneNumberUtil.parse(number, country); + if (phoneNumber != null && phoneNumberUtil.isValidNumber(phoneNumber)) { + return phoneNumberUtil + .format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.NATIONAL) + .replaceAll("\\D", ""); + } + } catch (final NumberParseException e) { + Log.w(MmsService.TAG, "getNumberNoCountryCode: invalid number " + e); + } + } + return number; + } +} diff --git a/src/android/support/v7/mms/SendRequest.java b/src/android/support/v7/mms/SendRequest.java new file mode 100644 index 0000000..1b7b1db --- /dev/null +++ b/src/android/support/v7/mms/SendRequest.java @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +import android.app.PendingIntent; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcel; +import android.os.ParcelFileDescriptor; +import android.os.Parcelable; +import android.telephony.SmsManager; +import android.text.TextUtils; +import android.util.Log; + +import java.io.IOException; +import java.util.concurrent.Callable; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +/** + * Request to send an MMS + */ +class SendRequest extends MmsRequest { + // Max send response PDU size in bytes (exceeding this may cause problem with + // system intent delivery). + private static final int MAX_SEND_RESPONSE_SIZE = 1000 * 1024; + + private byte[] mPduData; + + SendRequest(final String locationUrl, final Uri pduUri, final PendingIntent sentIntent) { + super(locationUrl, pduUri, sentIntent); + } + + @Override + protected boolean loadRequest(final Context context, final Bundle mmsConfig) { + mPduData = readPduFromContentUri( + context, + mPduUri, + mmsConfig.getInt( + CarrierConfigValuesLoader.CONFIG_MAX_MESSAGE_SIZE, + CarrierConfigValuesLoader.CONFIG_MAX_MESSAGE_SIZE_DEFAULT)); + return (mPduData != null); + } + + @Override + protected boolean transferResponse(final Context context, final Intent fillIn, + final byte[] response) { + // SendConf pdus are always small and can be included in the intent + if (response != null && fillIn != null) { + if (response.length > MAX_SEND_RESPONSE_SIZE) { + // If the response PDU is too large, it won't be able to fit in + // the PendingIntent to be transferred via system IPC. + return false; + } + fillIn.putExtra(SmsManager.EXTRA_MMS_DATA, response); + } + return true; + } + + @Override + protected byte[] doHttp(Context context, MmsNetworkManager netMgr, ApnSettingsLoader.Apn apn, + Bundle mmsConfig, String userAgent, String uaProfUrl) throws MmsHttpException { + final MmsHttpClient httpClient = netMgr.getHttpClient(); + return httpClient.execute(getHttpRequestUrl(apn), mPduData, MmsHttpClient.METHOD_POST, + !TextUtils.isEmpty(apn.getMmsProxy()), apn.getMmsProxy(), apn.getMmsProxyPort(), + mmsConfig, userAgent, uaProfUrl); + } + + @Override + protected String getHttpRequestUrl(final ApnSettingsLoader.Apn apn) { + return !TextUtils.isEmpty(mLocationUrl) ? mLocationUrl : apn.getMmsc(); + } + + /** + * Read pdu from content provider uri + * + * @param contentUri content provider uri from which to read + * @param maxSize maximum number of bytes to read + * @return pdu bytes if succeeded else null + */ + public byte[] readPduFromContentUri(final Context context, final Uri contentUri, + final int maxSize) { + if (contentUri == null) { + return null; + } + final Callable copyPduToArray = new Callable() { + public byte[] call() { + ParcelFileDescriptor.AutoCloseInputStream inStream = null; + try { + final ContentResolver cr = context.getContentResolver(); + final ParcelFileDescriptor pduFd = cr.openFileDescriptor(contentUri, "r"); + inStream = new ParcelFileDescriptor.AutoCloseInputStream(pduFd); + // Request one extra byte to make sure file not bigger than maxSize + final byte[] readBuf = new byte[maxSize+1]; + final int bytesRead = inStream.read(readBuf, 0, maxSize+1); + if (bytesRead <= 0) { + Log.e(MmsService.TAG, "Reading PDU from sender: empty PDU"); + return null; + } + if (bytesRead > maxSize) { + Log.e(MmsService.TAG, "Reading PDU from sender: PDU too large"); + return null; + } + // Copy and return the exact length of bytes + final byte[] result = new byte[bytesRead]; + System.arraycopy(readBuf, 0, result, 0, bytesRead); + return result; + } catch (IOException e) { + Log.e(MmsService.TAG, "Reading PDU from sender: IO exception", e); + return null; + } finally { + if (inStream != null) { + try { + inStream.close(); + } catch (IOException ex) { + // Ignore + } + } + } + } + }; + final Future pendingResult = mPduTransferExecutor.submit(copyPduToArray); + try { + return pendingResult.get(TASK_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (Exception e) { + // Typically a timeout occurred - cancel task + pendingResult.cancel(true); + } + return null; + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + public SendRequest createFromParcel(Parcel in) { + return new SendRequest(in); + } + + public SendRequest[] newArray(int size) { + return new SendRequest[size]; + } + }; + + private SendRequest(Parcel in) { + super(in); + } +} diff --git a/src/android/support/v7/mms/UserAgentInfoLoader.java b/src/android/support/v7/mms/UserAgentInfoLoader.java new file mode 100644 index 0000000..214d58d --- /dev/null +++ b/src/android/support/v7/mms/UserAgentInfoLoader.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +/** + * Interface to load UserAgent and UA Prof URL + */ +public interface UserAgentInfoLoader { + // Carrier configuration keys for passing as config overrides into system MMS service + public static final String CONFIG_USER_AGENT = "userAgent"; + public static final String CONFIG_UA_PROF_URL = "uaProfUrl"; + + /** + * Get UserAgent value + * + * @return the text of UserAgent + */ + String getUserAgent(); + + /** + * Get UA Profile URL + * + * @return the URL of UA profile + */ + String getUAProfUrl(); +} diff --git a/src/android/support/v7/mms/Utils.java b/src/android/support/v7/mms/Utils.java new file mode 100644 index 0000000..cb55c2e --- /dev/null +++ b/src/android/support/v7/mms/Utils.java @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms; + +import android.content.Context; +import android.content.res.Configuration; +import android.os.Build; +import android.telephony.SmsManager; +import android.telephony.SubscriptionInfo; +import android.telephony.SubscriptionManager; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import android.util.Log; + +import java.net.MalformedURLException; +import java.net.URL; + +/** + * Utility methods + */ +class Utils { + /** + * Check if MMS API is available + * + * @return true if MMS API is available, false otherwise + */ + static boolean hasMmsApi() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP; + } + + /** + * Check if support multi-SIM + * + * @return true if MSIM is supported, false otherwise + */ + static boolean supportMSim() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1; + } + + /** + * Check if support APIs for getting UserAgent and UAProfUrl + * + * @return true if those APIs are supported, false otherwise + */ + static boolean hasUserAgentApi() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; + } + + /** + * Get system SmsManager + * + * @param subId the subscription ID of the SmsManager + * @return the SmsManager for the input subId + */ + static SmsManager getSmsManager(final int subId) { + if (supportMSim()) { + return SmsManager.getSmsManagerForSubscriptionId(subId); + } else { + return SmsManager.getDefault(); + } + } + + /** + * Get the real subscription ID if the input is -1 + * + * @param subId input subscription ID + * @return the default SMS subscription ID if the input is -1, otherwise the original + */ + static int getEffectiveSubscriptionId(int subId) { + if (supportMSim()) { + if (subId == MmsManager.DEFAULT_SUB_ID) { + subId = SmsManager.getDefaultSmsSubscriptionId(); + } + } + if (subId < 0) { + subId = MmsManager.DEFAULT_SUB_ID; + } + return subId; + } + + /** + * Get MCC/MNC of an SIM subscription + * + * @param context the Context to use + * @param subId the SIM subId + * @return a non-empty array with exactly two elements, first is mcc and last is mnc. + */ + static int[] getMccMnc(final Context context, final int subId) { + final int[] mccMnc = new int[] { 0, 0 }; + if (Utils.supportMSim()) { + final SubscriptionManager subscriptionManager = SubscriptionManager.from(context); + final SubscriptionInfo subInfo = subscriptionManager.getActiveSubscriptionInfo(subId); + if (subInfo != null) { + mccMnc[0] = subInfo.getMcc(); + mccMnc[1] = subInfo.getMnc(); + } + } else { + final TelephonyManager telephonyManager = + (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + final String mccMncString = telephonyManager.getSimOperator(); + try { + mccMnc[0] = Integer.parseInt(mccMncString.substring(0, 3)); + mccMnc[1] = Integer.parseInt(mccMncString.substring(3)); + } catch (Exception e) { + Log.w(MmsService.TAG, "Invalid mcc/mnc from system " + mccMncString + ": " + e); + mccMnc[0] = 0; + mccMnc[1] = 0; + } + } + return mccMnc; + } + + /** + * Get a subscription's Context so we can load resources from it + * + * @param context the sub-independent Context + * @param subId the SIM's subId + * @return the sub-dependent Context + */ + static Context getSubDepContext(final Context context, final int subId) { + if (!supportMSim()) { + return context; + } + final int[] mccMnc = getMccMnc(context, subId); + final int mcc = mccMnc[0]; + final int mnc = mccMnc[1]; + if (mcc == 0 && mnc == 0) { + return context; + } + final Configuration subConfig = new Configuration(); + subConfig.mcc = mcc; + subConfig.mnc = mnc; + return context.createConfigurationContext(subConfig); + } + + /** + * Redact the URL for non-VERBOSE logging. Replace url with only the host part and the length + * of the input URL string. + * + * @param urlString + * @return + */ + static String redactUrlForNonVerbose(String urlString) { + if (Log.isLoggable(MmsService.TAG, Log.VERBOSE)) { + // Don't redact for VERBOSE level logging + return urlString; + } + if (TextUtils.isEmpty(urlString)) { + return urlString; + } + String protocol = "http"; + String host = ""; + try { + final URL url = new URL(urlString); + protocol = url.getProtocol(); + host = url.getHost(); + } catch (MalformedURLException e) { + // Ignore + } + // Print "http://host[length]" + final StringBuilder sb = new StringBuilder(); + sb.append(protocol).append("://").append(host) + .append("[").append(urlString.length()).append("]"); + return sb.toString(); + } +} diff --git a/src/android/support/v7/mms/pdu/AcknowledgeInd.java b/src/android/support/v7/mms/pdu/AcknowledgeInd.java new file mode 100644 index 0000000..10f9e35 --- /dev/null +++ b/src/android/support/v7/mms/pdu/AcknowledgeInd.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +/** + * M-Acknowledge.ind PDU. + */ +public class AcknowledgeInd extends GenericPdu { + /** + * Constructor, used when composing a M-Acknowledge.ind pdu. + * + * @param mmsVersion current viersion of mms + * @param transactionId the transaction-id value + * @throws InvalidHeaderValueException if parameters are invalid. + * NullPointerException if transactionId is null. + */ + public AcknowledgeInd(int mmsVersion, byte[] transactionId) + throws InvalidHeaderValueException { + super(); + + setMessageType(PduHeaders.MESSAGE_TYPE_ACKNOWLEDGE_IND); + setMmsVersion(mmsVersion); + setTransactionId(transactionId); + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + AcknowledgeInd(PduHeaders headers) { + super(headers); + } + + /** + * Get X-Mms-Report-Allowed field value. + * + * @return the X-Mms-Report-Allowed value + */ + public int getReportAllowed() { + return mPduHeaders.getOctet(PduHeaders.REPORT_ALLOWED); + } + + /** + * Set X-Mms-Report-Allowed field value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setReportAllowed(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.REPORT_ALLOWED); + } + + /** + * Get X-Mms-Transaction-Id field value. + * + * @return the X-Mms-Report-Allowed value + */ + public byte[] getTransactionId() { + return mPduHeaders.getTextString(PduHeaders.TRANSACTION_ID); + } + + /** + * Set X-Mms-Transaction-Id field value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setTransactionId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.TRANSACTION_ID); + } +} diff --git a/src/android/support/v7/mms/pdu/Base64.java b/src/android/support/v7/mms/pdu/Base64.java new file mode 100644 index 0000000..9f3418f --- /dev/null +++ b/src/android/support/v7/mms/pdu/Base64.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +public class Base64 { + /** + * Used to get the number of Quadruples. + */ + static final int FOURBYTE = 4; + + /** + * Byte used to pad output. + */ + static final byte PAD = (byte) '='; + + /** + * The base length. + */ + static final int BASELENGTH = 255; + + // Create arrays to hold the base64 characters + private static byte[] base64Alphabet = new byte[BASELENGTH]; + + // Populating the character arrays + static { + for (int i = 0; i < BASELENGTH; i++) { + base64Alphabet[i] = (byte) -1; + } + for (int i = 'Z'; i >= 'A'; i--) { + base64Alphabet[i] = (byte) (i - 'A'); + } + for (int i = 'z'; i >= 'a'; i--) { + base64Alphabet[i] = (byte) (i - 'a' + 26); + } + for (int i = '9'; i >= '0'; i--) { + base64Alphabet[i] = (byte) (i - '0' + 52); + } + + base64Alphabet['+'] = 62; + base64Alphabet['/'] = 63; + } + + /** + * Decodes Base64 data into octects + * + * @param base64Data Byte array containing Base64 data + * @return Array containing decoded data. + */ + public static byte[] decodeBase64(byte[] base64Data) { + // RFC 2045 requires that we discard ALL non-Base64 characters + base64Data = discardNonBase64(base64Data); + + // handle the edge case, so we don't have to worry about it later + if (base64Data.length == 0) { + return new byte[0]; + } + + int numberQuadruple = base64Data.length / FOURBYTE; + byte decodedData[] = null; + byte b1 = 0, b2 = 0, b3 = 0, b4 = 0, marker0 = 0, marker1 = 0; + + // Throw away anything not in base64Data + + int encodedIndex = 0; + int dataIndex = 0; + { + // this sizes the output array properly - rlw + int lastData = base64Data.length; + // ignore the '=' padding + while (base64Data[lastData - 1] == PAD) { + if (--lastData == 0) { + return new byte[0]; + } + } + decodedData = new byte[lastData - numberQuadruple]; + } + + for (int i = 0; i < numberQuadruple; i++) { + dataIndex = i * 4; + marker0 = base64Data[dataIndex + 2]; + marker1 = base64Data[dataIndex + 3]; + + b1 = base64Alphabet[base64Data[dataIndex]]; + b2 = base64Alphabet[base64Data[dataIndex + 1]]; + + if (marker0 != PAD && marker1 != PAD) { + //No PAD e.g 3cQl + b3 = base64Alphabet[marker0]; + b4 = base64Alphabet[marker1]; + + decodedData[encodedIndex] = (byte) (b1 << 2 | b2 >> 4); + decodedData[encodedIndex + 1] = + (byte) (((b2 & 0xf) << 4) | ((b3 >> 2) & 0xf)); + decodedData[encodedIndex + 2] = (byte) (b3 << 6 | b4); + } else if (marker0 == PAD) { + //Two PAD e.g. 3c[Pad][Pad] + decodedData[encodedIndex] = (byte) (b1 << 2 | b2 >> 4); + } else if (marker1 == PAD) { + //One PAD e.g. 3cQ[Pad] + b3 = base64Alphabet[marker0]; + + decodedData[encodedIndex] = (byte) (b1 << 2 | b2 >> 4); + decodedData[encodedIndex + 1] = + (byte) (((b2 & 0xf) << 4) | ((b3 >> 2) & 0xf)); + } + encodedIndex += 3; + } + return decodedData; + } + + /** + * Check octect wheter it is a base64 encoding. + * + * @param octect to be checked byte + * @return ture if it is base64 encoding, false otherwise. + */ + private static boolean isBase64(byte octect) { + if (octect == PAD) { + return true; + } else if (base64Alphabet[octect] == -1) { + return false; + } else { + return true; + } + } + + /** + * Discards any characters outside of the base64 alphabet, per + * the requirements on page 25 of RFC 2045 - "Any characters + * outside of the base64 alphabet are to be ignored in base64 + * encoded data." + * + * @param data The base-64 encoded data to groom + * @return The data, less non-base64 characters (see RFC 2045). + */ + static byte[] discardNonBase64(byte[] data) { + byte groomedData[] = new byte[data.length]; + int bytesCopied = 0; + + for (int i = 0; i < data.length; i++) { + if (isBase64(data[i])) { + groomedData[bytesCopied++] = data[i]; + } + } + + byte packedData[] = new byte[bytesCopied]; + + System.arraycopy(groomedData, 0, packedData, 0, bytesCopied); + + return packedData; + } +} diff --git a/src/android/support/v7/mms/pdu/CharacterSets.java b/src/android/support/v7/mms/pdu/CharacterSets.java new file mode 100644 index 0000000..8ce2a02 --- /dev/null +++ b/src/android/support/v7/mms/pdu/CharacterSets.java @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +import java.io.UnsupportedEncodingException; +import java.util.HashMap; + +public class CharacterSets { + /** + * IANA assigned MIB enum numbers. + * + * From wap-230-wsp-20010705-a.pdf + * Any-charset = + * Equivalent to the special RFC2616 charset value "*" + */ + public static final int ANY_CHARSET = 0x00; + public static final int US_ASCII = 0x03; + public static final int ISO_8859_1 = 0x04; + public static final int ISO_8859_2 = 0x05; + public static final int ISO_8859_3 = 0x06; + public static final int ISO_8859_4 = 0x07; + public static final int ISO_8859_5 = 0x08; + public static final int ISO_8859_6 = 0x09; + public static final int ISO_8859_7 = 0x0A; + public static final int ISO_8859_8 = 0x0B; + public static final int ISO_8859_9 = 0x0C; + public static final int SHIFT_JIS = 0x11; + public static final int UTF_8 = 0x6A; + public static final int BIG5 = 0x07EA; + public static final int UCS2 = 0x03E8; + public static final int UTF_16 = 0x03F7; + + /** + * If the encoding of given data is unsupported, use UTF_8 to decode it. + */ + public static final int DEFAULT_CHARSET = UTF_8; + + /** + * Array of MIB enum numbers. + */ + private static final int[] MIBENUM_NUMBERS = { + ANY_CHARSET, + US_ASCII, + ISO_8859_1, + ISO_8859_2, + ISO_8859_3, + ISO_8859_4, + ISO_8859_5, + ISO_8859_6, + ISO_8859_7, + ISO_8859_8, + ISO_8859_9, + SHIFT_JIS, + UTF_8, + BIG5, + UCS2, + UTF_16, + }; + + /** + * The Well-known-charset Mime name. + */ + public static final String MIMENAME_ANY_CHARSET = "*"; + public static final String MIMENAME_US_ASCII = "us-ascii"; + public static final String MIMENAME_ISO_8859_1 = "iso-8859-1"; + public static final String MIMENAME_ISO_8859_2 = "iso-8859-2"; + public static final String MIMENAME_ISO_8859_3 = "iso-8859-3"; + public static final String MIMENAME_ISO_8859_4 = "iso-8859-4"; + public static final String MIMENAME_ISO_8859_5 = "iso-8859-5"; + public static final String MIMENAME_ISO_8859_6 = "iso-8859-6"; + public static final String MIMENAME_ISO_8859_7 = "iso-8859-7"; + public static final String MIMENAME_ISO_8859_8 = "iso-8859-8"; + public static final String MIMENAME_ISO_8859_9 = "iso-8859-9"; + public static final String MIMENAME_SHIFT_JIS = "shift_JIS"; + public static final String MIMENAME_UTF_8 = "utf-8"; + public static final String MIMENAME_BIG5 = "big5"; + public static final String MIMENAME_UCS2 = "iso-10646-ucs-2"; + public static final String MIMENAME_UTF_16 = "utf-16"; + + public static final String DEFAULT_CHARSET_NAME = MIMENAME_UTF_8; + + /** + * Array of the names of character sets. + */ + private static final String[] MIME_NAMES = { + MIMENAME_ANY_CHARSET, + MIMENAME_US_ASCII, + MIMENAME_ISO_8859_1, + MIMENAME_ISO_8859_2, + MIMENAME_ISO_8859_3, + MIMENAME_ISO_8859_4, + MIMENAME_ISO_8859_5, + MIMENAME_ISO_8859_6, + MIMENAME_ISO_8859_7, + MIMENAME_ISO_8859_8, + MIMENAME_ISO_8859_9, + MIMENAME_SHIFT_JIS, + MIMENAME_UTF_8, + MIMENAME_BIG5, + MIMENAME_UCS2, + MIMENAME_UTF_16, + }; + + private static final HashMap MIBENUM_TO_NAME_MAP; + private static final HashMap NAME_TO_MIBENUM_MAP; + + static { + // Create the HashMaps. + MIBENUM_TO_NAME_MAP = new HashMap(); + NAME_TO_MIBENUM_MAP = new HashMap(); + assert(MIBENUM_NUMBERS.length == MIME_NAMES.length); + int count = MIBENUM_NUMBERS.length - 1; + for(int i = 0; i <= count; i++) { + MIBENUM_TO_NAME_MAP.put(MIBENUM_NUMBERS[i], MIME_NAMES[i]); + NAME_TO_MIBENUM_MAP.put(MIME_NAMES[i], MIBENUM_NUMBERS[i]); + } + } + + private CharacterSets() {} // Non-instantiatable + + /** + * Map an MIBEnum number to the name of the charset which this number + * is assigned to by IANA. + * + * @param mibEnumValue An IANA assigned MIBEnum number. + * @return The name string of the charset. + * @throws UnsupportedEncodingException + */ + public static String getMimeName(int mibEnumValue) + throws UnsupportedEncodingException { + String name = MIBENUM_TO_NAME_MAP.get(mibEnumValue); + if (name == null) { + throw new UnsupportedEncodingException(); + } + return name; + } + + /** + * Map a well-known charset name to its assigned MIBEnum number. + * + * @param mimeName The charset name. + * @return The MIBEnum number assigned by IANA for this charset. + * @throws UnsupportedEncodingException + */ + public static int getMibEnumValue(String mimeName) + throws UnsupportedEncodingException { + if(null == mimeName) { + return -1; + } + + Integer mibEnumValue = NAME_TO_MIBENUM_MAP.get(mimeName); + if (mibEnumValue == null) { + throw new UnsupportedEncodingException(); + } + return mibEnumValue; + } +} diff --git a/src/android/support/v7/mms/pdu/ContentType.java b/src/android/support/v7/mms/pdu/ContentType.java new file mode 100644 index 0000000..7ba961d --- /dev/null +++ b/src/android/support/v7/mms/pdu/ContentType.java @@ -0,0 +1,230 @@ +/* + * Copyright (C) 2007-2008 Esmertec AG. + * Copyright (C) 2007-2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +import java.util.ArrayList; + +public class ContentType { + public static final String MMS_MESSAGE = "application/vnd.wap.mms-message"; + // The phony content type for generic PDUs (e.g. ReadOrig.ind, + // Notification.ind, Delivery.ind). + public static final String MMS_GENERIC = "application/vnd.wap.mms-generic"; + public static final String MULTIPART_MIXED = "application/vnd.wap.multipart.mixed"; + public static final String MULTIPART_RELATED = "application/vnd.wap.multipart.related"; + public static final String MULTIPART_ALTERNATIVE = "application/vnd.wap.multipart.alternative"; + + public static final String TEXT_PLAIN = "text/plain"; + public static final String TEXT_HTML = "text/html"; + public static final String TEXT_VCALENDAR = "text/x-vCalendar"; + public static final String TEXT_VCARD = "text/x-vCard"; + + public static final String IMAGE_UNSPECIFIED = "image/*"; + public static final String IMAGE_JPEG = "image/jpeg"; + public static final String IMAGE_JPG = "image/jpg"; + public static final String IMAGE_GIF = "image/gif"; + public static final String IMAGE_WBMP = "image/vnd.wap.wbmp"; + public static final String IMAGE_PNG = "image/png"; + public static final String IMAGE_X_MS_BMP = "image/x-ms-bmp"; + + public static final String AUDIO_UNSPECIFIED = "audio/*"; + public static final String AUDIO_AAC = "audio/aac"; + public static final String AUDIO_AMR = "audio/amr"; + public static final String AUDIO_IMELODY = "audio/imelody"; + public static final String AUDIO_MID = "audio/mid"; + public static final String AUDIO_MIDI = "audio/midi"; + public static final String AUDIO_MP3 = "audio/mp3"; + public static final String AUDIO_MPEG3 = "audio/mpeg3"; + public static final String AUDIO_MPEG = "audio/mpeg"; + public static final String AUDIO_MPG = "audio/mpg"; + public static final String AUDIO_MP4 = "audio/mp4"; + public static final String AUDIO_X_MID = "audio/x-mid"; + public static final String AUDIO_X_MIDI = "audio/x-midi"; + public static final String AUDIO_X_MP3 = "audio/x-mp3"; + public static final String AUDIO_X_MPEG3 = "audio/x-mpeg3"; + public static final String AUDIO_X_MPEG = "audio/x-mpeg"; + public static final String AUDIO_X_MPG = "audio/x-mpg"; + public static final String AUDIO_3GPP = "audio/3gpp"; + public static final String AUDIO_X_WAV = "audio/x-wav"; + public static final String AUDIO_OGG = "application/ogg"; + + public static final String VIDEO_UNSPECIFIED = "video/*"; + public static final String VIDEO_3GPP = "video/3gpp"; + public static final String VIDEO_3G2 = "video/3gpp2"; + public static final String VIDEO_H263 = "video/h263"; + public static final String VIDEO_MP4 = "video/mp4"; + + public static final String APP_SMIL = "application/smil"; + public static final String APP_WAP_XHTML = "application/vnd.wap.xhtml+xml"; + public static final String APP_XHTML = "application/xhtml+xml"; + + public static final String APP_DRM_CONTENT = "application/vnd.oma.drm.content"; + public static final String APP_DRM_MESSAGE = "application/vnd.oma.drm.message"; + + private static final ArrayList sSupportedContentTypes = new ArrayList(); + private static final ArrayList sSupportedImageTypes = new ArrayList(); + private static final ArrayList sSupportedAudioTypes = new ArrayList(); + private static final ArrayList sSupportedVideoTypes = new ArrayList(); + + static { + sSupportedContentTypes.add(TEXT_PLAIN); + sSupportedContentTypes.add(TEXT_HTML); + sSupportedContentTypes.add(TEXT_VCALENDAR); + sSupportedContentTypes.add(TEXT_VCARD); + + sSupportedContentTypes.add(IMAGE_JPEG); + sSupportedContentTypes.add(IMAGE_GIF); + sSupportedContentTypes.add(IMAGE_WBMP); + sSupportedContentTypes.add(IMAGE_PNG); + sSupportedContentTypes.add(IMAGE_JPG); + sSupportedContentTypes.add(IMAGE_X_MS_BMP); + //supportedContentTypes.add(IMAGE_SVG); not yet supported. + + sSupportedContentTypes.add(AUDIO_AAC); + sSupportedContentTypes.add(AUDIO_AMR); + sSupportedContentTypes.add(AUDIO_IMELODY); + sSupportedContentTypes.add(AUDIO_MID); + sSupportedContentTypes.add(AUDIO_MIDI); + sSupportedContentTypes.add(AUDIO_MP3); + sSupportedContentTypes.add(AUDIO_MP4); + sSupportedContentTypes.add(AUDIO_MPEG3); + sSupportedContentTypes.add(AUDIO_MPEG); + sSupportedContentTypes.add(AUDIO_MPG); + sSupportedContentTypes.add(AUDIO_X_MID); + sSupportedContentTypes.add(AUDIO_X_MIDI); + sSupportedContentTypes.add(AUDIO_X_MP3); + sSupportedContentTypes.add(AUDIO_X_MPEG3); + sSupportedContentTypes.add(AUDIO_X_MPEG); + sSupportedContentTypes.add(AUDIO_X_MPG); + sSupportedContentTypes.add(AUDIO_X_WAV); + sSupportedContentTypes.add(AUDIO_3GPP); + sSupportedContentTypes.add(AUDIO_OGG); + + sSupportedContentTypes.add(VIDEO_3GPP); + sSupportedContentTypes.add(VIDEO_3G2); + sSupportedContentTypes.add(VIDEO_H263); + sSupportedContentTypes.add(VIDEO_MP4); + + sSupportedContentTypes.add(APP_SMIL); + sSupportedContentTypes.add(APP_WAP_XHTML); + sSupportedContentTypes.add(APP_XHTML); + + sSupportedContentTypes.add(APP_DRM_CONTENT); + sSupportedContentTypes.add(APP_DRM_MESSAGE); + + // add supported image types + sSupportedImageTypes.add(IMAGE_JPEG); + sSupportedImageTypes.add(IMAGE_GIF); + sSupportedImageTypes.add(IMAGE_WBMP); + sSupportedImageTypes.add(IMAGE_PNG); + sSupportedImageTypes.add(IMAGE_JPG); + sSupportedImageTypes.add(IMAGE_X_MS_BMP); + + // add supported audio types + sSupportedAudioTypes.add(AUDIO_AAC); + sSupportedAudioTypes.add(AUDIO_AMR); + sSupportedAudioTypes.add(AUDIO_IMELODY); + sSupportedAudioTypes.add(AUDIO_MID); + sSupportedAudioTypes.add(AUDIO_MIDI); + sSupportedAudioTypes.add(AUDIO_MP3); + sSupportedAudioTypes.add(AUDIO_MPEG3); + sSupportedAudioTypes.add(AUDIO_MPEG); + sSupportedAudioTypes.add(AUDIO_MPG); + sSupportedAudioTypes.add(AUDIO_MP4); + sSupportedAudioTypes.add(AUDIO_X_MID); + sSupportedAudioTypes.add(AUDIO_X_MIDI); + sSupportedAudioTypes.add(AUDIO_X_MP3); + sSupportedAudioTypes.add(AUDIO_X_MPEG3); + sSupportedAudioTypes.add(AUDIO_X_MPEG); + sSupportedAudioTypes.add(AUDIO_X_MPG); + sSupportedAudioTypes.add(AUDIO_X_WAV); + sSupportedAudioTypes.add(AUDIO_3GPP); + sSupportedAudioTypes.add(AUDIO_OGG); + + // add supported video types + sSupportedVideoTypes.add(VIDEO_3GPP); + sSupportedVideoTypes.add(VIDEO_3G2); + sSupportedVideoTypes.add(VIDEO_H263); + sSupportedVideoTypes.add(VIDEO_MP4); + } + + // This class should never be instantiated. + private ContentType() { + } + + public static boolean isSupportedType(String contentType) { + return (null != contentType) && sSupportedContentTypes.contains(contentType); + } + + public static boolean isSupportedImageType(String contentType) { + return isImageType(contentType) && isSupportedType(contentType); + } + + public static boolean isSupportedAudioType(String contentType) { + return isAudioType(contentType) && isSupportedType(contentType); + } + + public static boolean isSupportedVideoType(String contentType) { + return isVideoType(contentType) && isSupportedType(contentType); + } + + public static boolean isTextType(String contentType) { + return (null != contentType) && contentType.startsWith("text/"); + } + + public static boolean isImageType(String contentType) { + return (null != contentType) && contentType.startsWith("image/"); + } + + public static boolean isAudioType(String contentType) { + return (null != contentType) && contentType.startsWith("audio/"); + } + + public static boolean isVideoType(String contentType) { + return (null != contentType) && contentType.startsWith("video/"); + } + + public static boolean isDrmType(String contentType) { + return (null != contentType) + && (contentType.equals(APP_DRM_CONTENT) + || contentType.equals(APP_DRM_MESSAGE)); + } + + public static boolean isUnspecified(String contentType) { + return (null != contentType) && contentType.endsWith("*"); + } + + @SuppressWarnings("unchecked") + public static ArrayList getImageTypes() { + return (ArrayList) sSupportedImageTypes.clone(); + } + + @SuppressWarnings("unchecked") + public static ArrayList getAudioTypes() { + return (ArrayList) sSupportedAudioTypes.clone(); + } + + @SuppressWarnings("unchecked") + public static ArrayList getVideoTypes() { + return (ArrayList) sSupportedVideoTypes.clone(); + } + + @SuppressWarnings("unchecked") + public static ArrayList getSupportedTypes() { + return (ArrayList) sSupportedContentTypes.clone(); + } +} diff --git a/src/android/support/v7/mms/pdu/DeliveryInd.java b/src/android/support/v7/mms/pdu/DeliveryInd.java new file mode 100644 index 0000000..d7d72d9 --- /dev/null +++ b/src/android/support/v7/mms/pdu/DeliveryInd.java @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +/** + * M-Delivery.Ind Pdu. + */ +public class DeliveryInd extends GenericPdu { + /** + * Empty constructor. + * Since the Pdu corresponding to this class is constructed + * by the Proxy-Relay server, this class is only instantiated + * by the Pdu Parser. + * + * @throws InvalidHeaderValueException if error occurs. + */ + public DeliveryInd() throws InvalidHeaderValueException { + super(); + setMessageType(PduHeaders.MESSAGE_TYPE_DELIVERY_IND); + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + DeliveryInd(PduHeaders headers) { + super(headers); + } + + /** + * Get Date value. + * + * @return the value + */ + public long getDate() { + return mPduHeaders.getLongInteger(PduHeaders.DATE); + } + + /** + * Set Date value. + * + * @param value the value + */ + public void setDate(long value) { + mPduHeaders.setLongInteger(value, PduHeaders.DATE); + } + + /** + * Get Message-ID value. + * + * @return the value + */ + public byte[] getMessageId() { + return mPduHeaders.getTextString(PduHeaders.MESSAGE_ID); + } + + /** + * Set Message-ID value. + * + * @param value the value, should not be null + * @throws NullPointerException if the value is null. + */ + public void setMessageId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.MESSAGE_ID); + } + + /** + * Get Status value. + * + * @return the value + */ + public int getStatus() { + return mPduHeaders.getOctet(PduHeaders.STATUS); + } + + /** + * Set Status value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setStatus(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.STATUS); + } + + /** + * Get To value. + * + * @return the value + */ + public EncodedStringValue[] getTo() { + return mPduHeaders.getEncodedStringValues(PduHeaders.TO); + } + + /** + * set To value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setTo(EncodedStringValue[] value) { + mPduHeaders.setEncodedStringValues(value, PduHeaders.TO); + } + + /* + * Optional, not supported header fields: + * + * public byte[] getApplicId() {return null;} + * public void setApplicId(byte[] value) {} + * + * public byte[] getAuxApplicId() {return null;} + * public void getAuxApplicId(byte[] value) {} + * + * public byte[] getReplyApplicId() {return 0x00;} + * public void setReplyApplicId(byte[] value) {} + * + * public EncodedStringValue getStatusText() {return null;} + * public void setStatusText(EncodedStringValue value) {} + */ +} diff --git a/src/android/support/v7/mms/pdu/EncodedStringValue.java b/src/android/support/v7/mms/pdu/EncodedStringValue.java new file mode 100644 index 0000000..d8fcacf --- /dev/null +++ b/src/android/support/v7/mms/pdu/EncodedStringValue.java @@ -0,0 +1,283 @@ +/* + * Copyright (C) 2007-2008 Esmertec AG. + * Copyright (C) 2007-2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +import android.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; + +/** + * Encoded-string-value = Text-string | Value-length Char-set Text-string + */ +public class EncodedStringValue implements Cloneable { + private static final String TAG = "EncodedStringValue"; + private static final boolean DEBUG = false; + private static final boolean LOCAL_LOGV = false; + + /** + * The Char-set value. + */ + private int mCharacterSet; + + /** + * The Text-string value. + */ + private byte[] mData; + + /** + * Constructor. + * + * @param charset the Char-set value + * @param data the Text-string value + * @throws NullPointerException if Text-string value is null. + */ + public EncodedStringValue(int charset, byte[] data) { + // TODO: CharSet needs to be validated against MIBEnum. + if(null == data) { + throw new NullPointerException("EncodedStringValue: Text-string is null."); + } + + mCharacterSet = charset; + mData = new byte[data.length]; + System.arraycopy(data, 0, mData, 0, data.length); + } + + /** + * Constructor. + * + * @param data the Text-string value + * @throws NullPointerException if Text-string value is null. + */ + public EncodedStringValue(byte[] data) { + this(CharacterSets.DEFAULT_CHARSET, data); + } + + public EncodedStringValue(String data) { + try { + mData = data.getBytes(CharacterSets.DEFAULT_CHARSET_NAME); + mCharacterSet = CharacterSets.DEFAULT_CHARSET; + } catch (UnsupportedEncodingException e) { + Log.e(TAG, "Default encoding must be supported.", e); + } + } + + /** + * Get Char-set value. + * + * @return the value + */ + public int getCharacterSet() { + return mCharacterSet; + } + + /** + * Set Char-set value. + * + * @param charset the Char-set value + */ + public void setCharacterSet(int charset) { + // TODO: CharSet needs to be validated against MIBEnum. + mCharacterSet = charset; + } + + /** + * Get Text-string value. + * + * @return the value + */ + public byte[] getTextString() { + byte[] byteArray = new byte[mData.length]; + + System.arraycopy(mData, 0, byteArray, 0, mData.length); + return byteArray; + } + + /** + * Set Text-string value. + * + * @param textString the Text-string value + * @throws NullPointerException if Text-string value is null. + */ + public void setTextString(byte[] textString) { + if(null == textString) { + throw new NullPointerException("EncodedStringValue: Text-string is null."); + } + + mData = new byte[textString.length]; + System.arraycopy(textString, 0, mData, 0, textString.length); + } + + /** + * Convert this object to a {@link java.lang.String}. If the encoding of + * the EncodedStringValue is null or unsupported, it will be + * treated as iso-8859-1 encoding. + * + * @return The decoded String. + */ + public String getString() { + if (CharacterSets.ANY_CHARSET == mCharacterSet) { + return new String(mData); // system default encoding. + } else { + try { + String name = CharacterSets.getMimeName(mCharacterSet); + return new String(mData, name); + } catch (UnsupportedEncodingException e) { + if (LOCAL_LOGV) { + Log.v(TAG, e.getMessage(), e); + } + try { + return new String(mData, CharacterSets.MIMENAME_ISO_8859_1); + } catch (UnsupportedEncodingException _) { + return new String(mData); // system default encoding. + } + } + } + } + + /** + * Append to Text-string. + * + * @param textString the textString to append + * @throws NullPointerException if the text String is null + * or an IOException occured. + */ + public void appendTextString(byte[] textString) { + if(null == textString) { + throw new NullPointerException("Text-string is null."); + } + + if(null == mData) { + mData = new byte[textString.length]; + System.arraycopy(textString, 0, mData, 0, textString.length); + } else { + ByteArrayOutputStream newTextString = new ByteArrayOutputStream(); + try { + newTextString.write(mData); + newTextString.write(textString); + } catch (IOException e) { + e.printStackTrace(); + throw new NullPointerException( + "appendTextString: failed when write a new Text-string"); + } + + mData = newTextString.toByteArray(); + } + } + + /* + * (non-Javadoc) + * @see java.lang.Object#clone() + */ + @Override + public Object clone() throws CloneNotSupportedException { + super.clone(); + int len = mData.length; + byte[] dstBytes = new byte[len]; + System.arraycopy(mData, 0, dstBytes, 0, len); + + try { + return new EncodedStringValue(mCharacterSet, dstBytes); + } catch (Exception e) { + Log.e(TAG, "failed to clone an EncodedStringValue: " + this); + e.printStackTrace(); + throw new CloneNotSupportedException(e.getMessage()); + } + } + + /** + * Split this encoded string around matches of the given pattern. + * + * @param pattern the delimiting pattern + * @return the array of encoded strings computed by splitting this encoded + * string around matches of the given pattern + */ + public EncodedStringValue[] split(String pattern) { + String[] temp = getString().split(pattern); + EncodedStringValue[] ret = new EncodedStringValue[temp.length]; + for (int i = 0; i < ret.length; ++i) { + try { + ret[i] = new EncodedStringValue(mCharacterSet, + temp[i].getBytes()); + } catch (NullPointerException _) { + // Can't arrive here + return null; + } + } + return ret; + } + + /** + * Extract an EncodedStringValue[] from a given String. + */ + public static EncodedStringValue[] extract(String src) { + String[] values = src.split(";"); + + ArrayList list = new ArrayList(); + for (int i = 0; i < values.length; i++) { + if (values[i].length() > 0) { + list.add(new EncodedStringValue(values[i])); + } + } + + int len = list.size(); + if (len > 0) { + return list.toArray(new EncodedStringValue[len]); + } else { + return null; + } + } + + /** + * Concatenate an EncodedStringValue[] into a single String. + */ + public static String concat(EncodedStringValue[] addr) { + StringBuilder sb = new StringBuilder(); + int maxIndex = addr.length - 1; + for (int i = 0; i <= maxIndex; i++) { + sb.append(addr[i].getString()); + if (i < maxIndex) { + sb.append(";"); + } + } + + return sb.toString(); + } + + public static EncodedStringValue copy(EncodedStringValue value) { + if (value == null) { + return null; + } + + return new EncodedStringValue(value.mCharacterSet, value.mData); + } + + public static EncodedStringValue[] encodeStrings(String[] array) { + int count = array.length; + if (count > 0) { + EncodedStringValue[] encodedArray = new EncodedStringValue[count]; + for (int i = 0; i < count; i++) { + encodedArray[i] = new EncodedStringValue(array[i]); + } + return encodedArray; + } + return null; + } +} diff --git a/src/android/support/v7/mms/pdu/GenericPdu.java b/src/android/support/v7/mms/pdu/GenericPdu.java new file mode 100644 index 0000000..2f0d167 --- /dev/null +++ b/src/android/support/v7/mms/pdu/GenericPdu.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +public class GenericPdu { + /** + * The headers of pdu. + */ + PduHeaders mPduHeaders = null; + + /** + * Constructor. + */ + public GenericPdu() { + mPduHeaders = new PduHeaders(); + } + + /** + * Constructor. + * + * @param headers Headers for this PDU. + */ + GenericPdu(PduHeaders headers) { + mPduHeaders = headers; + } + + /** + * Get the headers of this PDU. + * + * @return A PduHeaders of this PDU. + */ + PduHeaders getPduHeaders() { + return mPduHeaders; + } + + /** + * Get X-Mms-Message-Type field value. + * + * @return the X-Mms-Report-Allowed value + */ + public int getMessageType() { + return mPduHeaders.getOctet(PduHeaders.MESSAGE_TYPE); + } + + /** + * Set X-Mms-Message-Type field value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + * RuntimeException if field's value is not Octet. + */ + public void setMessageType(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.MESSAGE_TYPE); + } + + /** + * Get X-Mms-MMS-Version field value. + * + * @return the X-Mms-MMS-Version value + */ + public int getMmsVersion() { + return mPduHeaders.getOctet(PduHeaders.MMS_VERSION); + } + + /** + * Set X-Mms-MMS-Version field value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + * RuntimeException if field's value is not Octet. + */ + public void setMmsVersion(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.MMS_VERSION); + } + + /** + * Get From value. + * From-value = Value-length + * (Address-present-token Encoded-string-value | Insert-address-token) + * + * @return the value + */ + public EncodedStringValue getFrom() { + return mPduHeaders.getEncodedStringValue(PduHeaders.FROM); + } + + /** + * Set From value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setFrom(EncodedStringValue value) { + mPduHeaders.setEncodedStringValue(value, PduHeaders.FROM); + } +} diff --git a/src/android/support/v7/mms/pdu/InvalidHeaderValueException.java b/src/android/support/v7/mms/pdu/InvalidHeaderValueException.java new file mode 100644 index 0000000..4b1de02 --- /dev/null +++ b/src/android/support/v7/mms/pdu/InvalidHeaderValueException.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +/** + * Thrown when an invalid header value was set. + */ +public class InvalidHeaderValueException extends MmsException { + private static final long serialVersionUID = -2053384496042052262L; + + /** + * Constructs an InvalidHeaderValueException with no detailed message. + */ + public InvalidHeaderValueException() { + super(); + } + + /** + * Constructs an InvalidHeaderValueException with the specified detailed message. + * + * @param message the detailed message. + */ + public InvalidHeaderValueException(String message) { + super(message); + } +} diff --git a/src/android/support/v7/mms/pdu/MmsException.java b/src/android/support/v7/mms/pdu/MmsException.java new file mode 100644 index 0000000..1fd1a02 --- /dev/null +++ b/src/android/support/v7/mms/pdu/MmsException.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +/** + * A generic exception that is thrown by the Mms client. + */ +public class MmsException extends Exception { + private static final long serialVersionUID = -7323249827281485390L; + + /** + * Creates a new MmsException. + */ + public MmsException() { + super(); + } + + /** + * Creates a new MmsException with the specified detail message. + * + * @param message the detail message. + */ + public MmsException(String message) { + super(message); + } + + /** + * Creates a new MmsException with the specified cause. + * + * @param cause the cause. + */ + public MmsException(Throwable cause) { + super(cause); + } + + /** + * Creates a new MmsException with the specified detail message and cause. + * + * @param message the detail message. + * @param cause the cause. + */ + public MmsException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/android/support/v7/mms/pdu/MultimediaMessagePdu.java b/src/android/support/v7/mms/pdu/MultimediaMessagePdu.java new file mode 100644 index 0000000..12f3b6d --- /dev/null +++ b/src/android/support/v7/mms/pdu/MultimediaMessagePdu.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +/** + * Multimedia message PDU. + */ +public class MultimediaMessagePdu extends GenericPdu{ + /** + * The body. + */ + private PduBody mMessageBody; + + /** + * Constructor. + */ + public MultimediaMessagePdu() { + super(); + } + + /** + * Constructor. + * + * @param header the header of this PDU + * @param body the body of this PDU + */ + public MultimediaMessagePdu(PduHeaders header, PduBody body) { + super(header); + mMessageBody = body; + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + MultimediaMessagePdu(PduHeaders headers) { + super(headers); + } + + /** + * Get body of the PDU. + * + * @return the body + */ + public PduBody getBody() { + return mMessageBody; + } + + /** + * Set body of the PDU. + * + * @param body the body + */ + public void setBody(PduBody body) { + mMessageBody = body; + } + + /** + * Get subject. + * + * @return the value + */ + public EncodedStringValue getSubject() { + return mPduHeaders.getEncodedStringValue(PduHeaders.SUBJECT); + } + + /** + * Set subject. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setSubject(EncodedStringValue value) { + mPduHeaders.setEncodedStringValue(value, PduHeaders.SUBJECT); + } + + /** + * Get To value. + * + * @return the value + */ + public EncodedStringValue[] getTo() { + return mPduHeaders.getEncodedStringValues(PduHeaders.TO); + } + + /** + * Add a "To" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void addTo(EncodedStringValue value) { + mPduHeaders.appendEncodedStringValue(value, PduHeaders.TO); + } + + /** + * Get X-Mms-Priority value. + * + * @return the value + */ + public int getPriority() { + return mPduHeaders.getOctet(PduHeaders.PRIORITY); + } + + /** + * Set X-Mms-Priority value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setPriority(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.PRIORITY); + } + + /** + * Get Date value. + * + * @return the value + */ + public long getDate() { + return mPduHeaders.getLongInteger(PduHeaders.DATE); + } + + /** + * Set Date value in seconds. + * + * @param value the value + */ + public void setDate(long value) { + mPduHeaders.setLongInteger(value, PduHeaders.DATE); + } +} diff --git a/src/android/support/v7/mms/pdu/NotificationInd.java b/src/android/support/v7/mms/pdu/NotificationInd.java new file mode 100644 index 0000000..16aa23d --- /dev/null +++ b/src/android/support/v7/mms/pdu/NotificationInd.java @@ -0,0 +1,283 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +/** + * M-Notification.ind PDU. + */ +public class NotificationInd extends GenericPdu { + /** + * Empty constructor. + * Since the Pdu corresponding to this class is constructed + * by the Proxy-Relay server, this class is only instantiated + * by the Pdu Parser. + * + * @throws InvalidHeaderValueException if error occurs. + * RuntimeException if an undeclared error occurs. + */ + public NotificationInd() throws InvalidHeaderValueException { + super(); + setMessageType(PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND); + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + NotificationInd(PduHeaders headers) { + super(headers); + } + + /** + * Get X-Mms-Content-Class Value. + * + * @return the value + */ + public int getContentClass() { + return mPduHeaders.getOctet(PduHeaders.CONTENT_CLASS); + } + + /** + * Set X-Mms-Content-Class Value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + * RuntimeException if an undeclared error occurs. + */ + public void setContentClass(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.CONTENT_CLASS); + } + + /** + * Get X-Mms-Content-Location value. + * When used in a PDU other than M-Mbox-Delete.conf and M-Delete.conf: + * Content-location-value = Uri-value + * + * @return the value + */ + public byte[] getContentLocation() { + return mPduHeaders.getTextString(PduHeaders.CONTENT_LOCATION); + } + + /** + * Set X-Mms-Content-Location value. + * + * @param value the value + * @throws NullPointerException if the value is null. + * RuntimeException if an undeclared error occurs. + */ + public void setContentLocation(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.CONTENT_LOCATION); + } + + /** + * Get X-Mms-Expiry value. + * + * Expiry-value = Value-length + * (Absolute-token Date-value | Relative-token Delta-seconds-value) + * + * @return the value + */ + public long getExpiry() { + return mPduHeaders.getLongInteger(PduHeaders.EXPIRY); + } + + /** + * Set X-Mms-Expiry value. + * + * @param value the value + * @throws RuntimeException if an undeclared error occurs. + */ + public void setExpiry(long value) { + mPduHeaders.setLongInteger(value, PduHeaders.EXPIRY); + } + + /** + * Get From value. + * From-value = Value-length + * (Address-present-token Encoded-string-value | Insert-address-token) + * + * @return the value + */ + public EncodedStringValue getFrom() { + return mPduHeaders.getEncodedStringValue(PduHeaders.FROM); + } + + /** + * Set From value. + * + * @param value the value + * @throws NullPointerException if the value is null. + * RuntimeException if an undeclared error occurs. + */ + public void setFrom(EncodedStringValue value) { + mPduHeaders.setEncodedStringValue(value, PduHeaders.FROM); + } + + /** + * Get X-Mms-Message-Class value. + * Message-class-value = Class-identifier | Token-text + * Class-identifier = Personal | Advertisement | Informational | Auto + * + * @return the value + */ + public byte[] getMessageClass() { + return mPduHeaders.getTextString(PduHeaders.MESSAGE_CLASS); + } + + /** + * Set X-Mms-Message-Class value. + * + * @param value the value + * @throws NullPointerException if the value is null. + * RuntimeException if an undeclared error occurs. + */ + public void setMessageClass(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.MESSAGE_CLASS); + } + + /** + * Get X-Mms-Message-Size value. + * Message-size-value = Long-integer + * + * @return the value + */ + public long getMessageSize() { + return mPduHeaders.getLongInteger(PduHeaders.MESSAGE_SIZE); + } + + /** + * Set X-Mms-Message-Size value. + * + * @param value the value + * @throws RuntimeException if an undeclared error occurs. + */ + public void setMessageSize(long value) { + mPduHeaders.setLongInteger(value, PduHeaders.MESSAGE_SIZE); + } + + /** + * Get subject. + * + * @return the value + */ + public EncodedStringValue getSubject() { + return mPduHeaders.getEncodedStringValue(PduHeaders.SUBJECT); + } + + /** + * Set subject. + * + * @param value the value + * @throws NullPointerException if the value is null. + * RuntimeException if an undeclared error occurs. + */ + public void setSubject(EncodedStringValue value) { + mPduHeaders.setEncodedStringValue(value, PduHeaders.SUBJECT); + } + + /** + * Get X-Mms-Transaction-Id. + * + * @return the value + */ + public byte[] getTransactionId() { + return mPduHeaders.getTextString(PduHeaders.TRANSACTION_ID); + } + + /** + * Set X-Mms-Transaction-Id. + * + * @param value the value + * @throws NullPointerException if the value is null. + * RuntimeException if an undeclared error occurs. + */ + public void setTransactionId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.TRANSACTION_ID); + } + + /** + * Get X-Mms-Delivery-Report Value. + * + * @return the value + */ + public int getDeliveryReport() { + return mPduHeaders.getOctet(PduHeaders.DELIVERY_REPORT); + } + + /** + * Set X-Mms-Delivery-Report Value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + * RuntimeException if an undeclared error occurs. + */ + public void setDeliveryReport(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.DELIVERY_REPORT); + } + + /* + * Optional, not supported header fields: + * + * public byte[] getApplicId() {return null;} + * public void setApplicId(byte[] value) {} + * + * public byte[] getAuxApplicId() {return null;} + * public void getAuxApplicId(byte[] value) {} + * + * public byte getDrmContent() {return 0x00;} + * public void setDrmContent(byte value) {} + * + * public byte getDistributionIndicator() {return 0x00;} + * public void setDistributionIndicator(byte value) {} + * + * public ElementDescriptorValue getElementDescriptor() {return null;} + * public void getElementDescriptor(ElementDescriptorValue value) {} + * + * public byte getPriority() {return 0x00;} + * public void setPriority(byte value) {} + * + * public byte getRecommendedRetrievalMode() {return 0x00;} + * public void setRecommendedRetrievalMode(byte value) {} + * + * public byte getRecommendedRetrievalModeText() {return 0x00;} + * public void setRecommendedRetrievalModeText(byte value) {} + * + * public byte[] getReplaceId() {return 0x00;} + * public void setReplaceId(byte[] value) {} + * + * public byte[] getReplyApplicId() {return 0x00;} + * public void setReplyApplicId(byte[] value) {} + * + * public byte getReplyCharging() {return 0x00;} + * public void setReplyCharging(byte value) {} + * + * public byte getReplyChargingDeadline() {return 0x00;} + * public void setReplyChargingDeadline(byte value) {} + * + * public byte[] getReplyChargingId() {return 0x00;} + * public void setReplyChargingId(byte[] value) {} + * + * public long getReplyChargingSize() {return 0;} + * public void setReplyChargingSize(long value) {} + * + * public byte getStored() {return 0x00;} + * public void setStored(byte value) {} + */ +} diff --git a/src/android/support/v7/mms/pdu/NotifyRespInd.java b/src/android/support/v7/mms/pdu/NotifyRespInd.java new file mode 100644 index 0000000..cb8e92b --- /dev/null +++ b/src/android/support/v7/mms/pdu/NotifyRespInd.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +/** + * M-NofifyResp.ind PDU. + */ +public class NotifyRespInd extends GenericPdu { + /** + * Constructor, used when composing a M-NotifyResp.ind pdu. + * + * @param mmsVersion current version of mms + * @param transactionId the transaction-id value + * @param status the status value + * @throws InvalidHeaderValueException if parameters are invalid. + * NullPointerException if transactionId is null. + * RuntimeException if an undeclared error occurs. + */ + public NotifyRespInd(int mmsVersion, + byte[] transactionId, + int status) throws InvalidHeaderValueException { + super(); + setMessageType(PduHeaders.MESSAGE_TYPE_NOTIFYRESP_IND); + setMmsVersion(mmsVersion); + setTransactionId(transactionId); + setStatus(status); + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + NotifyRespInd(PduHeaders headers) { + super(headers); + } + + /** + * Get X-Mms-Report-Allowed field value. + * + * @return the X-Mms-Report-Allowed value + */ + public int getReportAllowed() { + return mPduHeaders.getOctet(PduHeaders.REPORT_ALLOWED); + } + + /** + * Set X-Mms-Report-Allowed field value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + * RuntimeException if an undeclared error occurs. + */ + public void setReportAllowed(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.REPORT_ALLOWED); + } + + /** + * Set X-Mms-Status field value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + * RuntimeException if an undeclared error occurs. + */ + public void setStatus(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.STATUS); + } + + /** + * GetX-Mms-Status field value. + * + * @return the X-Mms-Status value + */ + public int getStatus() { + return mPduHeaders.getOctet(PduHeaders.STATUS); + } + + /** + * Get X-Mms-Transaction-Id field value. + * + * @return the X-Mms-Report-Allowed value + */ + public byte[] getTransactionId() { + return mPduHeaders.getTextString(PduHeaders.TRANSACTION_ID); + } + + /** + * Set X-Mms-Transaction-Id field value. + * + * @param value the value + * @throws NullPointerException if the value is null. + * RuntimeException if an undeclared error occurs. + */ + public void setTransactionId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.TRANSACTION_ID); + } +} diff --git a/src/android/support/v7/mms/pdu/PduBody.java b/src/android/support/v7/mms/pdu/PduBody.java new file mode 100644 index 0000000..9d983fc --- /dev/null +++ b/src/android/support/v7/mms/pdu/PduBody.java @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +import java.util.HashMap; +import java.util.Map; +import java.util.Vector; + +public class PduBody { + private Vector mParts = null; + + private Map mPartMapByContentId = null; + private Map mPartMapByContentLocation = null; + private Map mPartMapByName = null; + private Map mPartMapByFileName = null; + + /** + * Constructor. + */ + public PduBody() { + mParts = new Vector(); + + mPartMapByContentId = new HashMap(); + mPartMapByContentLocation = new HashMap(); + mPartMapByName = new HashMap(); + mPartMapByFileName = new HashMap(); + } + + private void putPartToMaps(PduPart part) { + // Put part to mPartMapByContentId. + byte[] contentId = part.getContentId(); + if(null != contentId) { + mPartMapByContentId.put(new String(contentId), part); + } + + // Put part to mPartMapByContentLocation. + byte[] contentLocation = part.getContentLocation(); + if(null != contentLocation) { + String clc = new String(contentLocation); + mPartMapByContentLocation.put(clc, part); + } + + // Put part to mPartMapByName. + byte[] name = part.getName(); + if(null != name) { + String clc = new String(name); + mPartMapByName.put(clc, part); + } + + // Put part to mPartMapByFileName. + byte[] fileName = part.getFilename(); + if(null != fileName) { + String clc = new String(fileName); + mPartMapByFileName.put(clc, part); + } + } + + /** + * Appends the specified part to the end of this body. + * + * @param part part to be appended + * @return true when success, false when fail + * @throws NullPointerException when part is null + */ + public boolean addPart(PduPart part) { + if(null == part) { + throw new NullPointerException(); + } + + putPartToMaps(part); + return mParts.add(part); + } + + /** + * Inserts the specified part at the specified position. + * + * @param index index at which the specified part is to be inserted + * @param part part to be inserted + * @throws NullPointerException when part is null + */ + public void addPart(int index, PduPart part) { + if(null == part) { + throw new NullPointerException(); + } + + putPartToMaps(part); + mParts.add(index, part); + } + + /** + * Removes the part at the specified position. + * + * @param index index of the part to return + * @return part at the specified index + */ + public PduPart removePart(int index) { + return mParts.remove(index); + } + + /** + * Remove all of the parts. + */ + public void removeAll() { + mParts.clear(); + } + + /** + * Get the part at the specified position. + * + * @param index index of the part to return + * @return part at the specified index + */ + public PduPart getPart(int index) { + return mParts.get(index); + } + + /** + * Get the index of the specified part. + * + * @param part the part object + * @return index the index of the first occurrence of the part in this body + */ + public int getPartIndex(PduPart part) { + return mParts.indexOf(part); + } + + /** + * Get the number of parts. + * + * @return the number of parts + */ + public int getPartsNum() { + return mParts.size(); + } + + /** + * Get pdu part by content id. + * + * @param cid the value of content id. + * @return the pdu part. + */ + public PduPart getPartByContentId(String cid) { + return mPartMapByContentId.get(cid); + } + + /** + * Get pdu part by Content-Location. Content-Location of part is + * the same as filename and name(param of content-type). + * + * @param contentLocation the content location. + * @return the pdu part. + */ + public PduPart getPartByContentLocation(String contentLocation) { + return mPartMapByContentLocation.get(contentLocation); + } + + /** + * Get pdu part by name. + * + * @param name the value of filename. + * @return the pdu part. + */ + public PduPart getPartByName(String name) { + return mPartMapByName.get(name); + } + + /** + * Get pdu part by filename. + * + * @param filename the value of filename. + * @return the pdu part. + */ + public PduPart getPartByFileName(String filename) { + return mPartMapByFileName.get(filename); + } +} diff --git a/src/android/support/v7/mms/pdu/PduContentTypes.java b/src/android/support/v7/mms/pdu/PduContentTypes.java new file mode 100644 index 0000000..7cd0ecf --- /dev/null +++ b/src/android/support/v7/mms/pdu/PduContentTypes.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +public class PduContentTypes { + /** + * All content types. From: + * http://www.openmobilealliance.org/tech/omna/omna-wsp-content-type.htm + */ + static final String[] contentTypes = { + "*/*", /* 0x00 */ + "text/*", /* 0x01 */ + "text/html", /* 0x02 */ + "text/plain", /* 0x03 */ + "text/x-hdml", /* 0x04 */ + "text/x-ttml", /* 0x05 */ + "text/x-vCalendar", /* 0x06 */ + "text/x-vCard", /* 0x07 */ + "text/vnd.wap.wml", /* 0x08 */ + "text/vnd.wap.wmlscript", /* 0x09 */ + "text/vnd.wap.wta-event", /* 0x0A */ + "multipart/*", /* 0x0B */ + "multipart/mixed", /* 0x0C */ + "multipart/form-data", /* 0x0D */ + "multipart/byterantes", /* 0x0E */ + "multipart/alternative", /* 0x0F */ + "application/*", /* 0x10 */ + "application/java-vm", /* 0x11 */ + "application/x-www-form-urlencoded", /* 0x12 */ + "application/x-hdmlc", /* 0x13 */ + "application/vnd.wap.wmlc", /* 0x14 */ + "application/vnd.wap.wmlscriptc", /* 0x15 */ + "application/vnd.wap.wta-eventc", /* 0x16 */ + "application/vnd.wap.uaprof", /* 0x17 */ + "application/vnd.wap.wtls-ca-certificate", /* 0x18 */ + "application/vnd.wap.wtls-user-certificate", /* 0x19 */ + "application/x-x509-ca-cert", /* 0x1A */ + "application/x-x509-user-cert", /* 0x1B */ + "image/*", /* 0x1C */ + "image/gif", /* 0x1D */ + "image/jpeg", /* 0x1E */ + "image/tiff", /* 0x1F */ + "image/png", /* 0x20 */ + "image/vnd.wap.wbmp", /* 0x21 */ + "application/vnd.wap.multipart.*", /* 0x22 */ + "application/vnd.wap.multipart.mixed", /* 0x23 */ + "application/vnd.wap.multipart.form-data", /* 0x24 */ + "application/vnd.wap.multipart.byteranges", /* 0x25 */ + "application/vnd.wap.multipart.alternative", /* 0x26 */ + "application/xml", /* 0x27 */ + "text/xml", /* 0x28 */ + "application/vnd.wap.wbxml", /* 0x29 */ + "application/x-x968-cross-cert", /* 0x2A */ + "application/x-x968-ca-cert", /* 0x2B */ + "application/x-x968-user-cert", /* 0x2C */ + "text/vnd.wap.si", /* 0x2D */ + "application/vnd.wap.sic", /* 0x2E */ + "text/vnd.wap.sl", /* 0x2F */ + "application/vnd.wap.slc", /* 0x30 */ + "text/vnd.wap.co", /* 0x31 */ + "application/vnd.wap.coc", /* 0x32 */ + "application/vnd.wap.multipart.related", /* 0x33 */ + "application/vnd.wap.sia", /* 0x34 */ + "text/vnd.wap.connectivity-xml", /* 0x35 */ + "application/vnd.wap.connectivity-wbxml", /* 0x36 */ + "application/pkcs7-mime", /* 0x37 */ + "application/vnd.wap.hashed-certificate", /* 0x38 */ + "application/vnd.wap.signed-certificate", /* 0x39 */ + "application/vnd.wap.cert-response", /* 0x3A */ + "application/xhtml+xml", /* 0x3B */ + "application/wml+xml", /* 0x3C */ + "text/css", /* 0x3D */ + "application/vnd.wap.mms-message", /* 0x3E */ + "application/vnd.wap.rollover-certificate", /* 0x3F */ + "application/vnd.wap.locc+wbxml", /* 0x40 */ + "application/vnd.wap.loc+xml", /* 0x41 */ + "application/vnd.syncml.dm+wbxml", /* 0x42 */ + "application/vnd.syncml.dm+xml", /* 0x43 */ + "application/vnd.syncml.notification", /* 0x44 */ + "application/vnd.wap.xhtml+xml", /* 0x45 */ + "application/vnd.wv.csp.cir", /* 0x46 */ + "application/vnd.oma.dd+xml", /* 0x47 */ + "application/vnd.oma.drm.message", /* 0x48 */ + "application/vnd.oma.drm.content", /* 0x49 */ + "application/vnd.oma.drm.rights+xml", /* 0x4A */ + "application/vnd.oma.drm.rights+wbxml", /* 0x4B */ + "application/vnd.wv.csp+xml", /* 0x4C */ + "application/vnd.wv.csp+wbxml", /* 0x4D */ + "application/vnd.syncml.ds.notification", /* 0x4E */ + "audio/*", /* 0x4F */ + "video/*", /* 0x50 */ + "application/vnd.oma.dd2+xml", /* 0x51 */ + "application/mikey" /* 0x52 */ + }; +} diff --git a/src/android/support/v7/mms/pdu/PduHeaders.java b/src/android/support/v7/mms/pdu/PduHeaders.java new file mode 100644 index 0000000..0271e58 --- /dev/null +++ b/src/android/support/v7/mms/pdu/PduHeaders.java @@ -0,0 +1,719 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +import java.util.ArrayList; +import java.util.HashMap; + +public class PduHeaders { + /** + * All pdu header fields. + */ + public static final int BCC = 0x81; + public static final int CC = 0x82; + public static final int CONTENT_LOCATION = 0x83; + public static final int CONTENT_TYPE = 0x84; + public static final int DATE = 0x85; + public static final int DELIVERY_REPORT = 0x86; + public static final int DELIVERY_TIME = 0x87; + public static final int EXPIRY = 0x88; + public static final int FROM = 0x89; + public static final int MESSAGE_CLASS = 0x8A; + public static final int MESSAGE_ID = 0x8B; + public static final int MESSAGE_TYPE = 0x8C; + public static final int MMS_VERSION = 0x8D; + public static final int MESSAGE_SIZE = 0x8E; + public static final int PRIORITY = 0x8F; + + public static final int READ_REPLY = 0x90; + public static final int READ_REPORT = 0x90; + public static final int REPORT_ALLOWED = 0x91; + public static final int RESPONSE_STATUS = 0x92; + public static final int RESPONSE_TEXT = 0x93; + public static final int SENDER_VISIBILITY = 0x94; + public static final int STATUS = 0x95; + public static final int SUBJECT = 0x96; + public static final int TO = 0x97; + public static final int TRANSACTION_ID = 0x98; + public static final int RETRIEVE_STATUS = 0x99; + public static final int RETRIEVE_TEXT = 0x9A; + public static final int READ_STATUS = 0x9B; + public static final int REPLY_CHARGING = 0x9C; + public static final int REPLY_CHARGING_DEADLINE = 0x9D; + public static final int REPLY_CHARGING_ID = 0x9E; + public static final int REPLY_CHARGING_SIZE = 0x9F; + + public static final int PREVIOUSLY_SENT_BY = 0xA0; + public static final int PREVIOUSLY_SENT_DATE = 0xA1; + public static final int STORE = 0xA2; + public static final int MM_STATE = 0xA3; + public static final int MM_FLAGS = 0xA4; + public static final int STORE_STATUS = 0xA5; + public static final int STORE_STATUS_TEXT = 0xA6; + public static final int STORED = 0xA7; + public static final int ATTRIBUTES = 0xA8; + public static final int TOTALS = 0xA9; + public static final int MBOX_TOTALS = 0xAA; + public static final int QUOTAS = 0xAB; + public static final int MBOX_QUOTAS = 0xAC; + public static final int MESSAGE_COUNT = 0xAD; + public static final int CONTENT = 0xAE; + public static final int START = 0xAF; + + public static final int ADDITIONAL_HEADERS = 0xB0; + public static final int DISTRIBUTION_INDICATOR = 0xB1; + public static final int ELEMENT_DESCRIPTOR = 0xB2; + public static final int LIMIT = 0xB3; + public static final int RECOMMENDED_RETRIEVAL_MODE = 0xB4; + public static final int RECOMMENDED_RETRIEVAL_MODE_TEXT = 0xB5; + public static final int STATUS_TEXT = 0xB6; + public static final int APPLIC_ID = 0xB7; + public static final int REPLY_APPLIC_ID = 0xB8; + public static final int AUX_APPLIC_ID = 0xB9; + public static final int CONTENT_CLASS = 0xBA; + public static final int DRM_CONTENT = 0xBB; + public static final int ADAPTATION_ALLOWED = 0xBC; + public static final int REPLACE_ID = 0xBD; + public static final int CANCEL_ID = 0xBE; + public static final int CANCEL_STATUS = 0xBF; + + /** + * X-Mms-Message-Type field types. + */ + public static final int MESSAGE_TYPE_SEND_REQ = 0x80; + public static final int MESSAGE_TYPE_SEND_CONF = 0x81; + public static final int MESSAGE_TYPE_NOTIFICATION_IND = 0x82; + public static final int MESSAGE_TYPE_NOTIFYRESP_IND = 0x83; + public static final int MESSAGE_TYPE_RETRIEVE_CONF = 0x84; + public static final int MESSAGE_TYPE_ACKNOWLEDGE_IND = 0x85; + public static final int MESSAGE_TYPE_DELIVERY_IND = 0x86; + public static final int MESSAGE_TYPE_READ_REC_IND = 0x87; + public static final int MESSAGE_TYPE_READ_ORIG_IND = 0x88; + public static final int MESSAGE_TYPE_FORWARD_REQ = 0x89; + public static final int MESSAGE_TYPE_FORWARD_CONF = 0x8A; + public static final int MESSAGE_TYPE_MBOX_STORE_REQ = 0x8B; + public static final int MESSAGE_TYPE_MBOX_STORE_CONF = 0x8C; + public static final int MESSAGE_TYPE_MBOX_VIEW_REQ = 0x8D; + public static final int MESSAGE_TYPE_MBOX_VIEW_CONF = 0x8E; + public static final int MESSAGE_TYPE_MBOX_UPLOAD_REQ = 0x8F; + public static final int MESSAGE_TYPE_MBOX_UPLOAD_CONF = 0x90; + public static final int MESSAGE_TYPE_MBOX_DELETE_REQ = 0x91; + public static final int MESSAGE_TYPE_MBOX_DELETE_CONF = 0x92; + public static final int MESSAGE_TYPE_MBOX_DESCR = 0x93; + public static final int MESSAGE_TYPE_DELETE_REQ = 0x94; + public static final int MESSAGE_TYPE_DELETE_CONF = 0x95; + public static final int MESSAGE_TYPE_CANCEL_REQ = 0x96; + public static final int MESSAGE_TYPE_CANCEL_CONF = 0x97; + + /** + * X-Mms-Delivery-Report | + * X-Mms-Read-Report | + * X-Mms-Report-Allowed | + * X-Mms-Sender-Visibility | + * X-Mms-Store | + * X-Mms-Stored | + * X-Mms-Totals | + * X-Mms-Quotas | + * X-Mms-Distribution-Indicator | + * X-Mms-DRM-Content | + * X-Mms-Adaptation-Allowed | + * field types. + */ + public static final int VALUE_YES = 0x80; + public static final int VALUE_NO = 0x81; + + /** + * Delivery-Time | + * Expiry and Reply-Charging-Deadline | + * field type components. + */ + public static final int VALUE_ABSOLUTE_TOKEN = 0x80; + public static final int VALUE_RELATIVE_TOKEN = 0x81; + + /** + * X-Mms-MMS-Version field types. + */ + public static final int MMS_VERSION_1_3 = ((1 << 4) | 3); + public static final int MMS_VERSION_1_2 = ((1 << 4) | 2); + public static final int MMS_VERSION_1_1 = ((1 << 4) | 1); + public static final int MMS_VERSION_1_0 = ((1 << 4) | 0); + + // Current version is 1.2. + public static final int CURRENT_MMS_VERSION = MMS_VERSION_1_2; + + /** + * From field type components. + */ + public static final int FROM_ADDRESS_PRESENT_TOKEN = 0x80; + public static final int FROM_INSERT_ADDRESS_TOKEN = 0x81; + + public static final String FROM_ADDRESS_PRESENT_TOKEN_STR = "address-present-token"; + public static final String FROM_INSERT_ADDRESS_TOKEN_STR = "insert-address-token"; + + /** + * X-Mms-Status Field. + */ + public static final int STATUS_EXPIRED = 0x80; + public static final int STATUS_RETRIEVED = 0x81; + public static final int STATUS_REJECTED = 0x82; + public static final int STATUS_DEFERRED = 0x83; + public static final int STATUS_UNRECOGNIZED = 0x84; + public static final int STATUS_INDETERMINATE = 0x85; + public static final int STATUS_FORWARDED = 0x86; + public static final int STATUS_UNREACHABLE = 0x87; + + /** + * MM-Flags field type components. + */ + public static final int MM_FLAGS_ADD_TOKEN = 0x80; + public static final int MM_FLAGS_REMOVE_TOKEN = 0x81; + public static final int MM_FLAGS_FILTER_TOKEN = 0x82; + + /** + * X-Mms-Message-Class field types. + */ + public static final int MESSAGE_CLASS_PERSONAL = 0x80; + public static final int MESSAGE_CLASS_ADVERTISEMENT = 0x81; + public static final int MESSAGE_CLASS_INFORMATIONAL = 0x82; + public static final int MESSAGE_CLASS_AUTO = 0x83; + + public static final String MESSAGE_CLASS_PERSONAL_STR = "personal"; + public static final String MESSAGE_CLASS_ADVERTISEMENT_STR = "advertisement"; + public static final String MESSAGE_CLASS_INFORMATIONAL_STR = "informational"; + public static final String MESSAGE_CLASS_AUTO_STR = "auto"; + + /** + * X-Mms-Priority field types. + */ + public static final int PRIORITY_LOW = 0x80; + public static final int PRIORITY_NORMAL = 0x81; + public static final int PRIORITY_HIGH = 0x82; + + /** + * X-Mms-Response-Status field types. + */ + public static final int RESPONSE_STATUS_OK = 0x80; + public static final int RESPONSE_STATUS_ERROR_UNSPECIFIED = 0x81; + public static final int RESPONSE_STATUS_ERROR_SERVICE_DENIED = 0x82; + + public static final int RESPONSE_STATUS_ERROR_MESSAGE_FORMAT_CORRUPT = 0x83; + public static final int RESPONSE_STATUS_ERROR_SENDING_ADDRESS_UNRESOLVED = 0x84; + + public static final int RESPONSE_STATUS_ERROR_MESSAGE_NOT_FOUND = 0x85; + public static final int RESPONSE_STATUS_ERROR_NETWORK_PROBLEM = 0x86; + public static final int RESPONSE_STATUS_ERROR_CONTENT_NOT_ACCEPTED = 0x87; + public static final int RESPONSE_STATUS_ERROR_UNSUPPORTED_MESSAGE = 0x88; + public static final int RESPONSE_STATUS_ERROR_TRANSIENT_FAILURE = 0xC0; + + public static final int RESPONSE_STATUS_ERROR_TRANSIENT_SENDNG_ADDRESS_UNRESOLVED = 0xC1; + public static final int RESPONSE_STATUS_ERROR_TRANSIENT_MESSAGE_NOT_FOUND = 0xC2; + public static final int RESPONSE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM = 0xC3; + public static final int RESPONSE_STATUS_ERROR_TRANSIENT_PARTIAL_SUCCESS = 0xC4; + + public static final int RESPONSE_STATUS_ERROR_PERMANENT_FAILURE = 0xE0; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_SERVICE_DENIED = 0xE1; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_MESSAGE_FORMAT_CORRUPT = 0xE2; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_SENDING_ADDRESS_UNRESOLVED = 0xE3; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_MESSAGE_NOT_FOUND = 0xE4; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_CONTENT_NOT_ACCEPTED = 0xE5; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_LIMITATIONS_NOT_MET = 0xE6; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_REQUEST_NOT_ACCEPTED = 0xE6; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_FORWARDING_DENIED = 0xE8; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_NOT_SUPPORTED = 0xE9; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_ADDRESS_HIDING_NOT_SUPPORTED = 0xEA; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_LACK_OF_PREPAID = 0xEB; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_END = 0xFF; + + /** + * X-Mms-Retrieve-Status field types. + */ + public static final int RETRIEVE_STATUS_OK = 0x80; + public static final int RETRIEVE_STATUS_ERROR_TRANSIENT_FAILURE = 0xC0; + public static final int RETRIEVE_STATUS_ERROR_TRANSIENT_MESSAGE_NOT_FOUND = 0xC1; + public static final int RETRIEVE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM = 0xC2; + public static final int RETRIEVE_STATUS_ERROR_PERMANENT_FAILURE = 0xE0; + public static final int RETRIEVE_STATUS_ERROR_PERMANENT_SERVICE_DENIED = 0xE1; + public static final int RETRIEVE_STATUS_ERROR_PERMANENT_MESSAGE_NOT_FOUND = 0xE2; + public static final int RETRIEVE_STATUS_ERROR_PERMANENT_CONTENT_UNSUPPORTED = 0xE3; + public static final int RETRIEVE_STATUS_ERROR_END = 0xFF; + + /** + * X-Mms-Sender-Visibility field types. + */ + public static final int SENDER_VISIBILITY_HIDE = 0x80; + public static final int SENDER_VISIBILITY_SHOW = 0x81; + + /** + * X-Mms-Read-Status field types. + */ + public static final int READ_STATUS_READ = 0x80; + public static final int READ_STATUS__DELETED_WITHOUT_BEING_READ = 0x81; + + /** + * X-Mms-Cancel-Status field types. + */ + public static final int CANCEL_STATUS_REQUEST_SUCCESSFULLY_RECEIVED = 0x80; + public static final int CANCEL_STATUS_REQUEST_CORRUPTED = 0x81; + + /** + * X-Mms-Reply-Charging field types. + */ + public static final int REPLY_CHARGING_REQUESTED = 0x80; + public static final int REPLY_CHARGING_REQUESTED_TEXT_ONLY = 0x81; + public static final int REPLY_CHARGING_ACCEPTED = 0x82; + public static final int REPLY_CHARGING_ACCEPTED_TEXT_ONLY = 0x83; + + /** + * X-Mms-MM-State field types. + */ + public static final int MM_STATE_DRAFT = 0x80; + public static final int MM_STATE_SENT = 0x81; + public static final int MM_STATE_NEW = 0x82; + public static final int MM_STATE_RETRIEVED = 0x83; + public static final int MM_STATE_FORWARDED = 0x84; + + /** + * X-Mms-Recommended-Retrieval-Mode field types. + */ + public static final int RECOMMENDED_RETRIEVAL_MODE_MANUAL = 0x80; + + /** + * X-Mms-Content-Class field types. + */ + public static final int CONTENT_CLASS_TEXT = 0x80; + public static final int CONTENT_CLASS_IMAGE_BASIC = 0x81; + public static final int CONTENT_CLASS_IMAGE_RICH = 0x82; + public static final int CONTENT_CLASS_VIDEO_BASIC = 0x83; + public static final int CONTENT_CLASS_VIDEO_RICH = 0x84; + public static final int CONTENT_CLASS_MEGAPIXEL = 0x85; + public static final int CONTENT_CLASS_CONTENT_BASIC = 0x86; + public static final int CONTENT_CLASS_CONTENT_RICH = 0x87; + + /** + * X-Mms-Store-Status field types. + */ + public static final int STORE_STATUS_SUCCESS = 0x80; + public static final int STORE_STATUS_ERROR_TRANSIENT_FAILURE = 0xC0; + public static final int STORE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM = 0xC1; + public static final int STORE_STATUS_ERROR_PERMANENT_FAILURE = 0xE0; + public static final int STORE_STATUS_ERROR_PERMANENT_SERVICE_DENIED = 0xE1; + public static final int STORE_STATUS_ERROR_PERMANENT_MESSAGE_FORMAT_CORRUPT = 0xE2; + public static final int STORE_STATUS_ERROR_PERMANENT_MESSAGE_NOT_FOUND = 0xE3; + public static final int STORE_STATUS_ERROR_PERMANENT_MMBOX_FULL = 0xE4; + public static final int STORE_STATUS_ERROR_END = 0xFF; + + /** + * The map contains the value of all headers. + */ + private HashMap mHeaderMap = null; + + /** + * Constructor of PduHeaders. + */ + public PduHeaders() { + mHeaderMap = new HashMap(); + } + + /** + * Get octet value by header field. + * + * @param field the field + * @return the octet value of the pdu header + * with specified header field. Return 0 if + * the value is not set. + */ + protected int getOctet(int field) { + Integer octet = (Integer) mHeaderMap.get(field); + if (null == octet) { + return 0; + } + + return octet; + } + + /** + * Set octet value to pdu header by header field. + * + * @param value the value + * @param field the field + * @throws InvalidHeaderValueException if the value is invalid. + */ + protected void setOctet(int value, int field) + throws InvalidHeaderValueException{ + /** + * Check whether this field can be set for specific + * header and check validity of the field. + */ + switch (field) { + case REPORT_ALLOWED: + case ADAPTATION_ALLOWED: + case DELIVERY_REPORT: + case DRM_CONTENT: + case DISTRIBUTION_INDICATOR: + case QUOTAS: + case READ_REPORT: + case STORE: + case STORED: + case TOTALS: + case SENDER_VISIBILITY: + if ((VALUE_YES != value) && (VALUE_NO != value)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case READ_STATUS: + if ((READ_STATUS_READ != value) && + (READ_STATUS__DELETED_WITHOUT_BEING_READ != value)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case CANCEL_STATUS: + if ((CANCEL_STATUS_REQUEST_SUCCESSFULLY_RECEIVED != value) && + (CANCEL_STATUS_REQUEST_CORRUPTED != value)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case PRIORITY: + if ((value < PRIORITY_LOW) || (value > PRIORITY_HIGH)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case STATUS: + if ((value < STATUS_EXPIRED) || (value > STATUS_UNREACHABLE)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case REPLY_CHARGING: + if ((value < REPLY_CHARGING_REQUESTED) + || (value > REPLY_CHARGING_ACCEPTED_TEXT_ONLY)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case MM_STATE: + if ((value < MM_STATE_DRAFT) || (value > MM_STATE_FORWARDED)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case RECOMMENDED_RETRIEVAL_MODE: + if (RECOMMENDED_RETRIEVAL_MODE_MANUAL != value) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case CONTENT_CLASS: + if ((value < CONTENT_CLASS_TEXT) + || (value > CONTENT_CLASS_CONTENT_RICH)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case RETRIEVE_STATUS: + // According to oma-ts-mms-enc-v1_3, section 7.3.50, we modify the invalid value. + if ((value > RETRIEVE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM) && + (value < RETRIEVE_STATUS_ERROR_PERMANENT_FAILURE)) { + value = RETRIEVE_STATUS_ERROR_TRANSIENT_FAILURE; + } else if ((value > RETRIEVE_STATUS_ERROR_PERMANENT_CONTENT_UNSUPPORTED) && + (value <= RETRIEVE_STATUS_ERROR_END)) { + value = RETRIEVE_STATUS_ERROR_PERMANENT_FAILURE; + } else if ((value < RETRIEVE_STATUS_OK) || + ((value > RETRIEVE_STATUS_OK) && + (value < RETRIEVE_STATUS_ERROR_TRANSIENT_FAILURE)) || + (value > RETRIEVE_STATUS_ERROR_END)) { + value = RETRIEVE_STATUS_ERROR_PERMANENT_FAILURE; + } + break; + case STORE_STATUS: + // According to oma-ts-mms-enc-v1_3, section 7.3.58, we modify the invalid value. + if ((value > STORE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM) && + (value < STORE_STATUS_ERROR_PERMANENT_FAILURE)) { + value = STORE_STATUS_ERROR_TRANSIENT_FAILURE; + } else if ((value > STORE_STATUS_ERROR_PERMANENT_MMBOX_FULL) && + (value <= STORE_STATUS_ERROR_END)) { + value = STORE_STATUS_ERROR_PERMANENT_FAILURE; + } else if ((value < STORE_STATUS_SUCCESS) || + ((value > STORE_STATUS_SUCCESS) && + (value < STORE_STATUS_ERROR_TRANSIENT_FAILURE)) || + (value > STORE_STATUS_ERROR_END)) { + value = STORE_STATUS_ERROR_PERMANENT_FAILURE; + } + break; + case RESPONSE_STATUS: + // According to oma-ts-mms-enc-v1_3, section 7.3.48, we modify the invalid value. + if ((value > RESPONSE_STATUS_ERROR_TRANSIENT_PARTIAL_SUCCESS) && + (value < RESPONSE_STATUS_ERROR_PERMANENT_FAILURE)) { + value = RESPONSE_STATUS_ERROR_TRANSIENT_FAILURE; + } else if (((value > RESPONSE_STATUS_ERROR_PERMANENT_LACK_OF_PREPAID) && + (value <= RESPONSE_STATUS_ERROR_PERMANENT_END)) || + (value < RESPONSE_STATUS_OK) || + ((value > RESPONSE_STATUS_ERROR_UNSUPPORTED_MESSAGE) && + (value < RESPONSE_STATUS_ERROR_TRANSIENT_FAILURE)) || + (value > RESPONSE_STATUS_ERROR_PERMANENT_END)) { + value = RESPONSE_STATUS_ERROR_PERMANENT_FAILURE; + } + break; + case MMS_VERSION: + if ((value < MMS_VERSION_1_0)|| (value > MMS_VERSION_1_3)) { + value = CURRENT_MMS_VERSION; // Current version is the default value. + } + break; + case MESSAGE_TYPE: + if ((value < MESSAGE_TYPE_SEND_REQ) || (value > MESSAGE_TYPE_CANCEL_CONF)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + default: + // This header value should not be Octect. + throw new RuntimeException("Invalid header field!"); + } + mHeaderMap.put(field, value); + } + + /** + * Get TextString value by header field. + * + * @param field the field + * @return the TextString value of the pdu header + * with specified header field + */ + protected byte[] getTextString(int field) { + return (byte[]) mHeaderMap.get(field); + } + + /** + * Set TextString value to pdu header by header field. + * + * @param value the value + * @param field the field + * @return the TextString value of the pdu header + * with specified header field + * @throws NullPointerException if the value is null. + */ + protected void setTextString(byte[] value, int field) { + /** + * Check whether this field can be set for specific + * header and check validity of the field. + */ + if (null == value) { + throw new NullPointerException(); + } + + switch (field) { + case TRANSACTION_ID: + case REPLY_CHARGING_ID: + case AUX_APPLIC_ID: + case APPLIC_ID: + case REPLY_APPLIC_ID: + case MESSAGE_ID: + case REPLACE_ID: + case CANCEL_ID: + case CONTENT_LOCATION: + case MESSAGE_CLASS: + case CONTENT_TYPE: + break; + default: + // This header value should not be Text-String. + throw new RuntimeException("Invalid header field!"); + } + mHeaderMap.put(field, value); + } + + /** + * Get EncodedStringValue value by header field. + * + * @param field the field + * @return the EncodedStringValue value of the pdu header + * with specified header field + */ + protected EncodedStringValue getEncodedStringValue(int field) { + return (EncodedStringValue) mHeaderMap.get(field); + } + + /** + * Get TO, CC or BCC header value. + * + * @param field the field + * @return the EncodeStringValue array of the pdu header + * with specified header field + */ + protected EncodedStringValue[] getEncodedStringValues(int field) { + ArrayList list = + (ArrayList) mHeaderMap.get(field); + if (null == list) { + return null; + } + EncodedStringValue[] values = new EncodedStringValue[list.size()]; + return list.toArray(values); + } + + /** + * Set EncodedStringValue value to pdu header by header field. + * + * @param value the value + * @param field the field + * @return the EncodedStringValue value of the pdu header + * with specified header field + * @throws NullPointerException if the value is null. + */ + protected void setEncodedStringValue(EncodedStringValue value, int field) { + /** + * Check whether this field can be set for specific + * header and check validity of the field. + */ + if (null == value) { + throw new NullPointerException(); + } + + switch (field) { + case SUBJECT: + case RECOMMENDED_RETRIEVAL_MODE_TEXT: + case RETRIEVE_TEXT: + case STATUS_TEXT: + case STORE_STATUS_TEXT: + case RESPONSE_TEXT: + case FROM: + case PREVIOUSLY_SENT_BY: + case MM_FLAGS: + break; + default: + // This header value should not be Encoded-String-Value. + throw new RuntimeException("Invalid header field!"); + } + + mHeaderMap.put(field, value); + } + + /** + * Set TO, CC or BCC header value. + * + * @param value the value + * @param field the field + * @return the EncodedStringValue value array of the pdu header + * with specified header field + * @throws NullPointerException if the value is null. + */ + protected void setEncodedStringValues(EncodedStringValue[] value, int field) { + /** + * Check whether this field can be set for specific + * header and check validity of the field. + */ + if (null == value) { + throw new NullPointerException(); + } + + switch (field) { + case BCC: + case CC: + case TO: + break; + default: + // This header value should not be Encoded-String-Value. + throw new RuntimeException("Invalid header field!"); + } + + ArrayList list = new ArrayList(); + for (int i = 0; i < value.length; i++) { + list.add(value[i]); + } + mHeaderMap.put(field, list); + } + + /** + * Append one EncodedStringValue to another. + * + * @param value the EncodedStringValue to append + * @param field the field + * @throws NullPointerException if the value is null. + */ + protected void appendEncodedStringValue(EncodedStringValue value, + int field) { + if (null == value) { + throw new NullPointerException(); + } + + switch (field) { + case BCC: + case CC: + case TO: + break; + default: + throw new RuntimeException("Invalid header field!"); + } + + ArrayList list = + (ArrayList) mHeaderMap.get(field); + if (null == list) { + list = new ArrayList(); + } + list.add(value); + mHeaderMap.put(field, list); + } + + /** + * Get LongInteger value by header field. + * + * @param field the field + * @return the LongInteger value of the pdu header + * with specified header field. if return -1, the + * field is not existed in pdu header. + */ + protected long getLongInteger(int field) { + Long longInteger = (Long) mHeaderMap.get(field); + if (null == longInteger) { + return -1; + } + + return longInteger.longValue(); + } + + /** + * Set LongInteger value to pdu header by header field. + * + * @param value the value + * @param field the field + */ + protected void setLongInteger(long value, int field) { + /** + * Check whether this field can be set for specific + * header and check validity of the field. + */ + switch (field) { + case DATE: + case REPLY_CHARGING_SIZE: + case MESSAGE_SIZE: + case MESSAGE_COUNT: + case START: + case LIMIT: + case DELIVERY_TIME: + case EXPIRY: + case REPLY_CHARGING_DEADLINE: + case PREVIOUSLY_SENT_DATE: + break; + default: + // This header value should not be LongInteger. + throw new RuntimeException("Invalid header field!"); + } + mHeaderMap.put(field, value); + } +} diff --git a/src/android/support/v7/mms/pdu/PduParser.java b/src/android/support/v7/mms/pdu/PduParser.java new file mode 100755 index 0000000..7d30df5 --- /dev/null +++ b/src/android/support/v7/mms/pdu/PduParser.java @@ -0,0 +1,2008 @@ +/* + * Copyright (C) 2007-2008 Esmertec AG. + * Copyright (C) 2007-2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +import android.util.Log; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.UnsupportedEncodingException; +import java.util.Arrays; +import java.util.HashMap; + +public class PduParser { + /** + * The next are WAP values defined in WSP specification. + */ + private static final int QUOTE = 127; + private static final int LENGTH_QUOTE = 31; + private static final int TEXT_MIN = 32; + private static final int TEXT_MAX = 127; + private static final int SHORT_INTEGER_MAX = 127; + private static final int SHORT_LENGTH_MAX = 30; + private static final int LONG_INTEGER_LENGTH_MAX = 8; + private static final int QUOTED_STRING_FLAG = 34; + private static final int END_STRING_FLAG = 0x00; + //The next two are used by the interface "parseWapString" to + //distinguish Text-String and Quoted-String. + private static final int TYPE_TEXT_STRING = 0; + private static final int TYPE_QUOTED_STRING = 1; + private static final int TYPE_TOKEN_STRING = 2; + + /** + * Specify the part position. + */ + private static final int THE_FIRST_PART = 0; + private static final int THE_LAST_PART = 1; + + /** + * The pdu data. + */ + private ByteArrayInputStream mPduDataStream = null; + + /** + * Store pdu headers + */ + private PduHeaders mHeaders = null; + + /** + * Store pdu parts. + */ + private PduBody mBody = null; + + /** + * Store the "type" parameter in "Content-Type" header field. + */ + private static byte[] mTypeParam = null; + + /** + * Store the "start" parameter in "Content-Type" header field. + */ + private static byte[] mStartParam = null; + + /** + * The log tag. + */ + private static final String LOG_TAG = "PduParser"; + private static final boolean DEBUG = false; + private static final boolean LOCAL_LOGV = false; + + /** + * Whether to parse content-disposition part header + */ + private final boolean mParseContentDisposition; + + /** + * Constructor. + * + * @param pduDataStream pdu data to be parsed + * @param parseContentDisposition whether to parse the Content-Disposition part header + */ + public PduParser(byte[] pduDataStream, boolean parseContentDisposition) { + mPduDataStream = new ByteArrayInputStream(pduDataStream); + mParseContentDisposition = parseContentDisposition; + } + + /** + * Parse the pdu. + * + * @return the pdu structure if parsing successfully. + * null if parsing error happened or mandatory fields are not set. + */ + public GenericPdu parse(){ + if (mPduDataStream == null) { + return null; + } + + /* parse headers */ + mHeaders = parseHeaders(mPduDataStream); + if (null == mHeaders) { + // Parse headers failed. + return null; + } + + /* get the message type */ + int messageType = mHeaders.getOctet(PduHeaders.MESSAGE_TYPE); + + /* check mandatory header fields */ + if (false == checkMandatoryHeader(mHeaders)) { + log("check mandatory headers failed!"); + return null; + } + + if ((PduHeaders.MESSAGE_TYPE_SEND_REQ == messageType) || + (PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF == messageType)) { + /* need to parse the parts */ + mBody = parseParts(mPduDataStream); + if (null == mBody) { + // Parse parts failed. + return null; + } + } + + switch (messageType) { + case PduHeaders.MESSAGE_TYPE_SEND_REQ: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parse: MESSAGE_TYPE_SEND_REQ"); + } + SendReq sendReq = new SendReq(mHeaders, mBody); + return sendReq; + case PduHeaders.MESSAGE_TYPE_SEND_CONF: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parse: MESSAGE_TYPE_SEND_CONF"); + } + SendConf sendConf = new SendConf(mHeaders); + return sendConf; + case PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parse: MESSAGE_TYPE_NOTIFICATION_IND"); + } + NotificationInd notificationInd = + new NotificationInd(mHeaders); + return notificationInd; + case PduHeaders.MESSAGE_TYPE_NOTIFYRESP_IND: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parse: MESSAGE_TYPE_NOTIFYRESP_IND"); + } + NotifyRespInd notifyRespInd = + new NotifyRespInd(mHeaders); + return notifyRespInd; + case PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parse: MESSAGE_TYPE_RETRIEVE_CONF"); + } + RetrieveConf retrieveConf = + new RetrieveConf(mHeaders, mBody); + + byte[] contentType = retrieveConf.getContentType(); + if (null == contentType) { + return null; + } + String ctTypeStr = new String(contentType); + if (ctTypeStr.equals(ContentType.MULTIPART_MIXED) + || ctTypeStr.equals(ContentType.MULTIPART_RELATED) + || ctTypeStr.equals(ContentType.MULTIPART_ALTERNATIVE)) { + // The MMS content type must be "application/vnd.wap.multipart.mixed" + // or "application/vnd.wap.multipart.related" + // or "application/vnd.wap.multipart.alternative" + return retrieveConf; + } else if (ctTypeStr.equals(ContentType.MULTIPART_ALTERNATIVE)) { + // "application/vnd.wap.multipart.alternative" + // should take only the first part. + PduPart firstPart = mBody.getPart(0); + mBody.removeAll(); + mBody.addPart(0, firstPart); + return retrieveConf; + } + return null; + case PduHeaders.MESSAGE_TYPE_DELIVERY_IND: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parse: MESSAGE_TYPE_DELIVERY_IND"); + } + DeliveryInd deliveryInd = + new DeliveryInd(mHeaders); + return deliveryInd; + case PduHeaders.MESSAGE_TYPE_ACKNOWLEDGE_IND: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parse: MESSAGE_TYPE_ACKNOWLEDGE_IND"); + } + AcknowledgeInd acknowledgeInd = + new AcknowledgeInd(mHeaders); + return acknowledgeInd; + case PduHeaders.MESSAGE_TYPE_READ_ORIG_IND: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parse: MESSAGE_TYPE_READ_ORIG_IND"); + } + ReadOrigInd readOrigInd = + new ReadOrigInd(mHeaders); + return readOrigInd; + case PduHeaders.MESSAGE_TYPE_READ_REC_IND: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parse: MESSAGE_TYPE_READ_REC_IND"); + } + ReadRecInd readRecInd = + new ReadRecInd(mHeaders); + return readRecInd; + default: + log("Parser doesn't support this message type in this version!"); + return null; + } + } + + /** + * Parse pdu headers. + * + * @param pduDataStream pdu data input stream + * @return headers in PduHeaders structure, null when parse fail + */ + protected PduHeaders parseHeaders(ByteArrayInputStream pduDataStream){ + if (pduDataStream == null) { + return null; + } + boolean keepParsing = true; + PduHeaders headers = new PduHeaders(); + + while (keepParsing && (pduDataStream.available() > 0)) { + pduDataStream.mark(1); + int headerField = extractByteValue(pduDataStream); + /* parse custom text header */ + if ((headerField >= TEXT_MIN) && (headerField <= TEXT_MAX)) { + pduDataStream.reset(); + byte [] bVal = parseWapString(pduDataStream, TYPE_TEXT_STRING); + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "TextHeader: " + new String(bVal)); + } + /* we should ignore it at the moment */ + continue; + } + switch (headerField) { + case PduHeaders.MESSAGE_TYPE: + { + int messageType = extractByteValue(pduDataStream); + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: messageType: " + messageType); + } + switch (messageType) { + // We don't support these kind of messages now. + case PduHeaders.MESSAGE_TYPE_FORWARD_REQ: + case PduHeaders.MESSAGE_TYPE_FORWARD_CONF: + case PduHeaders.MESSAGE_TYPE_MBOX_STORE_REQ: + case PduHeaders.MESSAGE_TYPE_MBOX_STORE_CONF: + case PduHeaders.MESSAGE_TYPE_MBOX_VIEW_REQ: + case PduHeaders.MESSAGE_TYPE_MBOX_VIEW_CONF: + case PduHeaders.MESSAGE_TYPE_MBOX_UPLOAD_REQ: + case PduHeaders.MESSAGE_TYPE_MBOX_UPLOAD_CONF: + case PduHeaders.MESSAGE_TYPE_MBOX_DELETE_REQ: + case PduHeaders.MESSAGE_TYPE_MBOX_DELETE_CONF: + case PduHeaders.MESSAGE_TYPE_MBOX_DESCR: + case PduHeaders.MESSAGE_TYPE_DELETE_REQ: + case PduHeaders.MESSAGE_TYPE_DELETE_CONF: + case PduHeaders.MESSAGE_TYPE_CANCEL_REQ: + case PduHeaders.MESSAGE_TYPE_CANCEL_CONF: + return null; + } + try { + headers.setOctet(messageType, headerField); + } catch(InvalidHeaderValueException e) { + log("Set invalid Octet value: " + messageType + + " into the header filed: " + headerField); + return null; + } catch(RuntimeException e) { + log(headerField + "is not Octet header field!"); + return null; + } + break; + } + /* Octect value */ + case PduHeaders.REPORT_ALLOWED: + case PduHeaders.ADAPTATION_ALLOWED: + case PduHeaders.DELIVERY_REPORT: + case PduHeaders.DRM_CONTENT: + case PduHeaders.DISTRIBUTION_INDICATOR: + case PduHeaders.QUOTAS: + case PduHeaders.READ_REPORT: + case PduHeaders.STORE: + case PduHeaders.STORED: + case PduHeaders.TOTALS: + case PduHeaders.SENDER_VISIBILITY: + case PduHeaders.READ_STATUS: + case PduHeaders.CANCEL_STATUS: + case PduHeaders.PRIORITY: + case PduHeaders.STATUS: + case PduHeaders.REPLY_CHARGING: + case PduHeaders.MM_STATE: + case PduHeaders.RECOMMENDED_RETRIEVAL_MODE: + case PduHeaders.CONTENT_CLASS: + case PduHeaders.RETRIEVE_STATUS: + case PduHeaders.STORE_STATUS: + /** + * The following field has a different value when + * used in the M-Mbox-Delete.conf and M-Delete.conf PDU. + * For now we ignore this fact, since we do not support these PDUs + */ + case PduHeaders.RESPONSE_STATUS: + { + int value = extractByteValue(pduDataStream); + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: byte: " + headerField + " value: " + + value); + } + + try { + headers.setOctet(value, headerField); + } catch(InvalidHeaderValueException e) { + log("Set invalid Octet value: " + value + + " into the header filed: " + headerField); + return null; + } catch(RuntimeException e) { + log(headerField + "is not Octet header field!"); + return null; + } + break; + } + + /* Long-Integer */ + case PduHeaders.DATE: + case PduHeaders.REPLY_CHARGING_SIZE: + case PduHeaders.MESSAGE_SIZE: + { + try { + long value = parseLongInteger(pduDataStream); + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: longint: " + headerField + " value: " + + value); + } + headers.setLongInteger(value, headerField); + } catch(RuntimeException e) { + log(headerField + "is not Long-Integer header field!"); + return null; + } + break; + } + + /* Integer-Value */ + case PduHeaders.MESSAGE_COUNT: + case PduHeaders.START: + case PduHeaders.LIMIT: + { + try { + long value = parseIntegerValue(pduDataStream); + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: int: " + headerField + " value: " + + value); + } + headers.setLongInteger(value, headerField); + } catch(RuntimeException e) { + log(headerField + "is not Long-Integer header field!"); + return null; + } + break; + } + + /* Text-String */ + case PduHeaders.TRANSACTION_ID: + case PduHeaders.REPLY_CHARGING_ID: + case PduHeaders.AUX_APPLIC_ID: + case PduHeaders.APPLIC_ID: + case PduHeaders.REPLY_APPLIC_ID: + /** + * The next three header fields are email addresses + * as defined in RFC2822, + * not including the characters "<" and ">" + */ + case PduHeaders.MESSAGE_ID: + case PduHeaders.REPLACE_ID: + case PduHeaders.CANCEL_ID: + /** + * The following field has a different value when + * used in the M-Mbox-Delete.conf and M-Delete.conf PDU. + * For now we ignore this fact, since we do not support these PDUs + */ + case PduHeaders.CONTENT_LOCATION: + { + byte[] value = parseWapString(pduDataStream, TYPE_TEXT_STRING); + if (null != value) { + try { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: string: " + headerField + " value: " + + new String(value)); + } + headers.setTextString(value, headerField); + } catch(NullPointerException e) { + log("null pointer error!"); + } catch(RuntimeException e) { + log(headerField + "is not Text-String header field!"); + return null; + } + } + break; + } + + /* Encoded-string-value */ + case PduHeaders.SUBJECT: + case PduHeaders.RECOMMENDED_RETRIEVAL_MODE_TEXT: + case PduHeaders.RETRIEVE_TEXT: + case PduHeaders.STATUS_TEXT: + case PduHeaders.STORE_STATUS_TEXT: + /* the next one is not support + * M-Mbox-Delete.conf and M-Delete.conf now */ + case PduHeaders.RESPONSE_TEXT: + { + EncodedStringValue value = + parseEncodedStringValue(pduDataStream); + if (null != value) { + try { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: encoded string: " + headerField + + " value: " + value.getString()); + } + headers.setEncodedStringValue(value, headerField); + } catch(NullPointerException e) { + log("null pointer error!"); + } catch (RuntimeException e) { + log(headerField + "is not Encoded-String-Value header field!"); + return null; + } + } + break; + } + + /* Addressing model */ + case PduHeaders.BCC: + case PduHeaders.CC: + case PduHeaders.TO: + { + EncodedStringValue value = + parseEncodedStringValue(pduDataStream); + if (null != value) { + byte[] address = value.getTextString(); + if (null != address) { + String str = new String(address); + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: (to/cc/bcc) address: " + headerField + + " value: " + str); + } + int endIndex = str.indexOf("/"); + if (endIndex > 0) { + str = str.substring(0, endIndex); + } + try { + value.setTextString(str.getBytes()); + } catch(NullPointerException e) { + log("null pointer error!"); + return null; + } + } + + try { + headers.appendEncodedStringValue(value, headerField); + } catch(NullPointerException e) { + log("null pointer error!"); + } catch(RuntimeException e) { + log(headerField + "is not Encoded-String-Value header field!"); + return null; + } + } + break; + } + + /* Value-length + * (Absolute-token Date-value | Relative-token Delta-seconds-value) */ + case PduHeaders.DELIVERY_TIME: + case PduHeaders.EXPIRY: + case PduHeaders.REPLY_CHARGING_DEADLINE: + { + /* parse Value-length */ + parseValueLength(pduDataStream); + + /* Absolute-token or Relative-token */ + int token = extractByteValue(pduDataStream); + + /* Date-value or Delta-seconds-value */ + long timeValue; + try { + timeValue = parseLongInteger(pduDataStream); + } catch(RuntimeException e) { + log(headerField + "is not Long-Integer header field!"); + return null; + } + if (PduHeaders.VALUE_RELATIVE_TOKEN == token) { + /* need to convert the Delta-seconds-value + * into Date-value */ + timeValue = System.currentTimeMillis()/1000 + timeValue; + } + + try { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: time value: " + headerField + + " value: " + timeValue); + } + headers.setLongInteger(timeValue, headerField); + } catch(RuntimeException e) { + log(headerField + "is not Long-Integer header field!"); + return null; + } + break; + } + + case PduHeaders.FROM: { + /* From-value = + * Value-length + * (Address-present-token Encoded-string-value | Insert-address-token) + */ + EncodedStringValue from = null; + parseValueLength(pduDataStream); /* parse value-length */ + + /* Address-present-token or Insert-address-token */ + int fromToken = extractByteValue(pduDataStream); + + /* Address-present-token or Insert-address-token */ + if (PduHeaders.FROM_ADDRESS_PRESENT_TOKEN == fromToken) { + /* Encoded-string-value */ + from = parseEncodedStringValue(pduDataStream); + if (null != from) { + byte[] address = from.getTextString(); + if (null != address) { + String str = new String(address); + int endIndex = str.indexOf("/"); + if (endIndex > 0) { + str = str.substring(0, endIndex); + } + try { + from.setTextString(str.getBytes()); + } catch(NullPointerException e) { + log("null pointer error!"); + return null; + } + } + } + } else { + try { + from = new EncodedStringValue( + PduHeaders.FROM_INSERT_ADDRESS_TOKEN_STR.getBytes()); + } catch(NullPointerException e) { + log(headerField + "is not Encoded-String-Value header field!"); + return null; + } + } + + try { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: from address: " + headerField + + " value: " + from.getString()); + } + headers.setEncodedStringValue(from, PduHeaders.FROM); + } catch(NullPointerException e) { + log("null pointer error!"); + } catch(RuntimeException e) { + log(headerField + "is not Encoded-String-Value header field!"); + return null; + } + break; + } + + case PduHeaders.MESSAGE_CLASS: { + /* Message-class-value = Class-identifier | Token-text */ + pduDataStream.mark(1); + int messageClass = extractByteValue(pduDataStream); + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: MESSAGE_CLASS: " + headerField + + " value: " + messageClass); + } + + if (messageClass >= PduHeaders.MESSAGE_CLASS_PERSONAL) { + /* Class-identifier */ + try { + if (PduHeaders.MESSAGE_CLASS_PERSONAL == messageClass) { + headers.setTextString( + PduHeaders.MESSAGE_CLASS_PERSONAL_STR.getBytes(), + PduHeaders.MESSAGE_CLASS); + } else if (PduHeaders.MESSAGE_CLASS_ADVERTISEMENT == messageClass) { + headers.setTextString( + PduHeaders.MESSAGE_CLASS_ADVERTISEMENT_STR.getBytes(), + PduHeaders.MESSAGE_CLASS); + } else if (PduHeaders.MESSAGE_CLASS_INFORMATIONAL == messageClass) { + headers.setTextString( + PduHeaders.MESSAGE_CLASS_INFORMATIONAL_STR.getBytes(), + PduHeaders.MESSAGE_CLASS); + } else if (PduHeaders.MESSAGE_CLASS_AUTO == messageClass) { + headers.setTextString( + PduHeaders.MESSAGE_CLASS_AUTO_STR.getBytes(), + PduHeaders.MESSAGE_CLASS); + } + } catch(NullPointerException e) { + log("null pointer error!"); + } catch(RuntimeException e) { + log(headerField + "is not Text-String header field!"); + return null; + } + } else { + /* Token-text */ + pduDataStream.reset(); + byte[] messageClassString = parseWapString(pduDataStream, TYPE_TEXT_STRING); + if (null != messageClassString) { + try { + headers.setTextString(messageClassString, PduHeaders.MESSAGE_CLASS); + } catch(NullPointerException e) { + log("null pointer error!"); + } catch(RuntimeException e) { + log(headerField + "is not Text-String header field!"); + return null; + } + } + } + break; + } + + case PduHeaders.MMS_VERSION: { + int version = parseShortInteger(pduDataStream); + + try { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: MMS_VERSION: " + headerField + + " value: " + version); + } + headers.setOctet(version, PduHeaders.MMS_VERSION); + } catch(InvalidHeaderValueException e) { + log("Set invalid Octet value: " + version + + " into the header filed: " + headerField); + return null; + } catch(RuntimeException e) { + log(headerField + "is not Octet header field!"); + return null; + } + break; + } + + case PduHeaders.PREVIOUSLY_SENT_BY: { + /* Previously-sent-by-value = + * Value-length Forwarded-count-value Encoded-string-value */ + /* parse value-length */ + parseValueLength(pduDataStream); + + /* parse Forwarded-count-value */ + try { + parseIntegerValue(pduDataStream); + } catch(RuntimeException e) { + log(headerField + " is not Integer-Value"); + return null; + } + + /* parse Encoded-string-value */ + EncodedStringValue previouslySentBy = + parseEncodedStringValue(pduDataStream); + if (null != previouslySentBy) { + try { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: PREVIOUSLY_SENT_BY: " + headerField + + " value: " + previouslySentBy.getString()); + } + headers.setEncodedStringValue(previouslySentBy, + PduHeaders.PREVIOUSLY_SENT_BY); + } catch(NullPointerException e) { + log("null pointer error!"); + } catch(RuntimeException e) { + log(headerField + "is not Encoded-String-Value header field!"); + return null; + } + } + break; + } + + case PduHeaders.PREVIOUSLY_SENT_DATE: { + /* Previously-sent-date-value = + * Value-length Forwarded-count-value Date-value */ + /* parse value-length */ + parseValueLength(pduDataStream); + + /* parse Forwarded-count-value */ + try { + parseIntegerValue(pduDataStream); + } catch(RuntimeException e) { + log(headerField + " is not Integer-Value"); + return null; + } + + /* Date-value */ + try { + long perviouslySentDate = parseLongInteger(pduDataStream); + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: PREVIOUSLY_SENT_DATE: " + headerField + + " value: " + perviouslySentDate); + } + headers.setLongInteger(perviouslySentDate, + PduHeaders.PREVIOUSLY_SENT_DATE); + } catch(RuntimeException e) { + log(headerField + "is not Long-Integer header field!"); + return null; + } + break; + } + + case PduHeaders.MM_FLAGS: { + /* MM-flags-value = + * Value-length + * ( Add-token | Remove-token | Filter-token ) + * Encoded-string-value + */ + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: MM_FLAGS: " + headerField + + " NOT REALLY SUPPORTED"); + } + + /* parse Value-length */ + parseValueLength(pduDataStream); + + /* Add-token | Remove-token | Filter-token */ + extractByteValue(pduDataStream); + + /* Encoded-string-value */ + parseEncodedStringValue(pduDataStream); + + /* not store this header filed in "headers", + * because now PduHeaders doesn't support it */ + break; + } + + /* Value-length + * (Message-total-token | Size-total-token) Integer-Value */ + case PduHeaders.MBOX_TOTALS: + case PduHeaders.MBOX_QUOTAS: + { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: MBOX_TOTALS: " + headerField); + } + /* Value-length */ + parseValueLength(pduDataStream); + + /* Message-total-token | Size-total-token */ + extractByteValue(pduDataStream); + + /*Integer-Value*/ + try { + parseIntegerValue(pduDataStream); + } catch(RuntimeException e) { + log(headerField + " is not Integer-Value"); + return null; + } + + /* not store these headers filed in "headers", + because now PduHeaders doesn't support them */ + break; + } + + case PduHeaders.ELEMENT_DESCRIPTOR: { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: ELEMENT_DESCRIPTOR: " + headerField); + } + parseContentType(pduDataStream, null); + + /* not store this header filed in "headers", + because now PduHeaders doesn't support it */ + break; + } + + case PduHeaders.CONTENT_TYPE: { + HashMap map = + new HashMap(); + byte[] contentType = + parseContentType(pduDataStream, map); + + if (null != contentType) { + try { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: CONTENT_TYPE: " + headerField + + Arrays.toString(contentType)); + } + headers.setTextString(contentType, PduHeaders.CONTENT_TYPE); + } catch(NullPointerException e) { + log("null pointer error!"); + } catch(RuntimeException e) { + log(headerField + "is not Text-String header field!"); + return null; + } + } + + /* get start parameter */ + mStartParam = (byte[]) map.get(PduPart.P_START); + + /* get charset parameter */ + mTypeParam= (byte[]) map.get(PduPart.P_TYPE); + + keepParsing = false; + break; + } + + case PduHeaders.CONTENT: + case PduHeaders.ADDITIONAL_HEADERS: + case PduHeaders.ATTRIBUTES: + default: { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: Unknown header: " + headerField); + } + log("Unknown header"); + } + } + } + + return headers; + } + + /** + * Parse pdu parts. + * + * @param pduDataStream pdu data input stream + * @return parts in PduBody structure + */ + protected PduBody parseParts(ByteArrayInputStream pduDataStream) { + if (pduDataStream == null) { + return null; + } + + int count = parseUnsignedInt(pduDataStream); // get the number of parts + PduBody body = new PduBody(); + + for (int i = 0 ; i < count ; i++) { + int headerLength = parseUnsignedInt(pduDataStream); + int dataLength = parseUnsignedInt(pduDataStream); + PduPart part = new PduPart(); + int startPos = pduDataStream.available(); + if (startPos <= 0) { + // Invalid part. + return null; + } + + /* parse part's content-type */ + HashMap map = new HashMap(); + byte[] contentType = parseContentType(pduDataStream, map); + if (null != contentType) { + part.setContentType(contentType); + } else { + part.setContentType((PduContentTypes.contentTypes[0]).getBytes()); //"*/*" + } + + /* get name parameter */ + byte[] name = (byte[]) map.get(PduPart.P_NAME); + if (null != name) { + part.setName(name); + } + + /* get charset parameter */ + Integer charset = (Integer) map.get(PduPart.P_CHARSET); + if (null != charset) { + part.setCharset(charset); + } + + /* parse part's headers */ + int endPos = pduDataStream.available(); + int partHeaderLen = headerLength - (startPos - endPos); + if (partHeaderLen > 0) { + if (false == parsePartHeaders(pduDataStream, part, partHeaderLen)) { + // Parse part header faild. + return null; + } + } else if (partHeaderLen < 0) { + // Invalid length of content-type. + return null; + } + + /* FIXME: check content-id, name, filename and content location, + * if not set anyone of them, generate a default content-location + */ + if ((null == part.getContentLocation()) + && (null == part.getName()) + && (null == part.getFilename()) + && (null == part.getContentId())) { + part.setContentLocation(Long.toOctalString( + System.currentTimeMillis()).getBytes()); + } + + /* get part's data */ + if (dataLength > 0) { + byte[] partData = new byte[dataLength]; + String partContentType = new String(part.getContentType()); + pduDataStream.read(partData, 0, dataLength); + if (partContentType.equalsIgnoreCase(ContentType.MULTIPART_ALTERNATIVE)) { + // parse "multipart/vnd.wap.multipart.alternative". + PduBody childBody = parseParts(new ByteArrayInputStream(partData)); + // take the first part of children. + part = childBody.getPart(0); + } else { + // Check Content-Transfer-Encoding. + byte[] partDataEncoding = part.getContentTransferEncoding(); + if (null != partDataEncoding) { + String encoding = new String(partDataEncoding); + if (encoding.equalsIgnoreCase(PduPart.P_BASE64)) { + // Decode "base64" into "binary". + partData = Base64.decodeBase64(partData); + } else if (encoding.equalsIgnoreCase(PduPart.P_QUOTED_PRINTABLE)) { + // Decode "quoted-printable" into "binary". + partData = QuotedPrintable.decodeQuotedPrintable(partData); + } else { + // "binary" is the default encoding. + } + } + if (null == partData) { + log("Decode part data error!"); + return null; + } + part.setData(partData); + } + } + + /* add this part to body */ + if (THE_FIRST_PART == checkPartPosition(part)) { + /* this is the first part */ + body.addPart(0, part); + } else { + /* add the part to the end */ + body.addPart(part); + } + } + + return body; + } + + /** + * Log status. + * + * @param text log information + */ + private static void log(String text) { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, text); + } + } + + /** + * Parse unsigned integer. + * + * @param pduDataStream pdu data input stream + * @return the integer, -1 when failed + */ + protected static int parseUnsignedInt(ByteArrayInputStream pduDataStream) { + /** + * From wap-230-wsp-20010705-a.pdf + * The maximum size of a uintvar is 32 bits. + * So it will be encoded in no more than 5 octets. + */ + assert(null != pduDataStream); + int result = 0; + int temp = pduDataStream.read(); + if (temp == -1) { + return temp; + } + + while((temp & 0x80) != 0) { + result = result << 7; + result |= temp & 0x7F; + temp = pduDataStream.read(); + if (temp == -1) { + return temp; + } + } + + result = result << 7; + result |= temp & 0x7F; + + return result; + } + + /** + * Parse value length. + * + * @param pduDataStream pdu data input stream + * @return the integer + */ + protected static int parseValueLength(ByteArrayInputStream pduDataStream) { + /** + * From wap-230-wsp-20010705-a.pdf + * Value-length = Short-length | (Length-quote Length) + * Short-length = + * Length-quote = + * Length = Uintvar-integer + * Uintvar-integer = 1*5 OCTET + */ + assert(null != pduDataStream); + int temp = pduDataStream.read(); + assert(-1 != temp); + int first = temp & 0xFF; + + if (first <= SHORT_LENGTH_MAX) { + return first; + } else if (first == LENGTH_QUOTE) { + return parseUnsignedInt(pduDataStream); + } + + throw new RuntimeException("Value length > LENGTH_QUOTE!"); + } + + /** + * Parse encoded string value. + * + * @param pduDataStream pdu data input stream + * @return the EncodedStringValue + */ + protected static EncodedStringValue parseEncodedStringValue(ByteArrayInputStream pduDataStream){ + /** + * From OMA-TS-MMS-ENC-V1_3-20050927-C.pdf + * Encoded-string-value = Text-string | Value-length Char-set Text-string + */ + assert(null != pduDataStream); + pduDataStream.mark(1); + EncodedStringValue returnValue = null; + int charset = 0; + int temp = pduDataStream.read(); + assert(-1 != temp); + int first = temp & 0xFF; + if (first == 0) { + return new EncodedStringValue(""); + } + + pduDataStream.reset(); + if (first < TEXT_MIN) { + parseValueLength(pduDataStream); + + charset = parseShortInteger(pduDataStream); //get the "Charset" + } + + byte[] textString = parseWapString(pduDataStream, TYPE_TEXT_STRING); + + try { + if (0 != charset) { + returnValue = new EncodedStringValue(charset, textString); + } else { + returnValue = new EncodedStringValue(textString); + } + } catch(Exception e) { + return null; + } + + return returnValue; + } + + /** + * Parse Text-String or Quoted-String. + * + * @param pduDataStream pdu data input stream + * @param stringType TYPE_TEXT_STRING or TYPE_QUOTED_STRING + * @return the string without End-of-string in byte array + */ + protected static byte[] parseWapString(ByteArrayInputStream pduDataStream, + int stringType) { + assert(null != pduDataStream); + /** + * From wap-230-wsp-20010705-a.pdf + * Text-string = [Quote] *TEXT End-of-string + * If the first character in the TEXT is in the range of 128-255, + * a Quote character must precede it. + * Otherwise the Quote character must be omitted. + * The Quote is not part of the contents. + * Quote = + * End-of-string = + * + * Quoted-string = *TEXT End-of-string + * + * Token-text = Token End-of-string + */ + + // Mark supposed beginning of Text-string + // We will have to mark again if first char is QUOTE or QUOTED_STRING_FLAG + pduDataStream.mark(1); + + // Check first char + int temp = pduDataStream.read(); + assert(-1 != temp); + if ((TYPE_QUOTED_STRING == stringType) && + (QUOTED_STRING_FLAG == temp)) { + // Mark again if QUOTED_STRING_FLAG and ignore it + pduDataStream.mark(1); + } else if ((TYPE_TEXT_STRING == stringType) && + (QUOTE == temp)) { + // Mark again if QUOTE and ignore it + pduDataStream.mark(1); + } else { + // Otherwise go back to origin + pduDataStream.reset(); + } + + // We are now definitely at the beginning of string + /** + * Return *TOKEN or *TEXT (Text-String without QUOTE, + * Quoted-String without QUOTED_STRING_FLAG and without End-of-string) + */ + return getWapString(pduDataStream, stringType); + } + + /** + * Check TOKEN data defined in RFC2616. + * @param ch checking data + * @return true when ch is TOKEN, false when ch is not TOKEN + */ + protected static boolean isTokenCharacter(int ch) { + /** + * Token = 1* + * separators = "("(40) | ")"(41) | "<"(60) | ">"(62) | "@"(64) + * | ","(44) | ";"(59) | ":"(58) | "\"(92) | <">(34) + * | "/"(47) | "["(91) | "]"(93) | "?"(63) | "="(61) + * | "{"(123) | "}"(125) | SP(32) | HT(9) + * CHAR = + * CTL = + * SP = + * HT = + */ + if((ch < 33) || (ch > 126)) { + return false; + } + + switch(ch) { + case '"': /* '"' */ + case '(': /* '(' */ + case ')': /* ')' */ + case ',': /* ',' */ + case '/': /* '/' */ + case ':': /* ':' */ + case ';': /* ';' */ + case '<': /* '<' */ + case '=': /* '=' */ + case '>': /* '>' */ + case '?': /* '?' */ + case '@': /* '@' */ + case '[': /* '[' */ + case '\\': /* '\' */ + case ']': /* ']' */ + case '{': /* '{' */ + case '}': /* '}' */ + return false; + } + + return true; + } + + /** + * Check TEXT data defined in RFC2616. + * @param ch checking data + * @return true when ch is TEXT, false when ch is not TEXT + */ + protected static boolean isText(int ch) { + /** + * TEXT = + * CTL = + * LWS = [CRLF] 1*( SP | HT ) + * CRLF = CR LF + * CR = + * LF = + */ + if(((ch >= 32) && (ch <= 126)) || ((ch >= 128) && (ch <= 255))) { + return true; + } + + switch(ch) { + case '\t': /* '\t' */ + case '\n': /* '\n' */ + case '\r': /* '\r' */ + return true; + } + + return false; + } + + protected static byte[] getWapString(ByteArrayInputStream pduDataStream, + int stringType) { + assert(null != pduDataStream); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + int temp = pduDataStream.read(); + assert(-1 != temp); + while((-1 != temp) && ('\0' != temp)) { + // check each of the character + if (stringType == TYPE_TOKEN_STRING) { + if (isTokenCharacter(temp)) { + out.write(temp); + } + } else { + if (isText(temp)) { + out.write(temp); + } + } + + temp = pduDataStream.read(); + assert(-1 != temp); + } + + if (out.size() > 0) { + return out.toByteArray(); + } + + return null; + } + + /** + * Extract a byte value from the input stream. + * + * @param pduDataStream pdu data input stream + * @return the byte + */ + protected static int extractByteValue(ByteArrayInputStream pduDataStream) { + assert(null != pduDataStream); + int temp = pduDataStream.read(); + assert(-1 != temp); + return temp & 0xFF; + } + + /** + * Parse Short-Integer. + * + * @param pduDataStream pdu data input stream + * @return the byte + */ + protected static int parseShortInteger(ByteArrayInputStream pduDataStream) { + /** + * From wap-230-wsp-20010705-a.pdf + * Short-integer = OCTET + * Integers in range 0-127 shall be encoded as a one + * octet value with the most significant bit set to one (1xxx xxxx) + * and with the value in the remaining least significant bits. + */ + assert(null != pduDataStream); + int temp = pduDataStream.read(); + assert(-1 != temp); + return temp & 0x7F; + } + + /** + * Parse Long-Integer. + * + * @param pduDataStream pdu data input stream + * @return long integer + */ + protected static long parseLongInteger(ByteArrayInputStream pduDataStream) { + /** + * From wap-230-wsp-20010705-a.pdf + * Long-integer = Short-length Multi-octet-integer + * The Short-length indicates the length of the Multi-octet-integer + * Multi-octet-integer = 1*30 OCTET + * The content octets shall be an unsigned integer value + * with the most significant octet encoded first (big-endian representation). + * The minimum number of octets must be used to encode the value. + * Short-length = + */ + assert(null != pduDataStream); + int temp = pduDataStream.read(); + assert(-1 != temp); + int count = temp & 0xFF; + + if (count > LONG_INTEGER_LENGTH_MAX) { + throw new RuntimeException("Octet count greater than 8 and I can't represent that!"); + } + + long result = 0; + + for (int i = 0 ; i < count ; i++) { + temp = pduDataStream.read(); + assert(-1 != temp); + result <<= 8; + result += (temp & 0xFF); + } + + return result; + } + + /** + * Parse Integer-Value. + * + * @param pduDataStream pdu data input stream + * @return long integer + */ + protected static long parseIntegerValue(ByteArrayInputStream pduDataStream) { + /** + * From wap-230-wsp-20010705-a.pdf + * Integer-Value = Short-integer | Long-integer + */ + assert(null != pduDataStream); + pduDataStream.mark(1); + int temp = pduDataStream.read(); + assert(-1 != temp); + pduDataStream.reset(); + if (temp > SHORT_INTEGER_MAX) { + return parseShortInteger(pduDataStream); + } else { + return parseLongInteger(pduDataStream); + } + } + + /** + * To skip length of the wap value. + * + * @param pduDataStream pdu data input stream + * @param length area size + * @return the values in this area + */ + protected static int skipWapValue(ByteArrayInputStream pduDataStream, int length) { + assert(null != pduDataStream); + byte[] area = new byte[length]; + int readLen = pduDataStream.read(area, 0, length); + if (readLen < length) { //The actually read length is lower than the length + return -1; + } else { + return readLen; + } + } + + /** + * Parse content type parameters. For now we just support + * four parameters used in mms: "type", "start", "name", "charset". + * + * @param pduDataStream pdu data input stream + * @param map to store parameters of Content-Type field + * @param length length of all the parameters + */ + protected static void parseContentTypeParams(ByteArrayInputStream pduDataStream, + HashMap map, Integer length) { + /** + * From wap-230-wsp-20010705-a.pdf + * Parameter = Typed-parameter | Untyped-parameter + * Typed-parameter = Well-known-parameter-token Typed-value + * the actual expected type of the value is implied by the well-known parameter + * Well-known-parameter-token = Integer-value + * the code values used for parameters are specified in the Assigned Numbers appendix + * Typed-value = Compact-value | Text-value + * In addition to the expected type, there may be no value. + * If the value cannot be encoded using the expected type, it shall be encoded as text. + * Compact-value = Integer-value | + * Date-value | Delta-seconds-value | Q-value | Version-value | + * Uri-value + * Untyped-parameter = Token-text Untyped-value + * the type of the value is unknown, but it shall be encoded as an integer, + * if that is possible. + * Untyped-value = Integer-value | Text-value + */ + assert(null != pduDataStream); + assert(length > 0); + + int startPos = pduDataStream.available(); + int tempPos = 0; + int lastLen = length; + while(0 < lastLen) { + int param = pduDataStream.read(); + assert(-1 != param); + lastLen--; + + switch (param) { + /** + * From rfc2387, chapter 3.1 + * The type parameter must be specified and its value is the MIME media + * type of the "root" body part. It permits a MIME user agent to + * determine the content-type without reference to the enclosed body + * part. If the value of the type parameter and the root body part's + * content-type differ then the User Agent's behavior is undefined. + * + * From wap-230-wsp-20010705-a.pdf + * type = Constrained-encoding + * Constrained-encoding = Extension-Media | Short-integer + * Extension-media = *TEXT End-of-string + */ + case PduPart.P_TYPE: + case PduPart.P_CT_MR_TYPE: + pduDataStream.mark(1); + int first = extractByteValue(pduDataStream); + pduDataStream.reset(); + if (first > TEXT_MAX) { + // Short-integer (well-known type) + int index = parseShortInteger(pduDataStream); + + if (index < PduContentTypes.contentTypes.length) { + byte[] type = (PduContentTypes.contentTypes[index]).getBytes(); + map.put(PduPart.P_TYPE, type); + } else { + //not support this type, ignore it. + } + } else { + // Text-String (extension-media) + byte[] type = parseWapString(pduDataStream, TYPE_TEXT_STRING); + if ((null != type) && (null != map)) { + map.put(PduPart.P_TYPE, type); + } + } + + tempPos = pduDataStream.available(); + lastLen = length - (startPos - tempPos); + break; + + /** + * From oma-ts-mms-conf-v1_3.pdf, chapter 10.2.3. + * Start Parameter Referring to Presentation + * + * From rfc2387, chapter 3.2 + * The start parameter, if given, is the content-ID of the compound + * object's "root". If not present the "root" is the first body part in + * the Multipart/Related entity. The "root" is the element the + * applications processes first. + * + * From wap-230-wsp-20010705-a.pdf + * start = Text-String + */ + case PduPart.P_START: + case PduPart.P_DEP_START: + byte[] start = parseWapString(pduDataStream, TYPE_TEXT_STRING); + if ((null != start) && (null != map)) { + map.put(PduPart.P_START, start); + } + + tempPos = pduDataStream.available(); + lastLen = length - (startPos - tempPos); + break; + + /** + * From oma-ts-mms-conf-v1_3.pdf + * In creation, the character set SHALL be either us-ascii + * (IANA MIBenum 3) or utf-8 (IANA MIBenum 106)[Unicode]. + * In retrieval, both us-ascii and utf-8 SHALL be supported. + * + * From wap-230-wsp-20010705-a.pdf + * charset = Well-known-charset|Text-String + * Well-known-charset = Any-charset | Integer-value + * Both are encoded using values from Character Set + * Assignments table in Assigned Numbers + * Any-charset = + * Equivalent to the special RFC2616 charset value "*" + */ + case PduPart.P_CHARSET: + pduDataStream.mark(1); + int firstValue = extractByteValue(pduDataStream); + pduDataStream.reset(); + //Check first char + if (((firstValue > TEXT_MIN) && (firstValue < TEXT_MAX)) || + (END_STRING_FLAG == firstValue)) { + //Text-String (extension-charset) + byte[] charsetStr = parseWapString(pduDataStream, TYPE_TEXT_STRING); + try { + int charsetInt = CharacterSets.getMibEnumValue( + new String(charsetStr)); + map.put(PduPart.P_CHARSET, charsetInt); + } catch (UnsupportedEncodingException e) { + // Not a well-known charset, use "*". + Log.e(LOG_TAG, Arrays.toString(charsetStr), e); + map.put(PduPart.P_CHARSET, CharacterSets.ANY_CHARSET); + } + } else { + //Well-known-charset + int charset = (int) parseIntegerValue(pduDataStream); + if (map != null) { + map.put(PduPart.P_CHARSET, charset); + } + } + + tempPos = pduDataStream.available(); + lastLen = length - (startPos - tempPos); + break; + + /** + * From oma-ts-mms-conf-v1_3.pdf + * A name for multipart object SHALL be encoded using name-parameter + * for Content-Type header in WSP multipart headers. + * + * From wap-230-wsp-20010705-a.pdf + * name = Text-String + */ + case PduPart.P_DEP_NAME: + case PduPart.P_NAME: + byte[] name = parseWapString(pduDataStream, TYPE_TEXT_STRING); + if ((null != name) && (null != map)) { + map.put(PduPart.P_NAME, name); + } + + tempPos = pduDataStream.available(); + lastLen = length - (startPos - tempPos); + break; + default: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "Not supported Content-Type parameter"); + } + if (-1 == skipWapValue(pduDataStream, lastLen)) { + Log.e(LOG_TAG, "Corrupt Content-Type"); + } else { + lastLen = 0; + } + break; + } + } + + if (0 != lastLen) { + Log.e(LOG_TAG, "Corrupt Content-Type"); + } + } + + /** + * Parse content type. + * + * @param pduDataStream pdu data input stream + * @param map to store parameters in Content-Type header field + * @return Content-Type value + */ + protected static byte[] parseContentType(ByteArrayInputStream pduDataStream, + HashMap map) { + /** + * From wap-230-wsp-20010705-a.pdf + * Content-type-value = Constrained-media | Content-general-form + * Content-general-form = Value-length Media-type + * Media-type = (Well-known-media | Extension-Media) *(Parameter) + */ + assert(null != pduDataStream); + + byte[] contentType = null; + pduDataStream.mark(1); + int temp = pduDataStream.read(); + assert(-1 != temp); + pduDataStream.reset(); + + int cur = (temp & 0xFF); + + if (cur < TEXT_MIN) { + int length = parseValueLength(pduDataStream); + int startPos = pduDataStream.available(); + pduDataStream.mark(1); + temp = pduDataStream.read(); + assert(-1 != temp); + pduDataStream.reset(); + int first = (temp & 0xFF); + + if ((first >= TEXT_MIN) && (first <= TEXT_MAX)) { + contentType = parseWapString(pduDataStream, TYPE_TEXT_STRING); + } else if (first > TEXT_MAX) { + int index = parseShortInteger(pduDataStream); + + if (index < PduContentTypes.contentTypes.length) { //well-known type + contentType = (PduContentTypes.contentTypes[index]).getBytes(); + } else { + pduDataStream.reset(); + contentType = parseWapString(pduDataStream, TYPE_TEXT_STRING); + } + } else { + Log.e(LOG_TAG, "Corrupt content-type"); + return (PduContentTypes.contentTypes[0]).getBytes(); //"*/*" + } + + int endPos = pduDataStream.available(); + int parameterLen = length - (startPos - endPos); + if (parameterLen > 0) {//have parameters + parseContentTypeParams(pduDataStream, map, parameterLen); + } + + if (parameterLen < 0) { + Log.e(LOG_TAG, "Corrupt MMS message"); + return (PduContentTypes.contentTypes[0]).getBytes(); //"*/*" + } + } else if (cur <= TEXT_MAX) { + contentType = parseWapString(pduDataStream, TYPE_TEXT_STRING); + } else { + contentType = + (PduContentTypes.contentTypes[parseShortInteger(pduDataStream)]).getBytes(); + } + + return contentType; + } + + /** + * Parse part's headers. + * + * @param pduDataStream pdu data input stream + * @param part to store the header informations of the part + * @param length length of the headers + * @return true if parse successfully, false otherwise + */ + protected boolean parsePartHeaders(ByteArrayInputStream pduDataStream, + PduPart part, int length) { + assert(null != pduDataStream); + assert(null != part); + assert(length > 0); + + /** + * From oma-ts-mms-conf-v1_3.pdf, chapter 10.2. + * A name for multipart object SHALL be encoded using name-parameter + * for Content-Type header in WSP multipart headers. + * In decoding, name-parameter of Content-Type SHALL be used if available. + * If name-parameter of Content-Type is not available, + * filename parameter of Content-Disposition header SHALL be used if available. + * If neither name-parameter of Content-Type header nor filename parameter + * of Content-Disposition header is available, + * Content-Location header SHALL be used if available. + * + * Within SMIL part the reference to the media object parts SHALL use + * either Content-ID or Content-Location mechanism [RFC2557] + * and the corresponding WSP part headers in media object parts + * contain the corresponding definitions. + */ + int startPos = pduDataStream.available(); + int tempPos = 0; + int lastLen = length; + while(0 < lastLen) { + int header = pduDataStream.read(); + assert(-1 != header); + lastLen--; + + if (header > TEXT_MAX) { + // Number assigned headers. + switch (header) { + case PduPart.P_CONTENT_LOCATION: + /** + * From wap-230-wsp-20010705-a.pdf, chapter 8.4.2.21 + * Content-location-value = Uri-value + */ + byte[] contentLocation = parseWapString(pduDataStream, TYPE_TEXT_STRING); + if (null != contentLocation) { + part.setContentLocation(contentLocation); + } + + tempPos = pduDataStream.available(); + lastLen = length - (startPos - tempPos); + break; + case PduPart.P_CONTENT_ID: + /** + * From wap-230-wsp-20010705-a.pdf, chapter 8.4.2.21 + * Content-ID-value = Quoted-string + */ + byte[] contentId = parseWapString(pduDataStream, TYPE_QUOTED_STRING); + if (null != contentId) { + part.setContentId(contentId); + } + + tempPos = pduDataStream.available(); + lastLen = length - (startPos - tempPos); + break; + case PduPart.P_DEP_CONTENT_DISPOSITION: + case PduPart.P_CONTENT_DISPOSITION: + /** + * From wap-230-wsp-20010705-a.pdf, chapter 8.4.2.21 + * Content-disposition-value = Value-length Disposition *(Parameter) + * Disposition = Form-data | Attachment | Inline | Token-text + * Form-data = + * Attachment = + * Inline = + */ + + /* + * some carrier mmsc servers do not support content_disposition + * field correctly + */ + if (mParseContentDisposition) { + int len = parseValueLength(pduDataStream); + pduDataStream.mark(1); + int thisStartPos = pduDataStream.available(); + int thisEndPos = 0; + int value = pduDataStream.read(); + + if (value == PduPart.P_DISPOSITION_FROM_DATA ) { + part.setContentDisposition(PduPart.DISPOSITION_FROM_DATA); + } else if (value == PduPart.P_DISPOSITION_ATTACHMENT) { + part.setContentDisposition(PduPart.DISPOSITION_ATTACHMENT); + } else if (value == PduPart.P_DISPOSITION_INLINE) { + part.setContentDisposition(PduPart.DISPOSITION_INLINE); + } else { + pduDataStream.reset(); + /* Token-text */ + part.setContentDisposition(parseWapString(pduDataStream + , TYPE_TEXT_STRING)); + } + + /* get filename parameter and skip other parameters */ + thisEndPos = pduDataStream.available(); + if (thisStartPos - thisEndPos < len) { + value = pduDataStream.read(); + if (value == PduPart.P_FILENAME) { //filename is text-string + part.setFilename(parseWapString(pduDataStream + , TYPE_TEXT_STRING)); + } + + /* skip other parameters */ + thisEndPos = pduDataStream.available(); + if (thisStartPos - thisEndPos < len) { + int last = len - (thisStartPos - thisEndPos); + byte[] temp = new byte[last]; + pduDataStream.read(temp, 0, last); + } + } + + tempPos = pduDataStream.available(); + lastLen = length - (startPos - tempPos); + } + break; + default: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "Not supported Part headers: " + header); + } + if (-1 == skipWapValue(pduDataStream, lastLen)) { + Log.e(LOG_TAG, "Corrupt Part headers"); + return false; + } + lastLen = 0; + break; + } + } else if ((header >= TEXT_MIN) && (header <= TEXT_MAX)) { + // Not assigned header. + byte[] tempHeader = parseWapString(pduDataStream, TYPE_TEXT_STRING); + byte[] tempValue = parseWapString(pduDataStream, TYPE_TEXT_STRING); + + // Check the header whether it is "Content-Transfer-Encoding". + if (true == + PduPart.CONTENT_TRANSFER_ENCODING.equalsIgnoreCase(new String(tempHeader))) { + part.setContentTransferEncoding(tempValue); + } + + tempPos = pduDataStream.available(); + lastLen = length - (startPos - tempPos); + } else { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "Not supported Part headers: " + header); + } + // Skip all headers of this part. + if (-1 == skipWapValue(pduDataStream, lastLen)) { + Log.e(LOG_TAG, "Corrupt Part headers"); + return false; + } + lastLen = 0; + } + } + + if (0 != lastLen) { + Log.e(LOG_TAG, "Corrupt Part headers"); + return false; + } + + return true; + } + + /** + * Check the position of a specified part. + * + * @param part the part to be checked + * @return part position, THE_FIRST_PART when it's the + * first one, THE_LAST_PART when it's the last one. + */ + private static int checkPartPosition(PduPart part) { + assert(null != part); + if ((null == mTypeParam) && + (null == mStartParam)) { + return THE_LAST_PART; + } + + /* check part's content-id */ + if (null != mStartParam) { + byte[] contentId = part.getContentId(); + if (null != contentId) { + if (true == Arrays.equals(mStartParam, contentId)) { + return THE_FIRST_PART; + } + } + // This is not the first part, so append to end (keeping the original order) + // Check b/19607294 for details of this change + return THE_LAST_PART; + } + + /* check part's content-type */ + if (null != mTypeParam) { + byte[] contentType = part.getContentType(); + if (null != contentType) { + if (true == Arrays.equals(mTypeParam, contentType)) { + return THE_FIRST_PART; + } + } + } + + return THE_LAST_PART; + } + + /** + * Check mandatory headers of a pdu. + * + * @param headers pdu headers + * @return true if the pdu has all of the mandatory headers, false otherwise. + */ + protected static boolean checkMandatoryHeader(PduHeaders headers) { + if (null == headers) { + return false; + } + + /* get message type */ + int messageType = headers.getOctet(PduHeaders.MESSAGE_TYPE); + + /* check Mms-Version field */ + int mmsVersion = headers.getOctet(PduHeaders.MMS_VERSION); + if (0 == mmsVersion) { + // Every message should have Mms-Version field. + return false; + } + + /* check mandatory header fields */ + switch (messageType) { + case PduHeaders.MESSAGE_TYPE_SEND_REQ: + // Content-Type field. + byte[] srContentType = headers.getTextString(PduHeaders.CONTENT_TYPE); + if (null == srContentType) { + return false; + } + + // From field. + EncodedStringValue srFrom = headers.getEncodedStringValue(PduHeaders.FROM); + if (null == srFrom) { + return false; + } + + // Transaction-Id field. + byte[] srTransactionId = headers.getTextString(PduHeaders.TRANSACTION_ID); + if (null == srTransactionId) { + return false; + } + + break; + case PduHeaders.MESSAGE_TYPE_SEND_CONF: + // Response-Status field. + int scResponseStatus = headers.getOctet(PduHeaders.RESPONSE_STATUS); + if (0 == scResponseStatus) { + return false; + } + + // Transaction-Id field. + byte[] scTransactionId = headers.getTextString(PduHeaders.TRANSACTION_ID); + if (null == scTransactionId) { + return false; + } + + break; + case PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND: + // Content-Location field. + byte[] niContentLocation = headers.getTextString(PduHeaders.CONTENT_LOCATION); + if (null == niContentLocation) { + return false; + } + + // Expiry field. + long niExpiry = headers.getLongInteger(PduHeaders.EXPIRY); + if (-1 == niExpiry) { + return false; + } + + // Message-Class field. + byte[] niMessageClass = headers.getTextString(PduHeaders.MESSAGE_CLASS); + if (null == niMessageClass) { + return false; + } + + // Message-Size field. + long niMessageSize = headers.getLongInteger(PduHeaders.MESSAGE_SIZE); + if (-1 == niMessageSize) { + return false; + } + + // Transaction-Id field. + byte[] niTransactionId = headers.getTextString(PduHeaders.TRANSACTION_ID); + if (null == niTransactionId) { + return false; + } + + break; + case PduHeaders.MESSAGE_TYPE_NOTIFYRESP_IND: + // Status field. + int nriStatus = headers.getOctet(PduHeaders.STATUS); + if (0 == nriStatus) { + return false; + } + + // Transaction-Id field. + byte[] nriTransactionId = headers.getTextString(PduHeaders.TRANSACTION_ID); + if (null == nriTransactionId) { + return false; + } + + break; + case PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF: + // Content-Type field. + byte[] rcContentType = headers.getTextString(PduHeaders.CONTENT_TYPE); + if (null == rcContentType) { + return false; + } + + // Date field. + long rcDate = headers.getLongInteger(PduHeaders.DATE); + if (-1 == rcDate) { + return false; + } + + break; + case PduHeaders.MESSAGE_TYPE_DELIVERY_IND: + // Date field. + long diDate = headers.getLongInteger(PduHeaders.DATE); + if (-1 == diDate) { + return false; + } + + // Message-Id field. + byte[] diMessageId = headers.getTextString(PduHeaders.MESSAGE_ID); + if (null == diMessageId) { + return false; + } + + // Status field. + int diStatus = headers.getOctet(PduHeaders.STATUS); + if (0 == diStatus) { + return false; + } + + // To field. + EncodedStringValue[] diTo = headers.getEncodedStringValues(PduHeaders.TO); + if (null == diTo) { + return false; + } + + break; + case PduHeaders.MESSAGE_TYPE_ACKNOWLEDGE_IND: + // Transaction-Id field. + byte[] aiTransactionId = headers.getTextString(PduHeaders.TRANSACTION_ID); + if (null == aiTransactionId) { + return false; + } + + break; + case PduHeaders.MESSAGE_TYPE_READ_ORIG_IND: + // Date field. + long roDate = headers.getLongInteger(PduHeaders.DATE); + if (-1 == roDate) { + return false; + } + + // From field. + EncodedStringValue roFrom = headers.getEncodedStringValue(PduHeaders.FROM); + if (null == roFrom) { + return false; + } + + // Message-Id field. + byte[] roMessageId = headers.getTextString(PduHeaders.MESSAGE_ID); + if (null == roMessageId) { + return false; + } + + // Read-Status field. + int roReadStatus = headers.getOctet(PduHeaders.READ_STATUS); + if (0 == roReadStatus) { + return false; + } + + // To field. + EncodedStringValue[] roTo = headers.getEncodedStringValues(PduHeaders.TO); + if (null == roTo) { + return false; + } + + break; + case PduHeaders.MESSAGE_TYPE_READ_REC_IND: + // From field. + EncodedStringValue rrFrom = headers.getEncodedStringValue(PduHeaders.FROM); + if (null == rrFrom) { + return false; + } + + // Message-Id field. + byte[] rrMessageId = headers.getTextString(PduHeaders.MESSAGE_ID); + if (null == rrMessageId) { + return false; + } + + // Read-Status field. + int rrReadStatus = headers.getOctet(PduHeaders.READ_STATUS); + if (0 == rrReadStatus) { + return false; + } + + // To field. + EncodedStringValue[] rrTo = headers.getEncodedStringValues(PduHeaders.TO); + if (null == rrTo) { + return false; + } + + break; + default: + // Parser doesn't support this message type in this version. + return false; + } + + return true; + } +} diff --git a/src/android/support/v7/mms/pdu/PduPart.java b/src/android/support/v7/mms/pdu/PduPart.java new file mode 100644 index 0000000..ec8e1a2 --- /dev/null +++ b/src/android/support/v7/mms/pdu/PduPart.java @@ -0,0 +1,414 @@ +/* + * Copyright (C) 2007-2008 Esmertec AG. + * Copyright (C) 2007-2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +import android.net.Uri; + +import java.util.HashMap; +import java.util.Map; + +/** + * The pdu part. + */ +public class PduPart { + /** + * Well-Known Parameters. + */ + public static final int P_Q = 0x80; + public static final int P_CHARSET = 0x81; + public static final int P_LEVEL = 0x82; + public static final int P_TYPE = 0x83; + public static final int P_DEP_NAME = 0x85; + public static final int P_DEP_FILENAME = 0x86; + public static final int P_DIFFERENCES = 0x87; + public static final int P_PADDING = 0x88; + // This value of "TYPE" s used with Content-Type: multipart/related + public static final int P_CT_MR_TYPE = 0x89; + public static final int P_DEP_START = 0x8A; + public static final int P_DEP_START_INFO = 0x8B; + public static final int P_DEP_COMMENT = 0x8C; + public static final int P_DEP_DOMAIN = 0x8D; + public static final int P_MAX_AGE = 0x8E; + public static final int P_DEP_PATH = 0x8F; + public static final int P_SECURE = 0x90; + public static final int P_SEC = 0x91; + public static final int P_MAC = 0x92; + public static final int P_CREATION_DATE = 0x93; + public static final int P_MODIFICATION_DATE = 0x94; + public static final int P_READ_DATE = 0x95; + public static final int P_SIZE = 0x96; + public static final int P_NAME = 0x97; + public static final int P_FILENAME = 0x98; + public static final int P_START = 0x99; + public static final int P_START_INFO = 0x9A; + public static final int P_COMMENT = 0x9B; + public static final int P_DOMAIN = 0x9C; + public static final int P_PATH = 0x9D; + + /** + * Header field names. + */ + public static final int P_CONTENT_TYPE = 0x91; + public static final int P_CONTENT_LOCATION = 0x8E; + public static final int P_CONTENT_ID = 0xC0; + public static final int P_DEP_CONTENT_DISPOSITION = 0xAE; + public static final int P_CONTENT_DISPOSITION = 0xC5; + // The next header is unassigned header, use reserved header(0x48) value. + public static final int P_CONTENT_TRANSFER_ENCODING = 0xC8; + + /** + * Content=Transfer-Encoding string. + */ + public static final String CONTENT_TRANSFER_ENCODING = + "Content-Transfer-Encoding"; + + /** + * Value of Content-Transfer-Encoding. + */ + public static final String P_BINARY = "binary"; + public static final String P_7BIT = "7bit"; + public static final String P_8BIT = "8bit"; + public static final String P_BASE64 = "base64"; + public static final String P_QUOTED_PRINTABLE = "quoted-printable"; + + /** + * Value of disposition can be set to PduPart when the value is octet in + * the PDU. + * "from-data" instead of Form-data. + * "attachment" instead of Attachment. + * "inline" instead of Inline. + */ + static final byte[] DISPOSITION_FROM_DATA = "from-data".getBytes(); + static final byte[] DISPOSITION_ATTACHMENT = "attachment".getBytes(); + static final byte[] DISPOSITION_INLINE = "inline".getBytes(); + + /** + * Content-Disposition value. + */ + public static final int P_DISPOSITION_FROM_DATA = 0x80; + public static final int P_DISPOSITION_ATTACHMENT = 0x81; + public static final int P_DISPOSITION_INLINE = 0x82; + + /** + * Header of part. + */ + private Map mPartHeader = null; + + /** + * Data uri. + */ + private Uri mUri = null; + + /** + * Part data. + */ + private byte[] mPartData = null; + + private static final String TAG = "PduPart"; + + /** + * Empty Constructor. + */ + public PduPart() { + mPartHeader = new HashMap(); + } + + /** + * Set part data. The data are stored as byte array. + * + * @param data the data + */ + public void setData(byte[] data) { + if(data == null) { + return; + } + + mPartData = new byte[data.length]; + System.arraycopy(data, 0, mPartData, 0, data.length); + } + + /** + * @return A copy of the part data or null if the data wasn't set or + * the data is stored as Uri. + * @see #getDataUri + */ + public byte[] getData() { + if(mPartData == null) { + return null; + } + + byte[] byteArray = new byte[mPartData.length]; + System.arraycopy(mPartData, 0, byteArray, 0, mPartData.length); + return byteArray; + } + + /** + * @return The length of the data, if this object have data, else 0. + */ + public int getDataLength() { + if(mPartData != null){ + return mPartData.length; + } else { + return 0; + } + } + + + /** + * Set data uri. The data are stored as Uri. + * + * @param uri the uri + */ + public void setDataUri(Uri uri) { + mUri = uri; + } + + /** + * @return The Uri of the part data or null if the data wasn't set or + * the data is stored as byte array. + * @see #getData + */ + public Uri getDataUri() { + return mUri; + } + + /** + * Set Content-id value + * + * @param contentId the content-id value + * @throws NullPointerException if the value is null. + */ + public void setContentId(byte[] contentId) { + if((contentId == null) || (contentId.length == 0)) { + throw new IllegalArgumentException( + "Content-Id may not be null or empty."); + } + + if ((contentId.length > 1) + && ((char) contentId[0] == '<') + && ((char) contentId[contentId.length - 1] == '>')) { + mPartHeader.put(P_CONTENT_ID, contentId); + return; + } + + // Insert beginning '<' and trailing '>' for Content-Id. + byte[] buffer = new byte[contentId.length + 2]; + buffer[0] = (byte) (0xff & '<'); + buffer[buffer.length - 1] = (byte) (0xff & '>'); + System.arraycopy(contentId, 0, buffer, 1, contentId.length); + mPartHeader.put(P_CONTENT_ID, buffer); + } + + /** + * Get Content-id value. + * + * @return the value + */ + public byte[] getContentId() { + return (byte[]) mPartHeader.get(P_CONTENT_ID); + } + + /** + * Set Char-set value. + * + * @param charset the value + */ + public void setCharset(int charset) { + mPartHeader.put(P_CHARSET, charset); + } + + /** + * Get Char-set value + * + * @return the charset value. Return 0 if charset was not set. + */ + public int getCharset() { + Integer charset = (Integer) mPartHeader.get(P_CHARSET); + if(charset == null) { + return 0; + } else { + return charset.intValue(); + } + } + + /** + * Set Content-Location value. + * + * @param contentLocation the value + * @throws NullPointerException if the value is null. + */ + public void setContentLocation(byte[] contentLocation) { + if(contentLocation == null) { + throw new NullPointerException("null content-location"); + } + + mPartHeader.put(P_CONTENT_LOCATION, contentLocation); + } + + /** + * Get Content-Location value. + * + * @return the value + * return PduPart.disposition[0] instead of (Form-data). + * return PduPart.disposition[1] instead of (Attachment). + * return PduPart.disposition[2] instead of (Inline). + */ + public byte[] getContentLocation() { + return (byte[]) mPartHeader.get(P_CONTENT_LOCATION); + } + + /** + * Set Content-Disposition value. + * Use PduPart.disposition[0] instead of (Form-data). + * Use PduPart.disposition[1] instead of (Attachment). + * Use PduPart.disposition[2] instead of (Inline). + * + * @param contentDisposition the value + * @throws NullPointerException if the value is null. + */ + public void setContentDisposition(byte[] contentDisposition) { + if(contentDisposition == null) { + throw new NullPointerException("null content-disposition"); + } + + mPartHeader.put(P_CONTENT_DISPOSITION, contentDisposition); + } + + /** + * Get Content-Disposition value. + * + * @return the value + */ + public byte[] getContentDisposition() { + return (byte[]) mPartHeader.get(P_CONTENT_DISPOSITION); + } + + /** + * Set Content-Type value. + * + * @param contentType the content type + * @throws NullPointerException if the value is null. + */ + public void setContentType(byte[] contentType) { + if(contentType == null) { + throw new NullPointerException("null content-type"); + } + + mPartHeader.put(P_CONTENT_TYPE, contentType); + } + + /** + * Get Content-Type value of part. + * + * @return the value + */ + public byte[] getContentType() { + return (byte[]) mPartHeader.get(P_CONTENT_TYPE); + } + + /** + * Set Content-Transfer-Encoding value + * + * @param contentTransferEncoding the Content-Transfer-Encoding value + * @throws NullPointerException if the value is null. + */ + public void setContentTransferEncoding(byte[] contentTransferEncoding) { + if(contentTransferEncoding == null) { + throw new NullPointerException("null content-transfer-encoding"); + } + + mPartHeader.put(P_CONTENT_TRANSFER_ENCODING, contentTransferEncoding); + } + + /** + * Get Content-Transfer-Encoding value. + * + * @return the value + */ + public byte[] getContentTransferEncoding() { + return (byte[]) mPartHeader.get(P_CONTENT_TRANSFER_ENCODING); + } + + /** + * Set Content-type parameter: name. + * + * @param name the name value + * @throws NullPointerException if the value is null. + */ + public void setName(byte[] name) { + if(null == name) { + throw new NullPointerException("null content-id"); + } + + mPartHeader.put(P_NAME, name); + } + + /** + * Get content-type parameter: name. + * + * @return the name + */ + public byte[] getName() { + return (byte[]) mPartHeader.get(P_NAME); + } + + /** + * Get Content-disposition parameter: filename + * + * @param fileName the filename value + * @throws NullPointerException if the value is null. + */ + public void setFilename(byte[] fileName) { + if(null == fileName) { + throw new NullPointerException("null content-id"); + } + + mPartHeader.put(P_FILENAME, fileName); + } + + /** + * Set Content-disposition parameter: filename + * + * @return the filename + */ + public byte[] getFilename() { + return (byte[]) mPartHeader.get(P_FILENAME); + } + + public String generateLocation() { + // Assumption: At least one of the content-location / name / filename + // or content-id should be set. This is guaranteed by the PduParser + // for incoming messages and by MM composer for outgoing messages. + byte[] location = (byte[]) mPartHeader.get(P_NAME); + if(null == location) { + location = (byte[]) mPartHeader.get(P_FILENAME); + + if (null == location) { + location = (byte[]) mPartHeader.get(P_CONTENT_LOCATION); + } + } + + if (null == location) { + byte[] contentId = (byte[]) mPartHeader.get(P_CONTENT_ID); + return "cid:" + new String(contentId); + } else { + return new String(location); + } + } +} + diff --git a/src/android/support/v7/mms/pdu/QuotedPrintable.java b/src/android/support/v7/mms/pdu/QuotedPrintable.java new file mode 100644 index 0000000..ea0abc5 --- /dev/null +++ b/src/android/support/v7/mms/pdu/QuotedPrintable.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +import java.io.ByteArrayOutputStream; + +public class QuotedPrintable { + private static byte ESCAPE_CHAR = '='; + + /** + * Decodes an array quoted-printable characters into an array of original bytes. + * Escaped characters are converted back to their original representation. + * + *

+ * This function implements a subset of + * quoted-printable encoding specification (rule #1 and rule #2) + * as defined in RFC 1521. + *

+ * + * @param bytes array of quoted-printable characters + * @return array of original bytes, + * null if quoted-printable decoding is unsuccessful. + */ + public static final byte[] decodeQuotedPrintable(byte[] bytes) { + if (bytes == null) { + return null; + } + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + for (int i = 0; i < bytes.length; i++) { + int b = bytes[i]; + if (b == ESCAPE_CHAR) { + try { + if('\r' == (char)bytes[i + 1] && + '\n' == (char)bytes[i + 2]) { + i += 2; + continue; + } + int u = Character.digit((char) bytes[++i], 16); + int l = Character.digit((char) bytes[++i], 16); + if (u == -1 || l == -1) { + return null; + } + buffer.write((char) ((u << 4) + l)); + } catch (ArrayIndexOutOfBoundsException e) { + return null; + } + } else { + buffer.write(b); + } + } + return buffer.toByteArray(); + } +} diff --git a/src/android/support/v7/mms/pdu/ReadOrigInd.java b/src/android/support/v7/mms/pdu/ReadOrigInd.java new file mode 100644 index 0000000..500a59d --- /dev/null +++ b/src/android/support/v7/mms/pdu/ReadOrigInd.java @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +public class ReadOrigInd extends GenericPdu { + /** + * Empty constructor. + * Since the Pdu corresponding to this class is constructed + * by the Proxy-Relay server, this class is only instantiated + * by the Pdu Parser. + * + * @throws InvalidHeaderValueException if error occurs. + */ + public ReadOrigInd() throws InvalidHeaderValueException { + super(); + setMessageType(PduHeaders.MESSAGE_TYPE_READ_ORIG_IND); + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + ReadOrigInd(PduHeaders headers) { + super(headers); + } + + /** + * Get Date value. + * + * @return the value + */ + public long getDate() { + return mPduHeaders.getLongInteger(PduHeaders.DATE); + } + + /** + * Set Date value. + * + * @param value the value + */ + public void setDate(long value) { + mPduHeaders.setLongInteger(value, PduHeaders.DATE); + } + + /** + * Get From value. + * From-value = Value-length + * (Address-present-token Encoded-string-value | Insert-address-token) + * + * @return the value + */ + public EncodedStringValue getFrom() { + return mPduHeaders.getEncodedStringValue(PduHeaders.FROM); + } + + /** + * Set From value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setFrom(EncodedStringValue value) { + mPduHeaders.setEncodedStringValue(value, PduHeaders.FROM); + } + + /** + * Get Message-ID value. + * + * @return the value + */ + public byte[] getMessageId() { + return mPduHeaders.getTextString(PduHeaders.MESSAGE_ID); + } + + /** + * Set Message-ID value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setMessageId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.MESSAGE_ID); + } + + /** + * Get X-MMS-Read-status value. + * + * @return the value + */ + public int getReadStatus() { + return mPduHeaders.getOctet(PduHeaders.READ_STATUS); + } + + /** + * Set X-MMS-Read-status value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setReadStatus(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.READ_STATUS); + } + + /** + * Get To value. + * + * @return the value + */ + public EncodedStringValue[] getTo() { + return mPduHeaders.getEncodedStringValues(PduHeaders.TO); + } + + /** + * Set To value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setTo(EncodedStringValue[] value) { + mPduHeaders.setEncodedStringValues(value, PduHeaders.TO); + } + + /* + * Optional, not supported header fields: + * + * public byte[] getApplicId() {return null;} + * public void setApplicId(byte[] value) {} + * + * public byte[] getAuxApplicId() {return null;} + * public void getAuxApplicId(byte[] value) {} + * + * public byte[] getReplyApplicId() {return 0x00;} + * public void setReplyApplicId(byte[] value) {} + */ +} diff --git a/src/android/support/v7/mms/pdu/ReadRecInd.java b/src/android/support/v7/mms/pdu/ReadRecInd.java new file mode 100644 index 0000000..9683d00 --- /dev/null +++ b/src/android/support/v7/mms/pdu/ReadRecInd.java @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +public class ReadRecInd extends GenericPdu { + /** + * Constructor, used when composing a M-ReadRec.ind pdu. + * + * @param from the from value + * @param messageId the message ID value + * @param mmsVersion current viersion of mms + * @param readStatus the read status value + * @param to the to value + * @throws InvalidHeaderValueException if parameters are invalid. + * NullPointerException if messageId or to is null. + */ + public ReadRecInd(EncodedStringValue from, + byte[] messageId, + int mmsVersion, + int readStatus, + EncodedStringValue[] to) throws InvalidHeaderValueException { + super(); + setMessageType(PduHeaders.MESSAGE_TYPE_READ_REC_IND); + setFrom(from); + setMessageId(messageId); + setMmsVersion(mmsVersion); + setTo(to); + setReadStatus(readStatus); + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + ReadRecInd(PduHeaders headers) { + super(headers); + } + + /** + * Get Date value. + * + * @return the value + */ + public long getDate() { + return mPduHeaders.getLongInteger(PduHeaders.DATE); + } + + /** + * Set Date value. + * + * @param value the value + */ + public void setDate(long value) { + mPduHeaders.setLongInteger(value, PduHeaders.DATE); + } + + /** + * Get Message-ID value. + * + * @return the value + */ + public byte[] getMessageId() { + return mPduHeaders.getTextString(PduHeaders.MESSAGE_ID); + } + + /** + * Set Message-ID value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setMessageId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.MESSAGE_ID); + } + + /** + * Get To value. + * + * @return the value + */ + public EncodedStringValue[] getTo() { + return mPduHeaders.getEncodedStringValues(PduHeaders.TO); + } + + /** + * Set To value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setTo(EncodedStringValue[] value) { + mPduHeaders.setEncodedStringValues(value, PduHeaders.TO); + } + + /** + * Get X-MMS-Read-status value. + * + * @return the value + */ + public int getReadStatus() { + return mPduHeaders.getOctet(PduHeaders.READ_STATUS); + } + + /** + * Set X-MMS-Read-status value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setReadStatus(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.READ_STATUS); + } + + /* + * Optional, not supported header fields: + * + * public byte[] getApplicId() {return null;} + * public void setApplicId(byte[] value) {} + * + * public byte[] getAuxApplicId() {return null;} + * public void getAuxApplicId(byte[] value) {} + * + * public byte[] getReplyApplicId() {return 0x00;} + * public void setReplyApplicId(byte[] value) {} + */ +} diff --git a/src/android/support/v7/mms/pdu/RetrieveConf.java b/src/android/support/v7/mms/pdu/RetrieveConf.java new file mode 100644 index 0000000..752ca04 --- /dev/null +++ b/src/android/support/v7/mms/pdu/RetrieveConf.java @@ -0,0 +1,298 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +/** + * M-Retrive.conf Pdu. + */ +public class RetrieveConf extends MultimediaMessagePdu { + /** + * Empty constructor. + * Since the Pdu corresponding to this class is constructed + * by the Proxy-Relay server, this class is only instantiated + * by the Pdu Parser. + * + * @throws InvalidHeaderValueException if error occurs. + */ + public RetrieveConf() throws InvalidHeaderValueException { + super(); + setMessageType(PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF); + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + RetrieveConf(PduHeaders headers) { + super(headers); + } + + /** + * Constructor with given headers and body + * + * @param headers Headers for this PDU. + * @param body Body of this PDu. + */ + RetrieveConf(PduHeaders headers, PduBody body) { + super(headers, body); + } + + /** + * Get CC value. + * + * @return the value + */ + public EncodedStringValue[] getCc() { + return mPduHeaders.getEncodedStringValues(PduHeaders.CC); + } + + /** + * Add a "CC" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void addCc(EncodedStringValue value) { + mPduHeaders.appendEncodedStringValue(value, PduHeaders.CC); + } + + /** + * Get Content-type value. + * + * @return the value + */ + public byte[] getContentType() { + return mPduHeaders.getTextString(PduHeaders.CONTENT_TYPE); + } + + /** + * Set Content-type value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setContentType(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.CONTENT_TYPE); + } + + /** + * Get X-Mms-Delivery-Report value. + * + * @return the value + */ + public int getDeliveryReport() { + return mPduHeaders.getOctet(PduHeaders.DELIVERY_REPORT); + } + + /** + * Set X-Mms-Delivery-Report value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setDeliveryReport(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.DELIVERY_REPORT); + } + + /** + * Get From value. + * From-value = Value-length + * (Address-present-token Encoded-string-value | Insert-address-token) + * + * @return the value + */ + public EncodedStringValue getFrom() { + return mPduHeaders.getEncodedStringValue(PduHeaders.FROM); + } + + /** + * Set From value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setFrom(EncodedStringValue value) { + mPduHeaders.setEncodedStringValue(value, PduHeaders.FROM); + } + + /** + * Get X-Mms-Message-Class value. + * Message-class-value = Class-identifier | Token-text + * Class-identifier = Personal | Advertisement | Informational | Auto + * + * @return the value + */ + public byte[] getMessageClass() { + return mPduHeaders.getTextString(PduHeaders.MESSAGE_CLASS); + } + + /** + * Set X-Mms-Message-Class value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setMessageClass(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.MESSAGE_CLASS); + } + + /** + * Get Message-ID value. + * + * @return the value + */ + public byte[] getMessageId() { + return mPduHeaders.getTextString(PduHeaders.MESSAGE_ID); + } + + /** + * Set Message-ID value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setMessageId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.MESSAGE_ID); + } + + /** + * Get X-Mms-Read-Report value. + * + * @return the value + */ + public int getReadReport() { + return mPduHeaders.getOctet(PduHeaders.READ_REPORT); + } + + /** + * Set X-Mms-Read-Report value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setReadReport(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.READ_REPORT); + } + + /** + * Get X-Mms-Retrieve-Status value. + * + * @return the value + */ + public int getRetrieveStatus() { + return mPduHeaders.getOctet(PduHeaders.RETRIEVE_STATUS); + } + + /** + * Set X-Mms-Retrieve-Status value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setRetrieveStatus(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.RETRIEVE_STATUS); + } + + /** + * Get X-Mms-Retrieve-Text value. + * + * @return the value + */ + public EncodedStringValue getRetrieveText() { + return mPduHeaders.getEncodedStringValue(PduHeaders.RETRIEVE_TEXT); + } + + /** + * Set X-Mms-Retrieve-Text value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setRetrieveText(EncodedStringValue value) { + mPduHeaders.setEncodedStringValue(value, PduHeaders.RETRIEVE_TEXT); + } + + /** + * Get X-Mms-Transaction-Id. + * + * @return the value + */ + public byte[] getTransactionId() { + return mPduHeaders.getTextString(PduHeaders.TRANSACTION_ID); + } + + /** + * Set X-Mms-Transaction-Id. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setTransactionId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.TRANSACTION_ID); + } + + /* + * Optional, not supported header fields: + * + * public byte[] getApplicId() {return null;} + * public void setApplicId(byte[] value) {} + * + * public byte[] getAuxApplicId() {return null;} + * public void getAuxApplicId(byte[] value) {} + * + * public byte getContentClass() {return 0x00;} + * public void setApplicId(byte value) {} + * + * public byte getDrmContent() {return 0x00;} + * public void setDrmContent(byte value) {} + * + * public byte getDistributionIndicator() {return 0x00;} + * public void setDistributionIndicator(byte value) {} + * + * public PreviouslySentByValue getPreviouslySentBy() {return null;} + * public void setPreviouslySentBy(PreviouslySentByValue value) {} + * + * public PreviouslySentDateValue getPreviouslySentDate() {} + * public void setPreviouslySentDate(PreviouslySentDateValue value) {} + * + * public MmFlagsValue getMmFlags() {return null;} + * public void setMmFlags(MmFlagsValue value) {} + * + * public MmStateValue getMmState() {return null;} + * public void getMmState(MmStateValue value) {} + * + * public byte[] getReplaceId() {return 0x00;} + * public void setReplaceId(byte[] value) {} + * + * public byte[] getReplyApplicId() {return 0x00;} + * public void setReplyApplicId(byte[] value) {} + * + * public byte getReplyCharging() {return 0x00;} + * public void setReplyCharging(byte value) {} + * + * public byte getReplyChargingDeadline() {return 0x00;} + * public void setReplyChargingDeadline(byte value) {} + * + * public byte[] getReplyChargingId() {return 0x00;} + * public void setReplyChargingId(byte[] value) {} + * + * public long getReplyChargingSize() {return 0;} + * public void setReplyChargingSize(long value) {} + */ +} diff --git a/src/android/support/v7/mms/pdu/SendConf.java b/src/android/support/v7/mms/pdu/SendConf.java new file mode 100644 index 0000000..5486398 --- /dev/null +++ b/src/android/support/v7/mms/pdu/SendConf.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +public class SendConf extends GenericPdu { + /** + * Empty constructor. + * Since the Pdu corresponding to this class is constructed + * by the Proxy-Relay server, this class is only instantiated + * by the Pdu Parser. + * + * @throws InvalidHeaderValueException if error occurs. + */ + public SendConf() throws InvalidHeaderValueException { + super(); + setMessageType(PduHeaders.MESSAGE_TYPE_SEND_CONF); + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + SendConf(PduHeaders headers) { + super(headers); + } + + /** + * Get Message-ID value. + * + * @return the value + */ + public byte[] getMessageId() { + return mPduHeaders.getTextString(PduHeaders.MESSAGE_ID); + } + + /** + * Set Message-ID value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setMessageId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.MESSAGE_ID); + } + + /** + * Get X-Mms-Response-Status. + * + * @return the value + */ + public int getResponseStatus() { + return mPduHeaders.getOctet(PduHeaders.RESPONSE_STATUS); + } + + /** + * Set X-Mms-Response-Status. + * + * @param value the values + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setResponseStatus(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.RESPONSE_STATUS); + } + + /** + * Get X-Mms-Transaction-Id field value. + * + * @return the X-Mms-Report-Allowed value + */ + public byte[] getTransactionId() { + return mPduHeaders.getTextString(PduHeaders.TRANSACTION_ID); + } + + /** + * Set X-Mms-Transaction-Id field value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setTransactionId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.TRANSACTION_ID); + } + + /* + * Optional, not supported header fields: + * + * public byte[] getContentLocation() {return null;} + * public void setContentLocation(byte[] value) {} + * + * public EncodedStringValue getResponseText() {return null;} + * public void setResponseText(EncodedStringValue value) {} + * + * public byte getStoreStatus() {return 0x00;} + * public void setStoreStatus(byte value) {} + * + * public byte[] getStoreStatusText() {return null;} + * public void setStoreStatusText(byte[] value) {} + */ +} diff --git a/src/android/support/v7/mms/pdu/SendReq.java b/src/android/support/v7/mms/pdu/SendReq.java new file mode 100644 index 0000000..0b78cc3 --- /dev/null +++ b/src/android/support/v7/mms/pdu/SendReq.java @@ -0,0 +1,343 @@ +/* + * Copyright (C) 2007-2008 Esmertec AG. + * Copyright (C) 2007-2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.support.v7.mms.pdu; + +import android.util.Log; + +public class SendReq extends MultimediaMessagePdu { + private static final String TAG = "SendReq"; + + public SendReq() { + super(); + + try { + setMessageType(PduHeaders.MESSAGE_TYPE_SEND_REQ); + setMmsVersion(PduHeaders.CURRENT_MMS_VERSION); + // FIXME: Content-type must be decided according to whether + // SMIL part present. + setContentType("application/vnd.wap.multipart.related".getBytes()); + setFrom(new EncodedStringValue(PduHeaders.FROM_INSERT_ADDRESS_TOKEN_STR.getBytes())); + setTransactionId(generateTransactionId()); + } catch (InvalidHeaderValueException e) { + // Impossible to reach here since all headers we set above are valid. + Log.e(TAG, "Unexpected InvalidHeaderValueException.", e); + throw new RuntimeException(e); + } + } + + private byte[] generateTransactionId() { + String transactionId = "T" + Long.toHexString(System.currentTimeMillis()); + return transactionId.getBytes(); + } + + /** + * Constructor, used when composing a M-Send.req pdu. + * + * @param contentType the content type value + * @param from the from value + * @param mmsVersion current viersion of mms + * @param transactionId the transaction-id value + * @throws InvalidHeaderValueException if parameters are invalid. + * NullPointerException if contentType, form or transactionId is null. + */ + public SendReq(byte[] contentType, + EncodedStringValue from, + int mmsVersion, + byte[] transactionId) throws InvalidHeaderValueException { + super(); + setMessageType(PduHeaders.MESSAGE_TYPE_SEND_REQ); + setContentType(contentType); + setFrom(from); + setMmsVersion(mmsVersion); + setTransactionId(transactionId); + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + SendReq(PduHeaders headers) { + super(headers); + } + + /** + * Constructor with given headers and body + * + * @param headers Headers for this PDU. + * @param body Body of this PDu. + */ + SendReq(PduHeaders headers, PduBody body) { + super(headers, body); + } + + /** + * Get Bcc value. + * + * @return the value + */ + public EncodedStringValue[] getBcc() { + return mPduHeaders.getEncodedStringValues(PduHeaders.BCC); + } + + /** + * Add a "BCC" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void addBcc(EncodedStringValue value) { + mPduHeaders.appendEncodedStringValue(value, PduHeaders.BCC); + } + + /** + * Set "BCC" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setBcc(EncodedStringValue[] value) { + mPduHeaders.setEncodedStringValues(value, PduHeaders.BCC); + } + + /** + * Get CC value. + * + * @return the value + */ + public EncodedStringValue[] getCc() { + return mPduHeaders.getEncodedStringValues(PduHeaders.CC); + } + + /** + * Add a "CC" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void addCc(EncodedStringValue value) { + mPduHeaders.appendEncodedStringValue(value, PduHeaders.CC); + } + + /** + * Set "CC" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setCc(EncodedStringValue[] value) { + mPduHeaders.setEncodedStringValues(value, PduHeaders.CC); + } + + /** + * Get Content-type value. + * + * @return the value + */ + public byte[] getContentType() { + return mPduHeaders.getTextString(PduHeaders.CONTENT_TYPE); + } + + /** + * Set Content-type value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setContentType(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.CONTENT_TYPE); + } + + /** + * Get X-Mms-Delivery-Report value. + * + * @return the value + */ + public int getDeliveryReport() { + return mPduHeaders.getOctet(PduHeaders.DELIVERY_REPORT); + } + + /** + * Set X-Mms-Delivery-Report value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setDeliveryReport(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.DELIVERY_REPORT); + } + + /** + * Get X-Mms-Expiry value. + * + * Expiry-value = Value-length + * (Absolute-token Date-value | Relative-token Delta-seconds-value) + * + * @return the value + */ + public long getExpiry() { + return mPduHeaders.getLongInteger(PduHeaders.EXPIRY); + } + + /** + * Set X-Mms-Expiry value. + * + * @param value the value + */ + public void setExpiry(long value) { + mPduHeaders.setLongInteger(value, PduHeaders.EXPIRY); + } + + /** + * Get X-Mms-MessageSize value. + * + * Expiry-value = size of message + * + * @return the value + */ + public long getMessageSize() { + return mPduHeaders.getLongInteger(PduHeaders.MESSAGE_SIZE); + } + + /** + * Set X-Mms-MessageSize value. + * + * @param value the value + */ + public void setMessageSize(long value) { + mPduHeaders.setLongInteger(value, PduHeaders.MESSAGE_SIZE); + } + + /** + * Get X-Mms-Message-Class value. + * Message-class-value = Class-identifier | Token-text + * Class-identifier = Personal | Advertisement | Informational | Auto + * + * @return the value + */ + public byte[] getMessageClass() { + return mPduHeaders.getTextString(PduHeaders.MESSAGE_CLASS); + } + + /** + * Set X-Mms-Message-Class value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setMessageClass(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.MESSAGE_CLASS); + } + + /** + * Get X-Mms-Read-Report value. + * + * @return the value + */ + public int getReadReport() { + return mPduHeaders.getOctet(PduHeaders.READ_REPORT); + } + + /** + * Set X-Mms-Read-Report value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setReadReport(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.READ_REPORT); + } + + /** + * Set "To" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setTo(EncodedStringValue[] value) { + mPduHeaders.setEncodedStringValues(value, PduHeaders.TO); + } + + /** + * Get X-Mms-Transaction-Id field value. + * + * @return the X-Mms-Report-Allowed value + */ + public byte[] getTransactionId() { + return mPduHeaders.getTextString(PduHeaders.TRANSACTION_ID); + } + + /** + * Set X-Mms-Transaction-Id field value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setTransactionId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.TRANSACTION_ID); + } + + /* + * Optional, not supported header fields: + * + * public byte getAdaptationAllowed() {return 0}; + * public void setAdaptationAllowed(btye value) {}; + * + * public byte[] getApplicId() {return null;} + * public void setApplicId(byte[] value) {} + * + * public byte[] getAuxApplicId() {return null;} + * public void getAuxApplicId(byte[] value) {} + * + * public byte getContentClass() {return 0x00;} + * public void setApplicId(byte value) {} + * + * public long getDeliveryTime() {return 0}; + * public void setDeliveryTime(long value) {}; + * + * public byte getDrmContent() {return 0x00;} + * public void setDrmContent(byte value) {} + * + * public MmFlagsValue getMmFlags() {return null;} + * public void setMmFlags(MmFlagsValue value) {} + * + * public MmStateValue getMmState() {return null;} + * public void getMmState(MmStateValue value) {} + * + * public byte[] getReplyApplicId() {return 0x00;} + * public void setReplyApplicId(byte[] value) {} + * + * public byte getReplyCharging() {return 0x00;} + * public void setReplyCharging(byte value) {} + * + * public byte getReplyChargingDeadline() {return 0x00;} + * public void setReplyChargingDeadline(byte value) {} + * + * public byte[] getReplyChargingId() {return 0x00;} + * public void setReplyChargingId(byte[] value) {} + * + * public long getReplyChargingSize() {return 0;} + * public void setReplyChargingSize(long value) {} + * + * public byte[] getReplyApplicId() {return 0x00;} + * public void setReplyApplicId(byte[] value) {} + * + * public byte getStore() {return 0x00;} + * public void setStore(byte value) {} + */ +} diff --git a/src/com/android/messaging/BugleApplication.java b/src/com/android/messaging/BugleApplication.java new file mode 100644 index 0000000..a5aea9f --- /dev/null +++ b/src/com/android/messaging/BugleApplication.java @@ -0,0 +1,262 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging; + +import android.app.Application; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.res.Configuration; +import android.os.Handler; +import android.os.Looper; +import android.support.v7.mms.CarrierConfigValuesLoader; +import android.support.v7.mms.MmsManager; +import android.telephony.CarrierConfigManager; + +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.receiver.SmsReceiver; +import com.android.messaging.sms.ApnDatabase; +import com.android.messaging.sms.BugleApnSettingsLoader; +import com.android.messaging.sms.BugleUserAgentInfoLoader; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.ui.ConversationDrawables; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.BuglePrefsKeys; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.Trace; +import com.google.common.annotations.VisibleForTesting; + +import java.io.File; +import java.lang.Thread.UncaughtExceptionHandler; + +/** + * The application object + */ +public class BugleApplication extends Application implements UncaughtExceptionHandler { + private static final String TAG = LogUtil.BUGLE_TAG; + + private UncaughtExceptionHandler sSystemUncaughtExceptionHandler; + private static boolean sRunningTests = false; + + @VisibleForTesting + protected static void setTestsRunning() { + sRunningTests = true; + } + + /** + * @return true if we're running unit tests. + */ + public static boolean isRunningTests() { + return sRunningTests; + } + + @Override + public void onCreate() { + Trace.beginSection("app.onCreate"); + super.onCreate(); + + // Note onCreate is called in both test and real application environments + if (!sRunningTests) { + // Only create the factory if not running tests + FactoryImpl.register(getApplicationContext(), this); + } else { + LogUtil.e(TAG, "BugleApplication.onCreate: FactoryImpl.register skipped for test run"); + } + + sSystemUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler(this); + Trace.endSection(); + } + + @Override + public void onConfigurationChanged(final Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + // Update conversation drawables when changing writing systems + // (Right-To-Left / Left-To-Right) + ConversationDrawables.get().updateDrawables(); + } + + // Called by the "real" factory from FactoryImpl.register() (i.e. not run in tests) + public void initializeSync(final Factory factory) { + Trace.beginSection("app.initializeSync"); + final Context context = factory.getApplicationContext(); + final BugleGservices bugleGservices = factory.getBugleGservices(); + final BuglePrefs buglePrefs = factory.getApplicationPrefs(); + final DataModel dataModel = factory.getDataModel(); + final CarrierConfigValuesLoader carrierConfigValuesLoader = + factory.getCarrierConfigValuesLoader(); + + maybeStartProfiling(); + + BugleApplication.updateAppConfig(context); + + // Initialize MMS lib + initMmsLib(context, bugleGservices, carrierConfigValuesLoader); + // Initialize APN database + ApnDatabase.initializeAppContext(context); + // Fixup messages in flight if we crashed and send any pending + dataModel.onApplicationCreated(); + // Register carrier config change receiver + if (OsUtil.isAtLeastM()) { + registerCarrierConfigChangeReceiver(context); + } + + Trace.endSection(); + } + + private static void registerCarrierConfigChangeReceiver(final Context context) { + context.registerReceiver(new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + LogUtil.i(TAG, "Carrier config changed. Reloading MMS config."); + MmsConfig.loadAsync(); + } + }, new IntentFilter(CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED)); + } + + private static void initMmsLib(final Context context, final BugleGservices bugleGservices, + final CarrierConfigValuesLoader carrierConfigValuesLoader) { + MmsManager.setApnSettingsLoader(new BugleApnSettingsLoader(context)); + MmsManager.setCarrierConfigValuesLoader(carrierConfigValuesLoader); + MmsManager.setUserAgentInfoLoader(new BugleUserAgentInfoLoader(context)); + MmsManager.setUseWakeLock(true); + // If Gservices is configured not to use mms api, force MmsManager to always use + // legacy mms sending logic + MmsManager.setForceLegacyMms(!bugleGservices.getBoolean( + BugleGservicesKeys.USE_MMS_API_IF_PRESENT, + BugleGservicesKeys.USE_MMS_API_IF_PRESENT_DEFAULT)); + bugleGservices.registerForChanges(new Runnable() { + @Override + public void run() { + MmsManager.setForceLegacyMms(!bugleGservices.getBoolean( + BugleGservicesKeys.USE_MMS_API_IF_PRESENT, + BugleGservicesKeys.USE_MMS_API_IF_PRESENT_DEFAULT)); + } + }); + } + + public static void updateAppConfig(final Context context) { + // Make sure we set the correct state for the SMS/MMS receivers + SmsReceiver.updateSmsReceiveHandler(context); + } + + // Called from thread started in FactoryImpl.register() (i.e. not run in tests) + public void initializeAsync(final Factory factory) { + // Handle shared prefs upgrade & Load MMS Configuration + Trace.beginSection("app.initializeAsync"); + maybeHandleSharedPrefsUpgrade(factory); + MmsConfig.load(); + Trace.endSection(); + } + + @Override + public void onLowMemory() { + super.onLowMemory(); + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "BugleApplication.onLowMemory"); + } + Factory.get().reclaimMemory(); + } + + @Override + public void uncaughtException(final Thread thread, final Throwable ex) { + final boolean background = getMainLooper().getThread() != thread; + if (background) { + LogUtil.e(TAG, "Uncaught exception in background thread " + thread, ex); + + final Handler handler = new Handler(getMainLooper()); + handler.post(new Runnable() { + + @Override + public void run() { + sSystemUncaughtExceptionHandler.uncaughtException(thread, ex); + } + }); + } else { + sSystemUncaughtExceptionHandler.uncaughtException(thread, ex); + } + } + + private void maybeStartProfiling() { + // App startup profiling support. To use it: + // adb shell setprop log.tag.BugleProfile DEBUG + // # Start the app, wait for a 30s, download trace file: + // adb pull /data/data/com.android.messaging/cache/startup.trace /tmp + // # Open trace file (using adt/tools/traceview) + if (android.util.Log.isLoggable(LogUtil.PROFILE_TAG, android.util.Log.DEBUG)) { + // Start method tracing with a big enough buffer and let it run for 30s. + // Note we use a logging tag as we don't want to wait for gservices to start up. + final File file = DebugUtils.getDebugFile("startup.trace", true); + if (file != null) { + android.os.Debug.startMethodTracing(file.getAbsolutePath(), 160 * 1024 * 1024); + new Handler(Looper.getMainLooper()).postDelayed( + new Runnable() { + @Override + public void run() { + android.os.Debug.stopMethodTracing(); + // Allow world to see trace file + DebugUtils.ensureReadable(file); + LogUtil.d(LogUtil.PROFILE_TAG, "Tracing complete - " + + file.getAbsolutePath()); + } + }, 30000); + } + } + } + + private void maybeHandleSharedPrefsUpgrade(final Factory factory) { + final int existingVersion = factory.getApplicationPrefs().getInt( + BuglePrefsKeys.SHARED_PREFERENCES_VERSION, + BuglePrefsKeys.SHARED_PREFERENCES_VERSION_DEFAULT); + final int targetVersion = Integer.parseInt(getString(R.string.pref_version)); + if (targetVersion > existingVersion) { + LogUtil.i(LogUtil.BUGLE_TAG, "Upgrading shared prefs from " + existingVersion + + " to " + targetVersion); + try { + // Perform upgrade on application-wide prefs. + factory.getApplicationPrefs().onUpgrade(existingVersion, targetVersion); + // Perform upgrade on each subscription's prefs. + PhoneUtils.forEachActiveSubscription(new PhoneUtils.SubscriptionRunnable() { + @Override + public void runForSubscription(final int subId) { + factory.getSubscriptionPrefs(subId) + .onUpgrade(existingVersion, targetVersion); + } + }); + factory.getApplicationPrefs().putInt(BuglePrefsKeys.SHARED_PREFERENCES_VERSION, + targetVersion); + } catch (final Exception ex) { + // Upgrade failed. Don't crash the app because we can always fall back to the + // default settings. + LogUtil.e(LogUtil.BUGLE_TAG, "Failed to upgrade shared prefs", ex); + } + } else if (targetVersion < existingVersion) { + // We don't care about downgrade since real user shouldn't encounter this, so log it + // and ignore any prefs migration. + LogUtil.e(LogUtil.BUGLE_TAG, "Shared prefs downgrade requested and ignored. " + + "oldVersion = " + existingVersion + ", newVersion = " + targetVersion); + } + } +} diff --git a/src/com/android/messaging/Factory.java b/src/com/android/messaging/Factory.java new file mode 100644 index 0000000..c542a54 --- /dev/null +++ b/src/com/android/messaging/Factory.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging; + +import android.content.Context; + +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.MemoryCacheManager; +import com.android.messaging.datamodel.ParticipantRefresh.ContactContentObserver; +import com.android.messaging.datamodel.media.MediaCacheManager; +import com.android.messaging.datamodel.media.MediaResourceManager; +import com.android.messaging.sms.BugleCarrierConfigValuesLoader; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.MediaUtil; +import com.android.messaging.util.PhoneUtils; +import com.google.common.annotations.VisibleForTesting; + +public abstract class Factory { + + // Making this volatile because on the unit tests, setInstance is called from a unit test + // thread, and then it's read on the UI thread. + private static volatile Factory sInstance; + @VisibleForTesting + protected static boolean sRegistered; + @VisibleForTesting + protected static boolean sInitialized; + + public static Factory get() { + return sInstance; + } + + protected static void setInstance(final Factory factory) { + // Not allowed to call this after real application initialization is complete + Assert.isTrue(!sRegistered); + Assert.isTrue(!sInitialized); + sInstance = factory; + } + public abstract void onRequiredPermissionsAcquired(); + + public abstract Context getApplicationContext(); + public abstract DataModel getDataModel(); + public abstract BugleGservices getBugleGservices(); + public abstract BuglePrefs getApplicationPrefs(); + public abstract BuglePrefs getSubscriptionPrefs(int subId); + public abstract BuglePrefs getWidgetPrefs(); + public abstract UIIntents getUIIntents(); + public abstract MemoryCacheManager getMemoryCacheManager(); + public abstract MediaResourceManager getMediaResourceManager(); + public abstract MediaCacheManager getMediaCacheManager(); + public abstract ContactContentObserver getContactContentObserver(); + public abstract PhoneUtils getPhoneUtils(int subId); + public abstract MediaUtil getMediaUtil(); + public abstract BugleCarrierConfigValuesLoader getCarrierConfigValuesLoader(); + // Note this needs to run from any thread + public abstract void reclaimMemory(); + + public abstract void onActivityResume(); +} diff --git a/src/com/android/messaging/FactoryImpl.java b/src/com/android/messaging/FactoryImpl.java new file mode 100644 index 0000000..a862308 --- /dev/null +++ b/src/com/android/messaging/FactoryImpl.java @@ -0,0 +1,245 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging; + +import android.content.Context; +import android.os.Process; +import android.telephony.SmsManager; +import android.util.SparseArray; + +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DataModelImpl; +import com.android.messaging.datamodel.MemoryCacheManager; +import com.android.messaging.datamodel.ParticipantRefresh.ContactContentObserver; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.media.BugleMediaCacheManager; +import com.android.messaging.datamodel.media.MediaCacheManager; +import com.android.messaging.datamodel.media.MediaResourceManager; +import com.android.messaging.sms.BugleCarrierConfigValuesLoader; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.UIIntentsImpl; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BugleApplicationPrefs; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesImpl; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.BugleSubscriptionPrefs; +import com.android.messaging.util.BugleWidgetPrefs; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.MediaUtil; +import com.android.messaging.util.MediaUtilImpl; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +import java.util.concurrent.ConcurrentHashMap; + +class FactoryImpl extends Factory { + private BugleApplication mApplication; + private DataModel mDataModel; + private BugleGservices mBugleGservices; + private BugleApplicationPrefs mBugleApplicationPrefs; + private BugleWidgetPrefs mBugleWidgetPrefs; + private Context mApplicationContext; + private UIIntents mUIIntents; + private MemoryCacheManager mMemoryCacheManager; + private MediaResourceManager mMediaResourceManager; + private MediaCacheManager mMediaCacheManager; + private ContactContentObserver mContactContentObserver; + private PhoneUtils mPhoneUtils; + private MediaUtil mMediaUtil; + private SparseArray mSubscriptionPrefs; + private BugleCarrierConfigValuesLoader mCarrierConfigValuesLoader; + + // Cached instance for Pre-L_MR1 + private static final Object PHONEUTILS_INSTANCE_LOCK = new Object(); + private static PhoneUtils sPhoneUtilsInstancePreLMR1 = null; + // Cached subId->instance for L_MR1 and beyond + private static final ConcurrentHashMap sPhoneUtilsInstanceCacheLMR1 = + new ConcurrentHashMap<>(); + + private FactoryImpl() { + } + + public static Factory register(final Context applicationContext, + final BugleApplication application) { + // This only gets called once (from BugleApplication.onCreate), but its not called in tests. + Assert.isTrue(!sRegistered); + Assert.isNull(Factory.get()); + + final FactoryImpl factory = new FactoryImpl(); + Factory.setInstance(factory); + sRegistered = true; + + // At this point Factory is published. Services can now get initialized and depend on + // Factory.get(). + factory.mApplication = application; + factory.mApplicationContext = applicationContext; + factory.mMemoryCacheManager = new MemoryCacheManager(); + factory.mMediaCacheManager = new BugleMediaCacheManager(); + factory.mMediaResourceManager = new MediaResourceManager(); + factory.mBugleGservices = new BugleGservicesImpl(applicationContext); + factory.mBugleApplicationPrefs = new BugleApplicationPrefs(applicationContext); + factory.mDataModel = new DataModelImpl(applicationContext); + factory.mBugleWidgetPrefs = new BugleWidgetPrefs(applicationContext); + factory.mUIIntents = new UIIntentsImpl(); + factory.mContactContentObserver = new ContactContentObserver(); + factory.mMediaUtil = new MediaUtilImpl(); + factory.mSubscriptionPrefs = new SparseArray(); + factory.mCarrierConfigValuesLoader = new BugleCarrierConfigValuesLoader(applicationContext); + + Assert.initializeGservices(factory.mBugleGservices); + LogUtil.initializeGservices(factory.mBugleGservices); + + if (OsUtil.hasRequiredPermissions()) { + factory.onRequiredPermissionsAcquired(); + } + + return factory; + } + + @Override + public void onRequiredPermissionsAcquired() { + if (sInitialized) { + return; + } + sInitialized = true; + + mApplication.initializeSync(this); + + final Thread asyncInitialization = new Thread() { + @Override + public void run() { + Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); + mApplication.initializeAsync(FactoryImpl.this); + } + }; + asyncInitialization.start(); + } + + @Override + public Context getApplicationContext() { + return mApplicationContext; + } + + @Override + public DataModel getDataModel() { + return mDataModel; + } + + @Override + public BugleGservices getBugleGservices() { + return mBugleGservices; + } + + @Override + public BuglePrefs getApplicationPrefs() { + return mBugleApplicationPrefs; + } + + @Override + public BuglePrefs getWidgetPrefs() { + return mBugleWidgetPrefs; + } + + @Override + public BuglePrefs getSubscriptionPrefs(int subId) { + subId = PhoneUtils.getDefault().getEffectiveSubId(subId); + BugleSubscriptionPrefs pref = mSubscriptionPrefs.get(subId); + if (pref == null) { + synchronized (this) { + if ((pref = mSubscriptionPrefs.get(subId)) == null) { + pref = new BugleSubscriptionPrefs(getApplicationContext(), subId); + mSubscriptionPrefs.put(subId, pref); + } + } + } + return pref; + } + + @Override + public UIIntents getUIIntents() { + return mUIIntents; + } + + @Override + public MemoryCacheManager getMemoryCacheManager() { + return mMemoryCacheManager; + } + + @Override + public MediaResourceManager getMediaResourceManager() { + return mMediaResourceManager; + } + + @Override + public MediaCacheManager getMediaCacheManager() { + return mMediaCacheManager; + } + + @Override + public ContactContentObserver getContactContentObserver() { + return mContactContentObserver; + } + + @Override + public PhoneUtils getPhoneUtils(int subId) { + if (OsUtil.isAtLeastL_MR1()) { + if (subId == ParticipantData.DEFAULT_SELF_SUB_ID) { + subId = SmsManager.getDefaultSmsSubscriptionId(); + } + if (subId < 0) { + LogUtil.w(LogUtil.BUGLE_TAG, "PhoneUtils.getForLMR1(): invalid subId = " + subId); + subId = ParticipantData.DEFAULT_SELF_SUB_ID; + } + PhoneUtils instance = sPhoneUtilsInstanceCacheLMR1.get(subId); + if (instance == null) { + instance = new PhoneUtils.PhoneUtilsLMR1(subId); + sPhoneUtilsInstanceCacheLMR1.putIfAbsent(subId, instance); + } + return instance; + } else { + Assert.isTrue(subId == ParticipantData.DEFAULT_SELF_SUB_ID); + if (sPhoneUtilsInstancePreLMR1 == null) { + synchronized (PHONEUTILS_INSTANCE_LOCK) { + if (sPhoneUtilsInstancePreLMR1 == null) { + sPhoneUtilsInstancePreLMR1 = new PhoneUtils.PhoneUtilsPreLMR1(); + } + } + } + return sPhoneUtilsInstancePreLMR1; + } + } + + @Override + public void reclaimMemory() { + mMemoryCacheManager.reclaimMemory(); + } + + @Override + public void onActivityResume() { + } + + @Override + public MediaUtil getMediaUtil() { + return mMediaUtil; + } + + @Override + public BugleCarrierConfigValuesLoader getCarrierConfigValuesLoader() { + return mCarrierConfigValuesLoader; + } +} diff --git a/src/com/android/messaging/annotation/VisibleForAnimation.java b/src/com/android/messaging/annotation/VisibleForAnimation.java new file mode 100644 index 0000000..f97d827 --- /dev/null +++ b/src/com/android/messaging/annotation/VisibleForAnimation.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.annotation; + +/** + * An annotation for class members that are made visible for Android's ObjectAnimator to work + * properly through reflection. + */ +public @interface VisibleForAnimation { +} diff --git a/src/com/android/messaging/datamodel/BitmapPool.java b/src/com/android/messaging/datamodel/BitmapPool.java new file mode 100644 index 0000000..1ec4f76 --- /dev/null +++ b/src/com/android/messaging/datamodel/BitmapPool.java @@ -0,0 +1,364 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.support.annotation.NonNull; +import android.text.TextUtils; +import android.util.SparseArray; + +import com.android.messaging.datamodel.MemoryCacheManager.MemoryCache; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +import java.io.InputStream; + +/** + * Class for creating / loading / reusing bitmaps. This class allow the user to create a new bitmap, + * reuse an bitmap from the pool and to return a bitmap for future reuse. The pool of bitmaps + * allows for faster decode and more efficient memory usage. + * Note: consumers should not create BitmapPool directly, but instead get the pool they want from + * the BitmapPoolManager. + */ +public class BitmapPool implements MemoryCache { + public static final int MAX_SUPPORTED_IMAGE_DIMENSION = 0xFFFF; + + protected static final boolean VERBOSE = false; + + /** + * Number of reuse failures to skip before reporting. + */ + private static final int FAILED_REPORTING_FREQUENCY = 100; + + /** + * Count of reuse failures which have occurred. + */ + private static volatile int sFailedBitmapReuseCount = 0; + + /** + * Overall pool data structure which currently only supports rectangular bitmaps. The size of + * one of the sides is used to index into the SparseArray. + */ + private final SparseArray mPool; + private final Object mPoolLock = new Object(); + private final String mPoolName; + private final int mMaxSize; + + /** + * Inner structure which holds a pool of bitmaps all the same size (i.e. all have the same + * width as each other and height as each other, but not necessarily the same). + */ + private class SingleSizePool { + int mNumItems; + final Bitmap[] mBitmaps; + + SingleSizePool(final int maxPoolSize) { + mNumItems = 0; + mBitmaps = new Bitmap[maxPoolSize]; + } + } + + /** + * Creates a pool of reused bitmaps with helper decode methods which will attempt to use the + * reclaimed bitmaps. This will help speed up the creation of bitmaps by using already allocated + * bitmaps. + * @param maxSize The overall max size of the pool. When the pool exceeds this size, all calls + * to reclaimBitmap(Bitmap) will result in recycling the bitmap. + * @param name Name of the bitmap pool and only used for logging. Can not be null. + */ + BitmapPool(final int maxSize, @NonNull final String name) { + Assert.isTrue(maxSize > 0); + Assert.isTrue(!TextUtils.isEmpty(name)); + mPoolName = name; + mMaxSize = maxSize; + mPool = new SparseArray(); + } + + @Override + public void reclaim() { + synchronized (mPoolLock) { + for (int p = 0; p < mPool.size(); p++) { + final SingleSizePool singleSizePool = mPool.valueAt(p); + for (int i = 0; i < singleSizePool.mNumItems; i++) { + singleSizePool.mBitmaps[i].recycle(); + singleSizePool.mBitmaps[i] = null; + } + singleSizePool.mNumItems = 0; + } + mPool.clear(); + } + } + + /** + * Creates a new BitmapFactory.Options. + */ + public static BitmapFactory.Options getBitmapOptionsForPool(final boolean scaled, + final int inputDensity, final int targetDensity) { + final BitmapFactory.Options options = new BitmapFactory.Options(); + options.inScaled = scaled; + options.inDensity = inputDensity; + options.inTargetDensity = targetDensity; + options.inSampleSize = 1; + options.inJustDecodeBounds = false; + options.inMutable = true; + return options; + } + + /** + * @return The pool key for the provided image dimensions or 0 if either width or height is + * greater than the max supported image dimension. + */ + private int getPoolKey(final int width, final int height) { + if (width > MAX_SUPPORTED_IMAGE_DIMENSION || height > MAX_SUPPORTED_IMAGE_DIMENSION) { + return 0; + } + return (width << 16) | height; + } + + /** + * + * @return A bitmap in the pool with the specified dimensions or null if no bitmap with the + * specified dimension is available. + */ + private Bitmap findPoolBitmap(final int width, final int height) { + final int poolKey = getPoolKey(width, height); + if (poolKey != 0) { + synchronized (mPoolLock) { + // Take a bitmap from the pool if one is available + final SingleSizePool singlePool = mPool.get(poolKey); + if (singlePool != null && singlePool.mNumItems > 0) { + singlePool.mNumItems--; + final Bitmap foundBitmap = singlePool.mBitmaps[singlePool.mNumItems]; + singlePool.mBitmaps[singlePool.mNumItems] = null; + return foundBitmap; + } + } + } + return null; + } + + /** + * Internal function to try and find a bitmap in the pool which matches the desired width and + * height and then set that in the bitmap options properly. + * + * TODO: Why do we take a width/height? Shouldn't this already be in the + * BitmapFactory.Options instance? Can we assert that they match? + * @param optionsTmp The BitmapFactory.Options to update with the bitmap for the system to try + * to reuse. + * @param width The width of the reusable bitmap. + * @param height The height of the reusable bitmap. + */ + private void assignPoolBitmap(final BitmapFactory.Options optionsTmp, final int width, + final int height) { + if (optionsTmp.inJustDecodeBounds) { + return; + } + optionsTmp.inBitmap = findPoolBitmap(width, height); + } + + /** + * Load a resource into a bitmap. Uses a bitmap from the pool if possible to reduce memory + * turnover. + * @param resourceId Resource id to load. + * @param resources Application resources. Cannot be null. + * @param optionsTmp Should be the same options returned from getBitmapOptionsForPool(). Cannot + * be null. + * @param width The width of the bitmap. + * @param height The height of the bitmap. + * @return The decoded Bitmap with the resource drawn in it. + */ + public Bitmap decodeSampledBitmapFromResource(final int resourceId, + @NonNull final Resources resources, @NonNull final BitmapFactory.Options optionsTmp, + final int width, final int height) { + Assert.notNull(resources); + Assert.notNull(optionsTmp); + Assert.isTrue(width > 0); + Assert.isTrue(height > 0); + assignPoolBitmap(optionsTmp, width, height); + Bitmap b = null; + try { + b = BitmapFactory.decodeResource(resources, resourceId, optionsTmp); + } catch (final IllegalArgumentException e) { + // BitmapFactory couldn't decode the file, try again without an inputBufferBitmap. + if (optionsTmp.inBitmap != null) { + optionsTmp.inBitmap = null; + b = BitmapFactory.decodeResource(resources, resourceId, optionsTmp); + sFailedBitmapReuseCount++; + if (sFailedBitmapReuseCount % FAILED_REPORTING_FREQUENCY == 0) { + LogUtil.w(LogUtil.BUGLE_TAG, + "Pooled bitmap consistently not being reused count = " + + sFailedBitmapReuseCount); + } + } + } catch (final OutOfMemoryError e) { + LogUtil.w(LogUtil.BUGLE_TAG, "Oom decoding resource " + resourceId); + reclaim(); + } + return b; + } + + /** + * Load an input stream into a bitmap. Uses a bitmap from the pool if possible to reduce memory + * turnover. + * @param inputStream InputStream load. Cannot be null. + * @param optionsTmp Should be the same options returned from getBitmapOptionsForPool(). Cannot + * be null. + * @param width The width of the bitmap. + * @param height The height of the bitmap. + * @return The decoded Bitmap with the resource drawn in it. + */ + public Bitmap decodeSampledBitmapFromInputStream(@NonNull final InputStream inputStream, + @NonNull final BitmapFactory.Options optionsTmp, + final int width, final int height) { + Assert.notNull(inputStream); + Assert.isTrue(width > 0); + Assert.isTrue(height > 0); + assignPoolBitmap(optionsTmp, width, height); + Bitmap b = null; + try { + b = BitmapFactory.decodeStream(inputStream, null, optionsTmp); + } catch (final IllegalArgumentException e) { + // BitmapFactory couldn't decode the file, try again without an inputBufferBitmap. + if (optionsTmp.inBitmap != null) { + optionsTmp.inBitmap = null; + b = BitmapFactory.decodeStream(inputStream, null, optionsTmp); + sFailedBitmapReuseCount++; + if (sFailedBitmapReuseCount % FAILED_REPORTING_FREQUENCY == 0) { + LogUtil.w(LogUtil.BUGLE_TAG, + "Pooled bitmap consistently not being reused count = " + + sFailedBitmapReuseCount); + } + } + } catch (final OutOfMemoryError e) { + LogUtil.w(LogUtil.BUGLE_TAG, "Oom decoding inputStream"); + reclaim(); + } + return b; + } + + /** + * Turn encoded bytes into a bitmap. Uses a bitmap from the pool if possible to reduce memory + * turnover. + * @param bytes Encoded bytes to draw on the bitmap. Cannot be null. + * @param optionsTmp The bitmap will set here and the input should be generated from + * getBitmapOptionsForPool(). Cannot be null. + * @param width The width of the bitmap. + * @param height The height of the bitmap. + * @return A Bitmap with the encoded bytes drawn in it. + */ + public Bitmap decodeByteArray(@NonNull final byte[] bytes, + @NonNull final BitmapFactory.Options optionsTmp, final int width, + final int height) throws OutOfMemoryError { + Assert.notNull(bytes); + Assert.notNull(optionsTmp); + Assert.isTrue(width > 0); + Assert.isTrue(height > 0); + assignPoolBitmap(optionsTmp, width, height); + Bitmap b = null; + try { + b = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, optionsTmp); + } catch (final IllegalArgumentException e) { + if (VERBOSE) { + LogUtil.v(LogUtil.BUGLE_TAG, "BitmapPool(" + mPoolName + + ") Unable to use pool bitmap"); + } + // BitmapFactory couldn't decode the file, try again without an inputBufferBitmap. + // (i.e. without the bitmap from the pool) + if (optionsTmp.inBitmap != null) { + optionsTmp.inBitmap = null; + b = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, optionsTmp); + sFailedBitmapReuseCount++; + if (sFailedBitmapReuseCount % FAILED_REPORTING_FREQUENCY == 0) { + LogUtil.w(LogUtil.BUGLE_TAG, + "Pooled bitmap consistently not being reused count = " + + sFailedBitmapReuseCount); + } + } + } + return b; + } + + /** + * Creates a bitmap with the given size, this will reuse a bitmap in the pool, if one is + * available, otherwise this will create a new one. + * @param width The desired width of the bitmap. + * @param height The desired height of the bitmap. + * @return A bitmap with the desired width and height, this maybe a reused bitmap from the pool. + */ + public Bitmap createOrReuseBitmap(final int width, final int height) { + Bitmap b = findPoolBitmap(width, height); + if (b == null) { + b = createBitmap(width, height); + } + return b; + } + + /** + * This will create a new bitmap regardless of pool state. + * @param width The desired width of the bitmap. + * @param height The desired height of the bitmap. + * @return A bitmap with the desired width and height. + */ + private Bitmap createBitmap(final int width, final int height) { + return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + } + + /** + * Called when a bitmap is finished being used so that it can be used for another bitmap in the + * future or recycled. Any bitmaps returned should not be used by the caller again. + * @param b The bitmap to return to the pool for future usage or recycled. This cannot be null. + */ + public void reclaimBitmap(@NonNull final Bitmap b) { + Assert.notNull(b); + final int poolKey = getPoolKey(b.getWidth(), b.getHeight()); + if (poolKey == 0 || !b.isMutable()) { + // Unsupported image dimensions or a immutable bitmap. + b.recycle(); + return; + } + synchronized (mPoolLock) { + SingleSizePool singleSizePool = mPool.get(poolKey); + if (singleSizePool == null) { + singleSizePool = new SingleSizePool(mMaxSize); + mPool.append(poolKey, singleSizePool); + } + if (singleSizePool.mNumItems < singleSizePool.mBitmaps.length) { + singleSizePool.mBitmaps[singleSizePool.mNumItems] = b; + singleSizePool.mNumItems++; + } else { + b.recycle(); + } + } + } + + /** + * @return whether the pool is full for a given width and height. + */ + public boolean isFull(final int width, final int height) { + final int poolKey = getPoolKey(width, height); + synchronized (mPoolLock) { + final SingleSizePool singleSizePool = mPool.get(poolKey); + if (singleSizePool != null && + singleSizePool.mNumItems >= singleSizePool.mBitmaps.length) { + return true; + } + return false; + } + } +} diff --git a/src/com/android/messaging/datamodel/BoundCursorLoader.java b/src/com/android/messaging/datamodel/BoundCursorLoader.java new file mode 100644 index 0000000..84d38e6 --- /dev/null +++ b/src/com/android/messaging/datamodel/BoundCursorLoader.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.content.Context; +import android.content.CursorLoader; +import android.net.Uri; + +/** + * Extension to basic cursor loader that has an attached binding id + */ +public class BoundCursorLoader extends CursorLoader { + private final String mBindingId; + + /** + * Create cursor loader for associated binding id + */ + public BoundCursorLoader(final String bindingId, final Context context, final Uri uri, + final String[] projection, final String selection, final String[] selectionArgs, + final String sortOrder) { + super(context, uri, projection, selection, selectionArgs, sortOrder); + mBindingId = bindingId; + } + + /** + * Binding id associated with this loader - consume can check to verify data still valid + * @return + */ + public String getBindingId() { + return mBindingId; + } +} diff --git a/src/com/android/messaging/datamodel/BugleDatabaseOperations.java b/src/com/android/messaging/datamodel/BugleDatabaseOperations.java new file mode 100644 index 0000000..8c40177 --- /dev/null +++ b/src/com/android/messaging/datamodel/BugleDatabaseOperations.java @@ -0,0 +1,1919 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDoneException; +import android.database.sqlite.SQLiteStatement; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.support.v4.util.ArrayMap; +import android.support.v4.util.SimpleArrayMap; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns; +import com.android.messaging.datamodel.DatabaseHelper.ConversationParticipantsColumns; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseHelper.PartColumns; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.ParticipantRefresh.ConversationParticipantsQuery; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.UriUtil; +import com.android.messaging.widget.WidgetConversationProvider; +import com.google.common.annotations.VisibleForTesting; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import javax.annotation.Nullable; + + +/** + * This class manages updating our local database + */ +public class BugleDatabaseOperations { + + private static final String TAG = LogUtil.BUGLE_DATABASE_TAG; + + // Global cache of phone numbers -> participant id mapping since this call is expensive. + private static final ArrayMap sNormalizedPhoneNumberToParticipantIdCache = + new ArrayMap(); + + /** + * Convert list of recipient strings (email/phone number) into list of ConversationParticipants + * + * @param recipients The recipient list + * @param refSubId The subId used to normalize phone numbers in the recipients + */ + static ArrayList getConversationParticipantsFromRecipients( + final List recipients, final int refSubId) { + // Generate a list of partially formed participants + final ArrayList participants = new + ArrayList(); + + if (recipients != null) { + for (final String recipient : recipients) { + participants.add(ParticipantData.getFromRawPhoneBySimLocale(recipient, refSubId)); + } + } + return participants; + } + + /** + * Sanitize a given list of conversation participants by de-duping and stripping out self + * phone number in group conversation. + */ + @DoesNotRunOnMainThread + public static void sanitizeConversationParticipants(final List participants) { + Assert.isNotMainThread(); + if (participants.size() > 0) { + // First remove redundant phone numbers + final HashSet recipients = new HashSet(); + for (int i = participants.size() - 1; i >= 0; i--) { + final String recipient = participants.get(i).getNormalizedDestination(); + if (!recipients.contains(recipient)) { + recipients.add(recipient); + } else { + participants.remove(i); + } + } + if (participants.size() > 1) { + // Remove self phone number from group conversation. + final HashSet selfNumbers = + PhoneUtils.getDefault().getNormalizedSelfNumbers(); + int removed = 0; + // Do this two-pass scan to avoid unnecessary memory allocation. + // Prescan to count the self numbers in the list + for (final ParticipantData p : participants) { + if (selfNumbers.contains(p.getNormalizedDestination())) { + removed++; + } + } + // If all are self numbers, maybe that's what the user wants, just leave + // the participants as is. Otherwise, do another scan to remove self numbers. + if (removed < participants.size()) { + for (int i = participants.size() - 1; i >= 0; i--) { + final String recipient = participants.get(i).getNormalizedDestination(); + if (selfNumbers.contains(recipient)) { + participants.remove(i); + } + } + } + } + } + } + + /** + * Convert list of ConversationParticipants into recipient strings (email/phone number) + */ + @DoesNotRunOnMainThread + public static ArrayList getRecipientsFromConversationParticipants( + final List participants) { + Assert.isNotMainThread(); + // First find the thread id for this list of participants. + final ArrayList recipients = new ArrayList(); + + for (final ParticipantData participant : participants) { + recipients.add(participant.getSendDestination()); + } + return recipients; + } + + /** + * Get or create a conversation based on the message's thread id + * + * NOTE: There are phones on which you can't get the recipients from the thread id for SMS + * until you have a message, so use getOrCreateConversationFromRecipient instead. + * + * TODO: Should this be in MMS/SMS code? + * + * @param db the database + * @param threadId The message's thread + * @param senderBlocked Flag whether sender of message is in blocked people list + * @param refSubId The reference subId for canonicalize phone numbers + * @return conversationId + */ + @DoesNotRunOnMainThread + public static String getOrCreateConversationFromThreadId(final DatabaseWrapper db, + final long threadId, final boolean senderBlocked, final int refSubId) { + Assert.isNotMainThread(); + final List recipients = MmsUtils.getRecipientsByThread(threadId); + final ArrayList participants = + getConversationParticipantsFromRecipients(recipients, refSubId); + + return getOrCreateConversation(db, threadId, senderBlocked, participants, false, false, + null); + } + + /** + * Get or create a conversation based on provided recipient + * + * @param db the database + * @param threadId The message's thread + * @param senderBlocked Flag whether sender of message is in blocked people list + * @param recipient recipient for thread + * @return conversationId + */ + @DoesNotRunOnMainThread + public static String getOrCreateConversationFromRecipient(final DatabaseWrapper db, + final long threadId, final boolean senderBlocked, final ParticipantData recipient) { + Assert.isNotMainThread(); + final ArrayList recipients = new ArrayList<>(1); + recipients.add(recipient); + return getOrCreateConversation(db, threadId, senderBlocked, recipients, false, false, null); + } + + /** + * Get or create a conversation based on provided participants + * + * @param db the database + * @param threadId The message's thread + * @param archived Flag whether the conversation should be created archived + * @param participants list of conversation participants + * @param noNotification If notification should be disabled + * @param noVibrate If vibrate on notification should be disabled + * @param soundUri If there is custom sound URI + * @return a conversation id + */ + @DoesNotRunOnMainThread + public static String getOrCreateConversation(final DatabaseWrapper db, final long threadId, + final boolean archived, final ArrayList participants, + boolean noNotification, boolean noVibrate, String soundUri) { + Assert.isNotMainThread(); + + // Check to see if this conversation is already in out local db cache + String conversationId = BugleDatabaseOperations.getExistingConversation(db, threadId, + false); + + if (conversationId == null) { + final String conversationName = ConversationListItemData.generateConversationName( + participants); + + // Create the conversation with the default self participant which always maps to + // the system default subscription. + final ParticipantData self = ParticipantData.getSelfParticipant( + ParticipantData.DEFAULT_SELF_SUB_ID); + + db.beginTransaction(); + try { + // Look up the "self" participantId (creating if necessary) + final String selfId = + BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self); + // Create a new conversation + conversationId = BugleDatabaseOperations.createConversationInTransaction( + db, threadId, conversationName, selfId, participants, archived, + noNotification, noVibrate, soundUri); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + return conversationId; + } + + /** + * Get a conversation from the local DB based on the message's thread id. + * + * @param dbWrapper The database + * @param threadId The message's thread in the SMS database + * @param senderBlocked Flag whether sender of message is in blocked people list + * @return The existing conversation id or null + */ + @VisibleForTesting + @DoesNotRunOnMainThread + public static String getExistingConversation(final DatabaseWrapper dbWrapper, + final long threadId, final boolean senderBlocked) { + Assert.isNotMainThread(); + String conversationId = null; + + Cursor cursor = null; + try { + // Look for an existing conversation in the db with this thread id + cursor = dbWrapper.rawQuery("SELECT " + ConversationColumns._ID + + " FROM " + DatabaseHelper.CONVERSATIONS_TABLE + + " WHERE " + ConversationColumns.SMS_THREAD_ID + "=" + threadId, + null); + + if (cursor.moveToFirst()) { + Assert.isTrue(cursor.getCount() == 1); + conversationId = cursor.getString(0); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return conversationId; + } + + /** + * Get the thread id for an existing conversation from the local DB. + * + * @param dbWrapper The database + * @param conversationId The conversation to look up thread for + * @return The thread id. Returns -1 if the conversation was not found or if it was found + * but the thread column was NULL. + */ + @DoesNotRunOnMainThread + public static long getThreadId(final DatabaseWrapper dbWrapper, final String conversationId) { + Assert.isNotMainThread(); + long threadId = -1; + + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { ConversationColumns.SMS_THREAD_ID }, + ConversationColumns._ID + " =?", + new String[] { conversationId }, + null, null, null); + + if (cursor.moveToFirst()) { + Assert.isTrue(cursor.getCount() == 1); + if (!cursor.isNull(0)) { + threadId = cursor.getLong(0); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return threadId; + } + + @DoesNotRunOnMainThread + public static boolean isBlockedDestination(final DatabaseWrapper db, final String destination) { + Assert.isNotMainThread(); + return isBlockedParticipant(db, destination, ParticipantColumns.NORMALIZED_DESTINATION); + } + + static boolean isBlockedParticipant(final DatabaseWrapper db, final String participantId) { + return isBlockedParticipant(db, participantId, ParticipantColumns._ID); + } + + static boolean isBlockedParticipant(final DatabaseWrapper db, final String value, + final String column) { + Cursor cursor = null; + try { + cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE, + new String[] { ParticipantColumns.BLOCKED }, + column + "=? AND " + ParticipantColumns.SUB_ID + "=?", + new String[] { value, + Integer.toString(ParticipantData.OTHER_THAN_SELF_SUB_ID) }, + null, null, null); + + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + return cursor.getInt(0) == 1; + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return false; // if there's no row, it's not blocked :-) + } + + /** + * Create a conversation in the local DB based on the message's thread id. + * + * It's up to the caller to make sure that this is all inside a transaction. It will return + * null if it's not in the local DB. + * + * @param dbWrapper The database + * @param threadId The message's thread + * @param selfId The selfId to make default for this conversation + * @param archived Flag whether the conversation should be created archived + * @param noNotification If notification should be disabled + * @param noVibrate If vibrate on notification should be disabled + * @param soundUri The customized sound + * @return The existing conversation id or new conversation id + */ + static String createConversationInTransaction(final DatabaseWrapper dbWrapper, + final long threadId, final String conversationName, final String selfId, + final List participants, final boolean archived, + boolean noNotification, boolean noVibrate, String soundUri) { + // We want conversation and participant creation to be atomic + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + boolean hasEmailAddress = false; + for (final ParticipantData participant : participants) { + Assert.isTrue(!participant.isSelf()); + if (participant.isEmail()) { + hasEmailAddress = true; + } + } + + // TODO : Conversations state - normal vs. archived + + // Insert a new local conversation for this thread id + final ContentValues values = new ContentValues(); + values.put(ConversationColumns.SMS_THREAD_ID, threadId); + // Start with conversation hidden - sending a message or saving a draft will change that + values.put(ConversationColumns.SORT_TIMESTAMP, 0L); + values.put(ConversationColumns.CURRENT_SELF_ID, selfId); + values.put(ConversationColumns.PARTICIPANT_COUNT, participants.size()); + values.put(ConversationColumns.INCLUDE_EMAIL_ADDRESS, (hasEmailAddress ? 1 : 0)); + if (archived) { + values.put(ConversationColumns.ARCHIVE_STATUS, 1); + } + if (noNotification) { + values.put(ConversationColumns.NOTIFICATION_ENABLED, 0); + } + if (noVibrate) { + values.put(ConversationColumns.NOTIFICATION_VIBRATION, 0); + } + if (!TextUtils.isEmpty(soundUri)) { + values.put(ConversationColumns.NOTIFICATION_SOUND_URI, soundUri); + } + + fillParticipantData(values, participants); + + final long conversationRowId = dbWrapper.insert(DatabaseHelper.CONVERSATIONS_TABLE, null, + values); + + Assert.isTrue(conversationRowId != -1); + if (conversationRowId == -1) { + LogUtil.e(TAG, "BugleDatabaseOperations : failed to insert conversation into table"); + return null; + } + + final String conversationId = Long.toString(conversationRowId); + + // Make sure that participants are added for this conversation + for (final ParticipantData participant : participants) { + // TODO: Use blocking information + addParticipantToConversation(dbWrapper, participant, conversationId); + } + + // Now fully resolved participants available can update conversation name / avatar. + // b/16437575: We cannot use the participants directly, but instead have to call + // getParticipantsForConversation() to retrieve the actual participants. This is needed + // because the call to addParticipantToConversation() won't fill up the ParticipantData + // if the participant already exists in the participant table. For example, say you have + // an existing conversation with John. Now if you create a new group conversation with + // Jeff & John with only their phone numbers, then when we try to add John's number to the + // group conversation, we see that he's already in the participant table, therefore we + // short-circuit any steps to actually fill out the ParticipantData for John other than + // just returning his participant id. Eventually, the ParticipantData we have is still the + // raw data with just the phone number. getParticipantsForConversation(), on the other + // hand, will fill out all the info for each participant from the participants table. + updateConversationNameAndAvatarInTransaction(dbWrapper, conversationId, + getParticipantsForConversation(dbWrapper, conversationId)); + + return conversationId; + } + + private static void fillParticipantData(final ContentValues values, + final List participants) { + if (participants != null && !participants.isEmpty()) { + final Uri avatarUri = AvatarUriUtil.createAvatarUri(participants); + values.put(ConversationColumns.ICON, avatarUri.toString()); + + long contactId; + String lookupKey; + String destination; + if (participants.size() == 1) { + final ParticipantData firstParticipant = participants.get(0); + contactId = firstParticipant.getContactId(); + lookupKey = firstParticipant.getLookupKey(); + destination = firstParticipant.getNormalizedDestination(); + } else { + contactId = 0; + lookupKey = null; + destination = null; + } + + values.put(ConversationColumns.PARTICIPANT_CONTACT_ID, contactId); + values.put(ConversationColumns.PARTICIPANT_LOOKUP_KEY, lookupKey); + values.put(ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION, destination); + } + } + + /** + * Delete conversation and associated messages/parts + */ + @DoesNotRunOnMainThread + public static boolean deleteConversation(final DatabaseWrapper dbWrapper, + final String conversationId, final long cutoffTimestamp) { + Assert.isNotMainThread(); + dbWrapper.beginTransaction(); + boolean conversationDeleted = false; + boolean conversationMessagesDeleted = false; + try { + // Delete existing messages + if (cutoffTimestamp == Long.MAX_VALUE) { + // Delete parts and messages + dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE, + MessageColumns.CONVERSATION_ID + "=?", new String[] { conversationId }); + conversationMessagesDeleted = true; + } else { + // Delete all messages prior to the cutoff + dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE, + MessageColumns.CONVERSATION_ID + "=? AND " + + MessageColumns.RECEIVED_TIMESTAMP + "<=?", + new String[] { conversationId, Long.toString(cutoffTimestamp) }); + + // Delete any draft message. The delete above may not always include the draft, + // because under certain scenarios (e.g. sending messages in progress), the draft + // timestamp can be larger than the cutoff time, which is generally the conversation + // sort timestamp. Because of how the sms/mms provider works on some newer + // devices, it's important that we never delete all the messages in a conversation + // without also deleting the conversation itself (see b/20262204 for details). + dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE, + MessageColumns.STATUS + "=? AND " + MessageColumns.CONVERSATION_ID + "=?", + new String[] { + Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_DRAFT), + conversationId + }); + + // Check to see if there are any messages left in the conversation + final long count = dbWrapper.queryNumEntries(DatabaseHelper.MESSAGES_TABLE, + MessageColumns.CONVERSATION_ID + "=?", new String[] { conversationId }); + conversationMessagesDeleted = (count == 0); + + // Log detail information if there are still messages left in the conversation + if (!conversationMessagesDeleted) { + final long maxTimestamp = + getConversationMaxTimestamp(dbWrapper, conversationId); + LogUtil.w(TAG, "BugleDatabaseOperations:" + + " cannot delete all messages in a conversation" + + ", after deletion: count=" + count + + ", max timestamp=" + maxTimestamp + + ", cutoff timestamp=" + cutoffTimestamp); + } + } + + if (conversationMessagesDeleted) { + // Delete conversation row + final int count = dbWrapper.delete(DatabaseHelper.CONVERSATIONS_TABLE, + ConversationColumns._ID + "=?", new String[] { conversationId }); + conversationDeleted = (count > 0); + } + dbWrapper.setTransactionSuccessful(); + } finally { + dbWrapper.endTransaction(); + } + return conversationDeleted; + } + + private static final String MAX_RECEIVED_TIMESTAMP = + "MAX(" + MessageColumns.RECEIVED_TIMESTAMP + ")"; + /** + * Get the max received timestamp of a conversation's messages + */ + private static long getConversationMaxTimestamp(final DatabaseWrapper dbWrapper, + final String conversationId) { + final Cursor cursor = dbWrapper.query( + DatabaseHelper.MESSAGES_TABLE, + new String[]{ MAX_RECEIVED_TIMESTAMP }, + MessageColumns.CONVERSATION_ID + "=?", + new String[]{ conversationId }, + null, null, null); + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + return cursor.getLong(0); + } + } finally { + cursor.close(); + } + } + return 0; + } + + @DoesNotRunOnMainThread + public static void updateConversationMetadataInTransaction(final DatabaseWrapper dbWrapper, + final String conversationId, final String messageId, final long latestTimestamp, + final boolean keepArchived, final String smsServiceCenter, + final boolean shouldAutoSwitchSelfId) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + + final ContentValues values = new ContentValues(); + values.put(ConversationColumns.LATEST_MESSAGE_ID, messageId); + values.put(ConversationColumns.SORT_TIMESTAMP, latestTimestamp); + if (!TextUtils.isEmpty(smsServiceCenter)) { + values.put(ConversationColumns.SMS_SERVICE_CENTER, smsServiceCenter); + } + + // When the conversation gets updated with new messages, unarchive the conversation unless + // the sender is blocked, or we have been told to keep it archived. + if (!keepArchived) { + values.put(ConversationColumns.ARCHIVE_STATUS, 0); + } + + final MessageData message = readMessage(dbWrapper, messageId); + addSnippetTextAndPreviewToContentValues(message, false /* showDraft */, values); + + if (shouldAutoSwitchSelfId) { + addSelfIdAutoSwitchInfoToContentValues(dbWrapper, message, conversationId, values); + } + + // Conversation always exists as this method is called from ActionService only after + // reading and if necessary creating the conversation. + updateConversationRow(dbWrapper, conversationId, values); + + if (shouldAutoSwitchSelfId && OsUtil.isAtLeastL_MR1()) { + // Normally, the draft message compose UI trusts its UI state for providing up-to-date + // conversation self id. Therefore, notify UI through local broadcast receiver about + // this external change so the change can be properly reflected. + UIIntents.get().broadcastConversationSelfIdChange(dbWrapper.getContext(), + conversationId, getConversationSelfId(dbWrapper, conversationId)); + } + } + + @DoesNotRunOnMainThread + public static void updateConversationMetadataInTransaction(final DatabaseWrapper db, + final String conversationId, final String messageId, final long latestTimestamp, + final boolean keepArchived, final boolean shouldAutoSwitchSelfId) { + Assert.isNotMainThread(); + updateConversationMetadataInTransaction( + db, conversationId, messageId, latestTimestamp, keepArchived, null, + shouldAutoSwitchSelfId); + } + + @DoesNotRunOnMainThread + public static void updateConversationArchiveStatusInTransaction(final DatabaseWrapper dbWrapper, + final String conversationId, final boolean isArchived) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + final ContentValues values = new ContentValues(); + values.put(ConversationColumns.ARCHIVE_STATUS, isArchived ? 1 : 0); + updateConversationRowIfExists(dbWrapper, conversationId, values); + } + + static void addSnippetTextAndPreviewToContentValues(final MessageData message, + final boolean showDraft, final ContentValues values) { + values.put(ConversationColumns.SHOW_DRAFT, showDraft ? 1 : 0); + values.put(ConversationColumns.SNIPPET_TEXT, message.getMessageText()); + values.put(ConversationColumns.SUBJECT_TEXT, message.getMmsSubject()); + + String type = null; + String uriString = null; + for (final MessagePartData part : message.getParts()) { + if (part.isAttachment() && + ContentType.isConversationListPreviewableType(part.getContentType())) { + uriString = part.getContentUri().toString(); + type = part.getContentType(); + break; + } + } + values.put(ConversationColumns.PREVIEW_CONTENT_TYPE, type); + values.put(ConversationColumns.PREVIEW_URI, uriString); + } + + /** + * Adds self-id auto switch info for a conversation if the last message has a different + * subscription than the conversation's. + * @return true if self id will need to be changed, false otherwise. + */ + static boolean addSelfIdAutoSwitchInfoToContentValues(final DatabaseWrapper dbWrapper, + final MessageData message, final String conversationId, final ContentValues values) { + // Only auto switch conversation self for incoming messages. + if (!OsUtil.isAtLeastL_MR1() || !message.getIsIncoming()) { + return false; + } + + final String conversationSelfId = getConversationSelfId(dbWrapper, conversationId); + final String messageSelfId = message.getSelfId(); + + if (conversationSelfId == null || messageSelfId == null) { + return false; + } + + // Get the sub IDs in effect for both the message and the conversation and compare them: + // 1. If message is unbound (using default sub id), then the message was sent with + // pre-MSIM support. Don't auto-switch because we don't know the subscription for the + // message. + // 2. If message is bound, + // i. If conversation is unbound, use the system default sub id as its effective sub. + // ii. If conversation is bound, use its subscription directly. + // Compare the message sub id with the conversation's effective sub id. If they are + // different, auto-switch the conversation to the message's sub. + final ParticipantData conversationSelf = getExistingParticipant(dbWrapper, + conversationSelfId); + final ParticipantData messageSelf = getExistingParticipant(dbWrapper, messageSelfId); + if (!messageSelf.isActiveSubscription()) { + // Don't switch if the message subscription is no longer active. + return false; + } + final int messageSubId = messageSelf.getSubId(); + if (messageSubId == ParticipantData.DEFAULT_SELF_SUB_ID) { + return false; + } + + final int conversationEffectiveSubId = + PhoneUtils.getDefault().getEffectiveSubId(conversationSelf.getSubId()); + + if (conversationEffectiveSubId != messageSubId) { + return addConversationSelfIdToContentValues(dbWrapper, messageSelf.getId(), values); + } + return false; + } + + /** + * Adds conversation self id updates to ContentValues given. This performs check on the selfId + * to ensure it's valid and active. + * @return true if self id will need to be changed, false otherwise. + */ + static boolean addConversationSelfIdToContentValues(final DatabaseWrapper dbWrapper, + final String selfId, final ContentValues values) { + // Make sure the selfId passed in is valid and active. + final String selection = ParticipantColumns._ID + "=? AND " + + ParticipantColumns.SIM_SLOT_ID + "<>?"; + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE, + new String[] { ParticipantColumns._ID }, selection, + new String[] { selfId, String.valueOf(ParticipantData.INVALID_SLOT_ID) }, + null, null, null); + + if (cursor != null && cursor.getCount() > 0) { + values.put(ConversationColumns.CURRENT_SELF_ID, selfId); + return true; + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return false; + } + + private static void updateConversationDraftSnippetAndPreviewInTransaction( + final DatabaseWrapper dbWrapper, final String conversationId, + final MessageData draftMessage) { + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + + long sortTimestamp = 0L; + Cursor cursor = null; + try { + // Check to find the latest message in the conversation + cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE, + REFRESH_CONVERSATION_MESSAGE_PROJECTION, + MessageColumns.CONVERSATION_ID + "=?", + new String[]{conversationId}, null, null, + MessageColumns.RECEIVED_TIMESTAMP + " DESC", "1" /* limit */); + + if (cursor.moveToFirst()) { + sortTimestamp = cursor.getLong(1); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + + final ContentValues values = new ContentValues(); + if (draftMessage == null || !draftMessage.hasContent()) { + values.put(ConversationColumns.SHOW_DRAFT, 0); + values.put(ConversationColumns.DRAFT_SNIPPET_TEXT, ""); + values.put(ConversationColumns.DRAFT_SUBJECT_TEXT, ""); + values.put(ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE, ""); + values.put(ConversationColumns.DRAFT_PREVIEW_URI, ""); + } else { + sortTimestamp = Math.max(sortTimestamp, draftMessage.getReceivedTimeStamp()); + values.put(ConversationColumns.SHOW_DRAFT, 1); + values.put(ConversationColumns.DRAFT_SNIPPET_TEXT, draftMessage.getMessageText()); + values.put(ConversationColumns.DRAFT_SUBJECT_TEXT, draftMessage.getMmsSubject()); + String type = null; + String uriString = null; + for (final MessagePartData part : draftMessage.getParts()) { + if (part.isAttachment() && + ContentType.isConversationListPreviewableType(part.getContentType())) { + uriString = part.getContentUri().toString(); + type = part.getContentType(); + break; + } + } + values.put(ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE, type); + values.put(ConversationColumns.DRAFT_PREVIEW_URI, uriString); + } + values.put(ConversationColumns.SORT_TIMESTAMP, sortTimestamp); + // Called in transaction after reading conversation row + updateConversationRow(dbWrapper, conversationId, values); + } + + @DoesNotRunOnMainThread + public static boolean updateConversationRowIfExists(final DatabaseWrapper dbWrapper, + final String conversationId, final ContentValues values) { + Assert.isNotMainThread(); + return updateRowIfExists(dbWrapper, DatabaseHelper.CONVERSATIONS_TABLE, + ConversationColumns._ID, conversationId, values); + } + + @DoesNotRunOnMainThread + public static void updateConversationRow(final DatabaseWrapper dbWrapper, + final String conversationId, final ContentValues values) { + Assert.isNotMainThread(); + final boolean exists = updateConversationRowIfExists(dbWrapper, conversationId, values); + Assert.isTrue(exists); + } + + @DoesNotRunOnMainThread + public static boolean updateMessageRowIfExists(final DatabaseWrapper dbWrapper, + final String messageId, final ContentValues values) { + Assert.isNotMainThread(); + return updateRowIfExists(dbWrapper, DatabaseHelper.MESSAGES_TABLE, MessageColumns._ID, + messageId, values); + } + + @DoesNotRunOnMainThread + public static void updateMessageRow(final DatabaseWrapper dbWrapper, + final String messageId, final ContentValues values) { + Assert.isNotMainThread(); + final boolean exists = updateMessageRowIfExists(dbWrapper, messageId, values); + Assert.isTrue(exists); + } + + @DoesNotRunOnMainThread + public static boolean updatePartRowIfExists(final DatabaseWrapper dbWrapper, + final String partId, final ContentValues values) { + Assert.isNotMainThread(); + return updateRowIfExists(dbWrapper, DatabaseHelper.PARTS_TABLE, PartColumns._ID, + partId, values); + } + + /** + * Returns the default conversation name based on its participants. + */ + private static String getDefaultConversationName(final List participants) { + return ConversationListItemData.generateConversationName(participants); + } + + /** + * Updates a given conversation's name based on its participants. + */ + @DoesNotRunOnMainThread + public static void updateConversationNameAndAvatarInTransaction( + final DatabaseWrapper dbWrapper, final String conversationId) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + + final ArrayList participants = + getParticipantsForConversation(dbWrapper, conversationId); + updateConversationNameAndAvatarInTransaction(dbWrapper, conversationId, participants); + } + + /** + * Updates a given conversation's name based on its participants. + */ + private static void updateConversationNameAndAvatarInTransaction( + final DatabaseWrapper dbWrapper, final String conversationId, + final List participants) { + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + + final ContentValues values = new ContentValues(); + values.put(ConversationColumns.NAME, + getDefaultConversationName(participants)); + + fillParticipantData(values, participants); + + // Used by background thread when refreshing conversation so conversation could be deleted. + updateConversationRowIfExists(dbWrapper, conversationId, values); + + WidgetConversationProvider.notifyConversationRenamed(Factory.get().getApplicationContext(), + conversationId); + } + + /** + * Updates a given conversation's self id. + */ + @DoesNotRunOnMainThread + public static void updateConversationSelfIdInTransaction( + final DatabaseWrapper dbWrapper, final String conversationId, final String selfId) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + final ContentValues values = new ContentValues(); + if (addConversationSelfIdToContentValues(dbWrapper, selfId, values)) { + updateConversationRowIfExists(dbWrapper, conversationId, values); + } + } + + @DoesNotRunOnMainThread + public static String getConversationSelfId(final DatabaseWrapper dbWrapper, + final String conversationId) { + Assert.isNotMainThread(); + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { ConversationColumns.CURRENT_SELF_ID }, + ConversationColumns._ID + "=?", + new String[] { conversationId }, + null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + return cursor.getString(0); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return null; + } + + /** + * Frees up memory associated with phone number to participant id matching. + */ + @DoesNotRunOnMainThread + public static void clearParticipantIdCache() { + Assert.isNotMainThread(); + synchronized (sNormalizedPhoneNumberToParticipantIdCache) { + sNormalizedPhoneNumberToParticipantIdCache.clear(); + } + } + + @DoesNotRunOnMainThread + public static ArrayList getRecipientsForConversation(final DatabaseWrapper dbWrapper, + final String conversationId) { + Assert.isNotMainThread(); + final ArrayList participants = + getParticipantsForConversation(dbWrapper, conversationId); + + final ArrayList recipients = new ArrayList(); + for (final ParticipantData participant : participants) { + recipients.add(participant.getSendDestination()); + } + + return recipients; + } + + @DoesNotRunOnMainThread + public static String getSmsServiceCenterForConversation(final DatabaseWrapper dbWrapper, + final String conversationId) { + Assert.isNotMainThread(); + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { ConversationColumns.SMS_SERVICE_CENTER }, + ConversationColumns._ID + "=?", + new String[] { conversationId }, + null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + return cursor.getString(0); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return null; + } + + @DoesNotRunOnMainThread + public static ParticipantData getExistingParticipant(final DatabaseWrapper dbWrapper, + final String participantId) { + Assert.isNotMainThread(); + ParticipantData participant = null; + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE, + ParticipantData.ParticipantsQuery.PROJECTION, + ParticipantColumns._ID + " =?", + new String[] { participantId }, null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + participant = ParticipantData.getFromCursor(cursor); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return participant; + } + + static int getSelfSubscriptionId(final DatabaseWrapper dbWrapper, + final String selfParticipantId) { + final ParticipantData selfParticipant = BugleDatabaseOperations.getExistingParticipant( + dbWrapper, selfParticipantId); + if (selfParticipant != null) { + Assert.isTrue(selfParticipant.isSelf()); + return selfParticipant.getSubId(); + } + return ParticipantData.DEFAULT_SELF_SUB_ID; + } + + @VisibleForTesting + @DoesNotRunOnMainThread + public static ArrayList getParticipantsForConversation( + final DatabaseWrapper dbWrapper, final String conversationId) { + Assert.isNotMainThread(); + final ArrayList participants = + new ArrayList(); + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE, + ParticipantData.ParticipantsQuery.PROJECTION, + ParticipantColumns._ID + " IN ( " + "SELECT " + + ConversationParticipantsColumns.PARTICIPANT_ID + " AS " + + ParticipantColumns._ID + + " FROM " + DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE + + " WHERE " + ConversationParticipantsColumns.CONVERSATION_ID + " =? )", + new String[] { conversationId }, null, null, null); + + while (cursor.moveToNext()) { + participants.add(ParticipantData.getFromCursor(cursor)); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return participants; + } + + @DoesNotRunOnMainThread + public static MessageData readMessage(final DatabaseWrapper dbWrapper, final String messageId) { + Assert.isNotMainThread(); + final MessageData message = readMessageData(dbWrapper, messageId); + if (message != null) { + readMessagePartsData(dbWrapper, message, false); + } + return message; + } + + @VisibleForTesting + static MessagePartData readMessagePartData(final DatabaseWrapper dbWrapper, + final String partId) { + MessagePartData messagePartData = null; + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.PARTS_TABLE, + MessagePartData.getProjection(), PartColumns._ID + "=?", + new String[] { partId }, null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + messagePartData = MessagePartData.createFromCursor(cursor); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return messagePartData; + } + + @DoesNotRunOnMainThread + public static MessageData readMessageData(final DatabaseWrapper dbWrapper, + final Uri smsMessageUri) { + Assert.isNotMainThread(); + MessageData message = null; + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE, + MessageData.getProjection(), MessageColumns.SMS_MESSAGE_URI + "=?", + new String[] { smsMessageUri.toString() }, null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + message = new MessageData(); + message.bind(cursor); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return message; + } + + @DoesNotRunOnMainThread + public static MessageData readMessageData(final DatabaseWrapper dbWrapper, + final String messageId) { + Assert.isNotMainThread(); + MessageData message = null; + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE, + MessageData.getProjection(), MessageColumns._ID + "=?", + new String[] { messageId }, null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + message = new MessageData(); + message.bind(cursor); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return message; + } + + /** + * Read all the parts for a message + * @param dbWrapper database + * @param message read parts for this message + * @param checkAttachmentFilesExist check each attachment file and only include if file exists + */ + private static void readMessagePartsData(final DatabaseWrapper dbWrapper, + final MessageData message, final boolean checkAttachmentFilesExist) { + final ContentResolver contentResolver = + Factory.get().getApplicationContext().getContentResolver(); + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.PARTS_TABLE, + MessagePartData.getProjection(), PartColumns.MESSAGE_ID + "=?", + new String[] { message.getMessageId() }, null, null, null); + while (cursor.moveToNext()) { + final MessagePartData messagePartData = MessagePartData.createFromCursor(cursor); + if (checkAttachmentFilesExist && messagePartData.isAttachment() && + !UriUtil.isBugleAppResource(messagePartData.getContentUri())) { + try { + // Test that the file exists before adding the attachment to the draft + final ParcelFileDescriptor fileDescriptor = + contentResolver.openFileDescriptor( + messagePartData.getContentUri(), "r"); + if (fileDescriptor != null) { + fileDescriptor.close(); + message.addPart(messagePartData); + } + } catch (final IOException e) { + // The attachment's temp storage no longer exists, just ignore the file + } catch (final SecurityException e) { + // Likely thrown by openFileDescriptor due to an expired access grant. + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) { + LogUtil.d(LogUtil.BUGLE_TAG, "uri: " + messagePartData.getContentUri()); + } + } + } else { + message.addPart(messagePartData); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + /** + * Write a message part to our local database + * + * @param dbWrapper The database + * @param messagePart The message part to insert + * @return The row id of the newly inserted part + */ + static String insertNewMessagePartInTransaction(final DatabaseWrapper dbWrapper, + final MessagePartData messagePart, final String conversationId) { + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + Assert.isTrue(!TextUtils.isEmpty(messagePart.getMessageId())); + + // Insert a new part row + final SQLiteStatement insert = messagePart.getInsertStatement(dbWrapper, conversationId); + final long rowNumber = insert.executeInsert(); + + Assert.inRange(rowNumber, 0, Long.MAX_VALUE); + final String partId = Long.toString(rowNumber); + + // Update the part id + messagePart.updatePartId(partId); + + return partId; + } + + /** + * Insert a message and its parts into the table + */ + @DoesNotRunOnMainThread + public static void insertNewMessageInTransaction(final DatabaseWrapper dbWrapper, + final MessageData message) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + + // Insert message row + final SQLiteStatement insert = message.getInsertStatement(dbWrapper); + final long rowNumber = insert.executeInsert(); + + Assert.inRange(rowNumber, 0, Long.MAX_VALUE); + final String messageId = Long.toString(rowNumber); + message.updateMessageId(messageId); + // Insert new parts + for (final MessagePartData messagePart : message.getParts()) { + messagePart.updateMessageId(messageId); + insertNewMessagePartInTransaction(dbWrapper, messagePart, message.getConversationId()); + } + } + + /** + * Update a message and add its parts into the table + */ + @DoesNotRunOnMainThread + public static void updateMessageInTransaction(final DatabaseWrapper dbWrapper, + final MessageData message) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + final String messageId = message.getMessageId(); + // Check message still exists (sms sync or delete might have purged it) + final MessageData current = BugleDatabaseOperations.readMessage(dbWrapper, messageId); + if (current != null) { + // Delete existing message parts) + deletePartsForMessage(dbWrapper, message.getMessageId()); + // Insert new parts + for (final MessagePartData messagePart : message.getParts()) { + messagePart.updatePartId(null); + messagePart.updateMessageId(message.getMessageId()); + insertNewMessagePartInTransaction(dbWrapper, messagePart, + message.getConversationId()); + } + // Update message row + final ContentValues values = new ContentValues(); + message.populate(values); + updateMessageRowIfExists(dbWrapper, message.getMessageId(), values); + } + } + + @DoesNotRunOnMainThread + public static void updateMessageAndPartsInTransaction(final DatabaseWrapper dbWrapper, + final MessageData message, final List partsToUpdate) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + final ContentValues values = new ContentValues(); + for (final MessagePartData messagePart : partsToUpdate) { + values.clear(); + messagePart.populate(values); + updatePartRowIfExists(dbWrapper, messagePart.getPartId(), values); + } + values.clear(); + message.populate(values); + updateMessageRowIfExists(dbWrapper, message.getMessageId(), values); + } + + /** + * Delete all parts for a message + */ + static void deletePartsForMessage(final DatabaseWrapper dbWrapper, + final String messageId) { + final int cnt = dbWrapper.delete(DatabaseHelper.PARTS_TABLE, + PartColumns.MESSAGE_ID + " =?", + new String[] { messageId }); + Assert.inRange(cnt, 0, Integer.MAX_VALUE); + } + + /** + * Delete one message and update the conversation (if necessary). + * + * @return number of rows deleted (should be 1 or 0). + */ + @DoesNotRunOnMainThread + public static int deleteMessage(final DatabaseWrapper dbWrapper, final String messageId) { + Assert.isNotMainThread(); + dbWrapper.beginTransaction(); + try { + // Read message to find out which conversation it is in + final MessageData message = BugleDatabaseOperations.readMessage(dbWrapper, messageId); + + int count = 0; + if (message != null) { + final String conversationId = message.getConversationId(); + // Delete message + count = dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE, + MessageColumns._ID + "=?", new String[] { messageId }); + + if (!deleteConversationIfEmptyInTransaction(dbWrapper, conversationId)) { + // TODO: Should we leave the conversation sort timestamp alone? + refreshConversationMetadataInTransaction(dbWrapper, conversationId, + false/* shouldAutoSwitchSelfId */, false/*archived*/); + } + } + dbWrapper.setTransactionSuccessful(); + return count; + } finally { + dbWrapper.endTransaction(); + } + } + + /** + * Deletes the conversation if there are zero non-draft messages left. + *

+ * This is necessary because the telephony database has a trigger that deletes threads after + * their last message is deleted. We need to ensure that if a thread goes away, we also delete + * the conversation in Bugle. We don't store draft messages in telephony, so we ignore those + * when querying for the # of messages in the conversation. + * + * @return true if the conversation was deleted + */ + @DoesNotRunOnMainThread + public static boolean deleteConversationIfEmptyInTransaction(final DatabaseWrapper dbWrapper, + final String conversationId) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + Cursor cursor = null; + try { + // TODO: The refreshConversationMetadataInTransaction method below uses this + // same query; maybe they should share this logic? + + // Check to see if there are any (non-draft) messages in the conversation + cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE, + REFRESH_CONVERSATION_MESSAGE_PROJECTION, + MessageColumns.CONVERSATION_ID + "=? AND " + + MessageColumns.STATUS + "!=" + MessageData.BUGLE_STATUS_OUTGOING_DRAFT, + new String[] { conversationId }, null, null, + MessageColumns.RECEIVED_TIMESTAMP + " DESC", "1" /* limit */); + if (cursor.getCount() == 0) { + dbWrapper.delete(DatabaseHelper.CONVERSATIONS_TABLE, + ConversationColumns._ID + "=?", new String[] { conversationId }); + LogUtil.i(TAG, + "BugleDatabaseOperations: Deleted empty conversation " + conversationId); + return true; + } else { + return false; + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + private static final String[] REFRESH_CONVERSATION_MESSAGE_PROJECTION = new String[] { + MessageColumns._ID, + MessageColumns.RECEIVED_TIMESTAMP, + MessageColumns.SENDER_PARTICIPANT_ID + }; + + /** + * Update conversation snippet, timestamp and optionally self id to match latest message in + * conversation. + */ + @DoesNotRunOnMainThread + public static void refreshConversationMetadataInTransaction(final DatabaseWrapper dbWrapper, + final String conversationId, final boolean shouldAutoSwitchSelfId, + boolean keepArchived) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + Cursor cursor = null; + try { + // Check to see if there are any (non-draft) messages in the conversation + cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE, + REFRESH_CONVERSATION_MESSAGE_PROJECTION, + MessageColumns.CONVERSATION_ID + "=? AND " + + MessageColumns.STATUS + "!=" + MessageData.BUGLE_STATUS_OUTGOING_DRAFT, + new String[] { conversationId }, null, null, + MessageColumns.RECEIVED_TIMESTAMP + " DESC", "1" /* limit */); + + if (cursor.moveToFirst()) { + // Refresh latest message in conversation + final String latestMessageId = cursor.getString(0); + final long latestMessageTimestamp = cursor.getLong(1); + final String senderParticipantId = cursor.getString(2); + final boolean senderBlocked = isBlockedParticipant(dbWrapper, senderParticipantId); + updateConversationMetadataInTransaction(dbWrapper, conversationId, + latestMessageId, latestMessageTimestamp, senderBlocked || keepArchived, + shouldAutoSwitchSelfId); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + /** + * When moving/removing an existing message update conversation metadata if necessary + * @param dbWrapper db wrapper + * @param conversationId conversation to modify + * @param messageId message that is leaving the conversation + * @param shouldAutoSwitchSelfId should we try to auto-switch the conversation's self-id as a + * result of this call when we see a new latest message? + * @param keepArchived should we keep the conversation archived despite refresh + */ + @DoesNotRunOnMainThread + public static void maybeRefreshConversationMetadataInTransaction( + final DatabaseWrapper dbWrapper, final String conversationId, final String messageId, + final boolean shouldAutoSwitchSelfId, final boolean keepArchived) { + Assert.isNotMainThread(); + boolean refresh = true; + if (!TextUtils.isEmpty(messageId)) { + refresh = false; + // Look for an existing conversation in the db with this conversation id + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { ConversationColumns.LATEST_MESSAGE_ID }, + ConversationColumns._ID + "=?", + new String[] { conversationId }, + null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + refresh = TextUtils.equals(cursor.getString(0), messageId); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + if (refresh) { + // TODO: I think it is okay to delete the conversation if it is empty... + refreshConversationMetadataInTransaction(dbWrapper, conversationId, + shouldAutoSwitchSelfId, keepArchived); + } + } + + + + // SQL statement to query latest message if for particular conversation + private static final String QUERY_CONVERSATIONS_LATEST_MESSAGE_SQL = "SELECT " + + ConversationColumns.LATEST_MESSAGE_ID + " FROM " + DatabaseHelper.CONVERSATIONS_TABLE + + " WHERE " + ConversationColumns._ID + "=? LIMIT 1"; + + /** + * Note this is not thread safe so callers need to make sure they own the wrapper + statements + * while they call this and use the returned value. + */ + @DoesNotRunOnMainThread + public static SQLiteStatement getQueryConversationsLatestMessageStatement( + final DatabaseWrapper db, final String conversationId) { + Assert.isNotMainThread(); + final SQLiteStatement query = db.getStatementInTransaction( + DatabaseWrapper.INDEX_QUERY_CONVERSATIONS_LATEST_MESSAGE, + QUERY_CONVERSATIONS_LATEST_MESSAGE_SQL); + query.clearBindings(); + query.bindString(1, conversationId); + return query; + } + + // SQL statement to query latest message if for particular conversation + private static final String QUERY_MESSAGES_LATEST_MESSAGE_SQL = "SELECT " + + MessageColumns._ID + " FROM " + DatabaseHelper.MESSAGES_TABLE + + " WHERE " + MessageColumns.CONVERSATION_ID + "=? ORDER BY " + + MessageColumns.RECEIVED_TIMESTAMP + " DESC LIMIT 1"; + + /** + * Note this is not thread safe so callers need to make sure they own the wrapper + statements + * while they call this and use the returned value. + */ + @DoesNotRunOnMainThread + public static SQLiteStatement getQueryMessagesLatestMessageStatement( + final DatabaseWrapper db, final String conversationId) { + Assert.isNotMainThread(); + final SQLiteStatement query = db.getStatementInTransaction( + DatabaseWrapper.INDEX_QUERY_MESSAGES_LATEST_MESSAGE, + QUERY_MESSAGES_LATEST_MESSAGE_SQL); + query.clearBindings(); + query.bindString(1, conversationId); + return query; + } + + /** + * Update conversation metadata if necessary + * @param dbWrapper db wrapper + * @param conversationId conversation to modify + * @param shouldAutoSwitchSelfId should we try to auto-switch the conversation's self-id as a + * result of this call when we see a new latest message? + * @param keepArchived if the conversation should be kept archived + */ + @DoesNotRunOnMainThread + public static void maybeRefreshConversationMetadataInTransaction( + final DatabaseWrapper dbWrapper, final String conversationId, + final boolean shouldAutoSwitchSelfId, boolean keepArchived) { + Assert.isNotMainThread(); + String currentLatestMessageId = null; + String latestMessageId = null; + try { + final SQLiteStatement currentLatestMessageIdSql = + getQueryConversationsLatestMessageStatement(dbWrapper, conversationId); + currentLatestMessageId = currentLatestMessageIdSql.simpleQueryForString(); + + final SQLiteStatement latestMessageIdSql = + getQueryMessagesLatestMessageStatement(dbWrapper, conversationId); + latestMessageId = latestMessageIdSql.simpleQueryForString(); + } catch (final SQLiteDoneException e) { + LogUtil.e(TAG, "BugleDatabaseOperations: Query for latest message failed", e); + } + + if (TextUtils.isEmpty(currentLatestMessageId) || + !TextUtils.equals(currentLatestMessageId, latestMessageId)) { + refreshConversationMetadataInTransaction(dbWrapper, conversationId, + shouldAutoSwitchSelfId, keepArchived); + } + } + + static boolean getConversationExists(final DatabaseWrapper dbWrapper, + final String conversationId) { + // Look for an existing conversation in the db with this conversation id + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { /* No projection */}, + ConversationColumns._ID + "=?", + new String[] { conversationId }, + null, null, null); + return cursor.getCount() == 1; + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + /** Preserve parts in message but clear the stored draft */ + public static final int UPDATE_MODE_CLEAR_DRAFT = 1; + /** Add the message as a draft */ + public static final int UPDATE_MODE_ADD_DRAFT = 2; + + /** + * Update draft message for specified conversation + * @param dbWrapper local database (wrapped) + * @param conversationId conversation to update + * @param message Optional message to preserve attachments for (either as draft or for + * sending) + * @param updateMode either {@link #UPDATE_MODE_CLEAR_DRAFT} or + * {@link #UPDATE_MODE_ADD_DRAFT} + * @return message id of newly written draft (else null) + */ + @DoesNotRunOnMainThread + public static String updateDraftMessageData(final DatabaseWrapper dbWrapper, + final String conversationId, @Nullable final MessageData message, + final int updateMode) { + Assert.isNotMainThread(); + Assert.notNull(conversationId); + Assert.inRange(updateMode, UPDATE_MODE_CLEAR_DRAFT, UPDATE_MODE_ADD_DRAFT); + String messageId = null; + Cursor cursor = null; + dbWrapper.beginTransaction(); + try { + // Find all draft parts for the current conversation + final SimpleArrayMap currentDraftParts = new SimpleArrayMap<>(); + cursor = dbWrapper.query(DatabaseHelper.DRAFT_PARTS_VIEW, + MessagePartData.getProjection(), + MessageColumns.CONVERSATION_ID + " =?", + new String[] { conversationId }, null, null, null); + while (cursor.moveToNext()) { + final MessagePartData part = MessagePartData.createFromCursor(cursor); + if (part.isAttachment()) { + currentDraftParts.put(part.getContentUri(), part); + } + } + // Optionally, preserve attachments for "message" + final boolean conversationExists = getConversationExists(dbWrapper, conversationId); + if (message != null && conversationExists) { + for (final MessagePartData part : message.getParts()) { + if (part.isAttachment()) { + currentDraftParts.remove(part.getContentUri()); + } + } + } + + // Delete orphan content + for (int index = 0; index < currentDraftParts.size(); index++) { + final MessagePartData part = currentDraftParts.valueAt(index); + part.destroySync(); + } + + // Delete existing draft (cascade deletes parts) + dbWrapper.delete(DatabaseHelper.MESSAGES_TABLE, + MessageColumns.STATUS + "=? AND " + MessageColumns.CONVERSATION_ID + "=?", + new String[] { + Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_DRAFT), + conversationId + }); + + // Write new draft + if (updateMode == UPDATE_MODE_ADD_DRAFT && message != null + && message.hasContent() && conversationExists) { + Assert.equals(MessageData.BUGLE_STATUS_OUTGOING_DRAFT, + message.getStatus()); + + // Now add draft to message table + insertNewMessageInTransaction(dbWrapper, message); + messageId = message.getMessageId(); + } + + if (conversationExists) { + updateConversationDraftSnippetAndPreviewInTransaction( + dbWrapper, conversationId, message); + + if (message != null && message.getSelfId() != null) { + updateConversationSelfIdInTransaction(dbWrapper, conversationId, + message.getSelfId()); + } + } + + dbWrapper.setTransactionSuccessful(); + } finally { + dbWrapper.endTransaction(); + if (cursor != null) { + cursor.close(); + } + } + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, + "Updated draft message " + messageId + " for conversation " + conversationId); + } + return messageId; + } + + /** + * Read the first draft message associated with this conversation. + * If none present create an empty (sms) draft message. + */ + @DoesNotRunOnMainThread + public static MessageData readDraftMessageData(final DatabaseWrapper dbWrapper, + final String conversationId, final String conversationSelfId) { + Assert.isNotMainThread(); + MessageData message = null; + Cursor cursor = null; + dbWrapper.beginTransaction(); + try { + cursor = dbWrapper.query(DatabaseHelper.MESSAGES_TABLE, + MessageData.getProjection(), + MessageColumns.STATUS + "=? AND " + MessageColumns.CONVERSATION_ID + "=?", + new String[] { + Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_DRAFT), + conversationId + }, null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + message = new MessageData(); + message.bindDraft(cursor, conversationSelfId); + readMessagePartsData(dbWrapper, message, true); + // Disconnect draft parts from DB + for (final MessagePartData part : message.getParts()) { + part.updatePartId(null); + part.updateMessageId(null); + } + message.updateMessageId(null); + } + dbWrapper.setTransactionSuccessful(); + } finally { + dbWrapper.endTransaction(); + if (cursor != null) { + cursor.close(); + } + } + return message; + } + + // Internal + private static void addParticipantToConversation(final DatabaseWrapper dbWrapper, + final ParticipantData participant, final String conversationId) { + final String participantId = getOrCreateParticipantInTransaction(dbWrapper, participant); + Assert.notNull(participantId); + + // Add the participant to the conversation participants table + final ContentValues values = new ContentValues(); + values.put(ConversationParticipantsColumns.CONVERSATION_ID, conversationId); + values.put(ConversationParticipantsColumns.PARTICIPANT_ID, participantId); + dbWrapper.insert(DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE, null, values); + } + + /** + * Get string used as canonical recipient for participant cache for sub id + */ + private static String getCanonicalRecipientFromSubId(final int subId) { + return "SELF(" + subId + ")"; + } + + /** + * Maps from a sub id or phone number to a participant id if there is one. + * + * @return If the participant is available in our cache, or the DB, this returns the + * participant id for the given subid/phone number. Otherwise it returns null. + */ + @VisibleForTesting + private static String getParticipantId(final DatabaseWrapper dbWrapper, + final int subId, final String canonicalRecipient) { + // First check our memory cache for the participant Id + String participantId; + synchronized (sNormalizedPhoneNumberToParticipantIdCache) { + participantId = sNormalizedPhoneNumberToParticipantIdCache.get(canonicalRecipient); + } + + if (participantId != null) { + return participantId; + } + + // This code will only be executed for incremental additions. + Cursor cursor = null; + try { + if (subId != ParticipantData.OTHER_THAN_SELF_SUB_ID) { + // Now look for an existing participant in the db with this sub id. + cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE, + new String[] {ParticipantColumns._ID}, + ParticipantColumns.SUB_ID + "=?", + new String[] { Integer.toString(subId) }, null, null, null); + } else { + // Look for existing participant with this normalized phone number and no subId. + cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE, + new String[] {ParticipantColumns._ID}, + ParticipantColumns.NORMALIZED_DESTINATION + "=? AND " + + ParticipantColumns.SUB_ID + "=?", + new String[] {canonicalRecipient, Integer.toString(subId)}, + null, null, null); + } + + if (cursor.moveToFirst()) { + // TODO Is this assert correct for multi-sim where a new sim was put in? + Assert.isTrue(cursor.getCount() == 1); + + // We found an existing participant in the database + participantId = cursor.getString(0); + + synchronized (sNormalizedPhoneNumberToParticipantIdCache) { + // Add it to the cache for next time + sNormalizedPhoneNumberToParticipantIdCache.put(canonicalRecipient, + participantId); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return participantId; + } + + @DoesNotRunOnMainThread + public static ParticipantData getOrCreateSelf(final DatabaseWrapper dbWrapper, + final int subId) { + Assert.isNotMainThread(); + ParticipantData participant = null; + dbWrapper.beginTransaction(); + try { + final ParticipantData shell = ParticipantData.getSelfParticipant(subId); + final String participantId = getOrCreateParticipantInTransaction(dbWrapper, shell); + participant = getExistingParticipant(dbWrapper, participantId); + dbWrapper.setTransactionSuccessful(); + } finally { + dbWrapper.endTransaction(); + } + return participant; + } + + /** + * Lookup and if necessary create a new participant + * @param dbWrapper Database wrapper + * @param participant Participant to find/create + * @return participantId ParticipantId for existing or newly created participant + */ + @DoesNotRunOnMainThread + public static String getOrCreateParticipantInTransaction(final DatabaseWrapper dbWrapper, + final ParticipantData participant) { + Assert.isNotMainThread(); + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + int subId = ParticipantData.OTHER_THAN_SELF_SUB_ID; + String participantId = null; + String canonicalRecipient = null; + if (participant.isSelf()) { + subId = participant.getSubId(); + canonicalRecipient = getCanonicalRecipientFromSubId(subId); + } else { + canonicalRecipient = participant.getNormalizedDestination(); + } + Assert.notNull(canonicalRecipient); + participantId = getParticipantId(dbWrapper, subId, canonicalRecipient); + + if (participantId != null) { + return participantId; + } + + if (!participant.isContactIdResolved()) { + // Refresh participant's name and avatar with matching contact in CP2. + ParticipantRefresh.refreshParticipant(dbWrapper, participant); + } + + // Insert the participant into the participants table + final ContentValues values = participant.toContentValues(); + final long participantRow = dbWrapper.insert(DatabaseHelper.PARTICIPANTS_TABLE, null, + values); + participantId = Long.toString(participantRow); + Assert.notNull(canonicalRecipient); + + synchronized (sNormalizedPhoneNumberToParticipantIdCache) { + // Now that we've inserted it, add it to our cache + sNormalizedPhoneNumberToParticipantIdCache.put(canonicalRecipient, participantId); + } + + return participantId; + } + + @DoesNotRunOnMainThread + public static void updateDestination(final DatabaseWrapper dbWrapper, + final String destination, final boolean blocked) { + Assert.isNotMainThread(); + final ContentValues values = new ContentValues(); + values.put(ParticipantColumns.BLOCKED, blocked ? 1 : 0); + dbWrapper.update(DatabaseHelper.PARTICIPANTS_TABLE, values, + ParticipantColumns.NORMALIZED_DESTINATION + "=? AND " + + ParticipantColumns.SUB_ID + "=?", + new String[] { destination, Integer.toString( + ParticipantData.OTHER_THAN_SELF_SUB_ID) }); + } + + @DoesNotRunOnMainThread + public static String getConversationFromOtherParticipantDestination( + final DatabaseWrapper db, final String otherDestination) { + Assert.isNotMainThread(); + Cursor cursor = null; + try { + cursor = db.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { ConversationColumns._ID }, + ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION + "=?", + new String[] { otherDestination }, null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + return cursor.getString(0); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return null; + } + + + /** + * Get a list of conversations that contain any of participants specified. + */ + private static HashSet getConversationsForParticipants( + final ArrayList participantIds) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final HashSet conversationIds = new HashSet(); + + final String selection = ConversationParticipantsColumns.PARTICIPANT_ID + "=?"; + for (final String participantId : participantIds) { + final String[] selectionArgs = new String[] { participantId }; + final Cursor cursor = db.query(DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE, + ConversationParticipantsQuery.PROJECTION, + selection, selectionArgs, null, null, null); + + if (cursor != null) { + try { + while (cursor.moveToNext()) { + final String conversationId = cursor.getString( + ConversationParticipantsQuery.INDEX_CONVERSATION_ID); + conversationIds.add(conversationId); + } + } finally { + cursor.close(); + } + } + } + + return conversationIds; + } + + /** + * Refresh conversation names/avatars based on a list of participants that are changed. + */ + @DoesNotRunOnMainThread + public static void refreshConversationsForParticipants(final ArrayList participants) { + Assert.isNotMainThread(); + final HashSet conversationIds = getConversationsForParticipants(participants); + if (conversationIds.size() > 0) { + for (final String conversationId : conversationIds) { + refreshConversation(conversationId); + } + + MessagingContentProvider.notifyConversationListChanged(); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Number of conversations refreshed:" + conversationIds.size()); + } + } + } + + /** + * Refresh conversation names/avatars based on a changed participant. + */ + @DoesNotRunOnMainThread + public static void refreshConversationsForParticipant(final String participantId) { + Assert.isNotMainThread(); + final ArrayList participantList = new ArrayList(1); + participantList.add(participantId); + refreshConversationsForParticipants(participantList); + } + + /** + * Refresh one conversation. + */ + private static void refreshConversation(final String conversationId) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + + db.beginTransaction(); + try { + BugleDatabaseOperations.updateConversationNameAndAvatarInTransaction(db, + conversationId); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + MessagingContentProvider.notifyParticipantsChanged(conversationId); + MessagingContentProvider.notifyMessagesChanged(conversationId); + MessagingContentProvider.notifyConversationMetadataChanged(conversationId); + } + + @DoesNotRunOnMainThread + public static boolean updateRowIfExists(final DatabaseWrapper db, final String table, + final String rowKey, final String rowId, final ContentValues values) { + Assert.isNotMainThread(); + final StringBuilder sb = new StringBuilder(); + final ArrayList whereValues = new ArrayList(values.size() + 1); + whereValues.add(rowId); + + for (final String key : values.keySet()) { + if (sb.length() > 0) { + sb.append(" OR "); + } + final Object value = values.get(key); + sb.append(key); + if (value != null) { + sb.append(" IS NOT ?"); + whereValues.add(value.toString()); + } else { + sb.append(" IS NOT NULL"); + } + } + + final String whereClause = rowKey + "=?" + " AND (" + sb.toString() + ")"; + final String [] whereValuesArray = whereValues.toArray(new String[whereValues.size()]); + final int count = db.update(table, values, whereClause, whereValuesArray); + if (count > 1) { + LogUtil.w(LogUtil.BUGLE_TAG, "Updated more than 1 row " + count + "; " + table + + " for " + rowKey + " = " + rowId + " (deleted?)"); + } + Assert.inRange(count, 0, 1); + return (count >= 0); + } +} diff --git a/src/com/android/messaging/datamodel/BugleNotifications.java b/src/com/android/messaging/datamodel/BugleNotifications.java new file mode 100644 index 0000000..b796e73 --- /dev/null +++ b/src/com/android/messaging/datamodel/BugleNotifications.java @@ -0,0 +1,1221 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.graphics.BitmapFactory; +import android.graphics.Typeface; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Bundle; +import android.os.SystemClock; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.NotificationCompat.WearableExtender; +import android.support.v4.app.NotificationManagerCompat; +import android.support.v4.app.RemoteInput; +import android.support.v4.util.SimpleArrayMap; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.text.style.StyleSpan; +import android.text.style.TextAppearanceSpan; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.MessageNotificationState.BundledMessageNotificationState; +import com.android.messaging.datamodel.MessageNotificationState.ConversationLineInfo; +import com.android.messaging.datamodel.MessageNotificationState.MultiConversationNotificationState; +import com.android.messaging.datamodel.MessageNotificationState.MultiMessageNotificationState; +import com.android.messaging.datamodel.action.MarkAsReadAction; +import com.android.messaging.datamodel.action.MarkAsSeenAction; +import com.android.messaging.datamodel.action.RedownloadMmsAction; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.media.AvatarRequestDescriptor; +import com.android.messaging.datamodel.media.ImageResource; +import com.android.messaging.datamodel.media.MediaRequest; +import com.android.messaging.datamodel.media.MediaResourceManager; +import com.android.messaging.datamodel.media.MessagePartVideoThumbnailRequestDescriptor; +import com.android.messaging.datamodel.media.UriImageRequestDescriptor; +import com.android.messaging.datamodel.media.VideoThumbnailRequest; +import com.android.messaging.sms.MmsSmsUtils; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.BuglePrefsKeys; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.ConversationIdSet; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.NotificationPlayer; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PendingIntentConstants; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.RingtoneUtil; +import com.android.messaging.util.ThreadUtil; +import com.android.messaging.util.UriUtil; + +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Handle posting, updating and removing all conversation notifications. + * + * There are currently two main classes of notification and their rules:

+ * 1) Messages - {@link MessageNotificationState}. Only one message notification. + * Unread messages across senders and conversations are coalesced.

+ * 2) Failed Messages - {@link MessageNotificationState#checkFailedMesages } Only one failed + * message. Multiple failures are coalesced.

+ * + * To add a new class of notifications, subclass the NotificationState and add commands which + * create one and pass into general creation function. + * + */ +public class BugleNotifications { + // Logging + public static final String TAG = LogUtil.BUGLE_NOTIFICATIONS_TAG; + + // Constants to use for update. + public static final int UPDATE_NONE = 0; + public static final int UPDATE_MESSAGES = 1; + public static final int UPDATE_ERRORS = 2; + public static final int UPDATE_ALL = UPDATE_MESSAGES + UPDATE_ERRORS; + + // Constants for notification type used for audio and vibration settings. + public static final int LOCAL_SMS_NOTIFICATION = 0; + + private static final String SMS_NOTIFICATION_TAG = ":sms:"; + private static final String SMS_ERROR_NOTIFICATION_TAG = ":error:"; + + private static final String WEARABLE_COMPANION_APP_PACKAGE = "com.google.android.wearable.app"; + + private static final Set sPendingNotifications = + new HashSet(); + + private static int sWearableImageWidth; + private static int sWearableImageHeight; + private static int sIconWidth; + private static int sIconHeight; + + private static boolean sInitialized = false; + + private static final Object mLock = new Object(); + + // sLastMessageDingTime is a map between a conversation id and a time. It's used to keep track + // of the time we last dinged a message for this conversation. When messages are coming in + // at flurry, we don't want to over-ding the user. + private static final SimpleArrayMap sLastMessageDingTime = + new SimpleArrayMap(); + private static int sTimeBetweenDingsMs; + + /** + * This is the volume at which to play the observable-conversation notification sound, + * expressed as a fraction of the system notification volume. + */ + private static final float OBSERVABLE_CONVERSATION_NOTIFICATION_VOLUME = 0.25f; + + /** + * Entry point for posting notifications. + * Don't call this on the UI thread. + * @param silent If true, no ring will be played. If false, checks global settings before + * playing a ringtone + * @param coverage Indicates which notification types should be checked. Valid values are + * UPDATE_NONE, UPDATE_MESSAGES, UPDATE_ERRORS, or UPDATE_ALL + */ + public static void update(final boolean silent, final int coverage) { + update(silent, null /* conversationId */, coverage); + } + + /** + * Entry point for posting notifications. + * Don't call this on the UI thread. + * @param silent If true, no ring will be played. If false, checks global settings before + * playing a ringtone + * @param conversationId Conversation ID where a new message was received + * @param coverage Indicates which notification types should be checked. Valid values are + * UPDATE_NONE, UPDATE_MESSAGES, UPDATE_ERRORS, or UPDATE_ALL + */ + public static void update(final boolean silent, final String conversationId, + final int coverage) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Update: silent = " + silent + + " conversationId = " + conversationId + + " coverage = " + coverage); + } + Assert.isNotMainThread(); + checkInitialized(); + + if (!shouldNotify()) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Notifications disabled"); + } + cancel(PendingIntentConstants.SMS_NOTIFICATION_ID); + return; + } else { + if ((coverage & UPDATE_MESSAGES) != 0) { + createMessageNotification(silent, conversationId); + } + } + if ((coverage & UPDATE_ERRORS) != 0) { + MessageNotificationState.checkFailedMessages(); + } + } + + /** + * Cancel all notifications of a certain type. + * + * @param type Message or error notifications from Constants. + */ + private static synchronized void cancel(final int type) { + cancel(type, null, false); + } + + /** + * Cancel all notifications of a certain type. + * + * @param type Message or error notifications from Constants. + * @param conversationId If set, cancel the notification for this + * conversation only. For message notifications, this only works + * if the notifications are bundled (group children). + * @param isBundledNotification True if this notification is part of a + * notification bundle. This only applies to message notifications, + * which are bundled together with other message notifications. + */ + private static synchronized void cancel(final int type, final String conversationId, + final boolean isBundledNotification) { + final String notificationTag = buildNotificationTag(type, conversationId, + isBundledNotification); + final NotificationManagerCompat notificationManager = + NotificationManagerCompat.from(Factory.get().getApplicationContext()); + + // Find all pending notifications and cancel them. + synchronized (sPendingNotifications) { + final Iterator iter = sPendingNotifications.iterator(); + while (iter.hasNext()) { + final NotificationState notifState = iter.next(); + if (notifState.mType == type) { + notifState.mCanceled = true; + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Canceling pending notification"); + } + iter.remove(); + } + } + } + notificationManager.cancel(notificationTag, type); + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "Canceled notifications of type " + type); + } + + // Message notifications for multiple conversations can be grouped together (see comment in + // createMessageNotification). We need to do bookkeeping to track the current set of + // notification group children, including removing them when we cancel notifications). + if (type == PendingIntentConstants.SMS_NOTIFICATION_ID) { + final Context context = Factory.get().getApplicationContext(); + final ConversationIdSet groupChildIds = getGroupChildIds(context); + + if (groupChildIds != null && groupChildIds.size() > 0) { + // If a conversation is specified, remove just that notification. Otherwise, + // we're removing the group summary so clear all children. + if (conversationId != null) { + groupChildIds.remove(conversationId); + writeGroupChildIds(context, groupChildIds); + } else { + cancelStaleGroupChildren(groupChildIds, null); + // We'll update the group children preference as we cancel each child, + // so we don't need to do it here. + } + } + } + } + + /** + * Cancels stale notifications from the currently active group of + * notifications. If the {@code state} parameter is an instance of + * {@link MultiConversationNotificationState} it represents a new + * notification group. This method will cancel any notifications that were + * in the old group, but not the new one. If the new notification is not a + * group, then all existing grouped notifications are cancelled. + * + * @param previousGroupChildren Conversation ids for the active notification + * group + * @param state New notification state + */ + private static void cancelStaleGroupChildren(final ConversationIdSet previousGroupChildren, + final NotificationState state) { + final ConversationIdSet newChildren = new ConversationIdSet(); + if (state instanceof MultiConversationNotificationState) { + for (final NotificationState child : + ((MultiConversationNotificationState) state).mChildren) { + if (child.mConversationIds != null) { + newChildren.add(child.mConversationIds.first()); + } + } + } + for (final String childConversationId : previousGroupChildren) { + if (!newChildren.contains(childConversationId)) { + cancel(PendingIntentConstants.SMS_NOTIFICATION_ID, childConversationId, true); + } + } + } + + /** + * Returns {@code true} if incoming notifications should display a + * notification, {@code false} otherwise. + * + * @return true if the notification should occur + */ + private static boolean shouldNotify() { + // If we're not the default sms app, don't put up any notifications. + if (!PhoneUtils.getDefault().isDefaultSmsApp()) { + return false; + } + + // Now check prefs (i.e. settings) to see if the user turned off notifications. + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + final Context context = Factory.get().getApplicationContext(); + final String prefKey = context.getString(R.string.notifications_enabled_pref_key); + final boolean defaultValue = context.getResources().getBoolean( + R.bool.notifications_enabled_pref_default); + return prefs.getBoolean(prefKey, defaultValue); + } + + /** + * Returns {@code true} if incoming notifications for the given {@link NotificationState} + * should vibrate the device, {@code false} otherwise. + * + * @return true if vibration should be used + */ + public static boolean shouldVibrate(final NotificationState state) { + // The notification should vibrate if the global setting is turned on AND + // the per-conversation setting is turned on (default). + if (!state.getNotificationVibrate()) { + return false; + } else { + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + final Context context = Factory.get().getApplicationContext(); + final String prefKey = context.getString(R.string.notification_vibration_pref_key); + final boolean defaultValue = context.getResources().getBoolean( + R.bool.notification_vibration_pref_default); + return prefs.getBoolean(prefKey, defaultValue); + } + } + + private static Uri getNotificationRingtoneUriForConversationId(final String conversationId) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final ConversationListItemData convData = + ConversationListItemData.getExistingConversation(db, conversationId); + return RingtoneUtil.getNotificationRingtoneUri( + convData != null ? convData.getNotificationSoundUri() : null); + } + + /** + * Returns a unique tag to identify a notification. + * + * @param name The tag name (in practice, the type) + * @param conversationId The conversation id (optional) + */ + private static String buildNotificationTag(final String name, + final String conversationId) { + final Context context = Factory.get().getApplicationContext(); + if (conversationId != null) { + return context.getPackageName() + name + ":" + conversationId; + } else { + return context.getPackageName() + name; + } + } + + /** + * Returns a unique tag to identify a notification. + *

+ * This delegates to + * {@link #buildNotificationTag(int, String, boolean)} and can be + * used when the notification is never bundled (e.g. error notifications). + */ + static String buildNotificationTag(final int type, final String conversationId) { + return buildNotificationTag(type, conversationId, false /* bundledNotification */); + } + + /** + * Returns a unique tag to identify a notification. + * + * @param type One of the constants in {@link PendingIntentConstants} + * @param conversationId The conversation id (where applicable) + * @param bundledNotification Set to true if this notification will be + * bundled together with other notifications (e.g. on a wearable + * device). + */ + static String buildNotificationTag(final int type, final String conversationId, + final boolean bundledNotification) { + String tag = null; + switch(type) { + case PendingIntentConstants.SMS_NOTIFICATION_ID: + if (bundledNotification) { + tag = buildNotificationTag(SMS_NOTIFICATION_TAG, conversationId); + } else { + tag = buildNotificationTag(SMS_NOTIFICATION_TAG, null); + } + break; + case PendingIntentConstants.MSG_SEND_ERROR: + tag = buildNotificationTag(SMS_ERROR_NOTIFICATION_TAG, null); + break; + } + return tag; + } + + private static void checkInitialized() { + if (!sInitialized) { + final Resources resources = Factory.get().getApplicationContext().getResources(); + sWearableImageWidth = resources.getDimensionPixelSize( + R.dimen.notification_wearable_image_width); + sWearableImageHeight = resources.getDimensionPixelSize( + R.dimen.notification_wearable_image_height); + sIconHeight = (int) resources.getDimension( + android.R.dimen.notification_large_icon_height); + sIconWidth = + (int) resources.getDimension(android.R.dimen.notification_large_icon_width); + + sInitialized = true; + } + } + + private static void processAndSend(final NotificationState state, final boolean silent, + final boolean softSound) { + final Context context = Factory.get().getApplicationContext(); + final NotificationCompat.Builder notifBuilder = new NotificationCompat.Builder(context); + notifBuilder.setCategory(Notification.CATEGORY_MESSAGE); + // TODO: Need to fix this for multi conversation notifications to rate limit dings. + final String conversationId = state.mConversationIds.first(); + + + final Uri ringtoneUri = RingtoneUtil.getNotificationRingtoneUri(state.getRingtoneUri()); + // If the notification's conversation is currently observable (focused or in the + // conversation list), then play a notification beep at a low volume and don't display an + // actual notification. + if (softSound) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "processAndSend: fromConversationId == " + + "sCurrentlyDisplayedConversationId so NOT showing notification," + + " but playing soft sound. conversationId: " + conversationId); + } + playObservableConversationNotificationSound(ringtoneUri); + return; + } + state.mBaseRequestCode = state.mType; + + // Set the delete intent (except for bundled wearable notifications, which are dismissed + // as a group, either from the wearable or when the summary notification is dismissed from + // the host device). + if (!(state instanceof BundledMessageNotificationState)) { + final PendingIntent clearIntent = state.getClearIntent(); + notifBuilder.setDeleteIntent(clearIntent); + } + + updateBuilderAudioVibrate(state, notifBuilder, silent, ringtoneUri, conversationId); + + // Set the content intent + PendingIntent destinationIntent; + if (state.mConversationIds.size() > 1) { + // We have notifications for multiple conversation, go to the conversation list. + destinationIntent = UIIntents.get() + .getPendingIntentForConversationListActivity(context); + } else { + // We have a single conversation, go directly to that conversation. + destinationIntent = UIIntents.get() + .getPendingIntentForConversationActivity(context, + state.mConversationIds.first(), + null /*draft*/); + } + notifBuilder.setContentIntent(destinationIntent); + + // TODO: set based on contact coming from a favorite. + notifBuilder.setPriority(state.getPriority()); + + // Save the state of the notification in-progress so when the avatar is loaded, + // we can continue building the notification. + final NotificationCompat.Style notifStyle = state.build(notifBuilder); + state.mNotificationBuilder = notifBuilder; + state.mNotificationStyle = notifStyle; + if (!state.mPeople.isEmpty()) { + final Bundle people = new Bundle(); + people.putStringArray(NotificationCompat.EXTRA_PEOPLE, + state.mPeople.toArray(new String[state.mPeople.size()])); + notifBuilder.addExtras(people); + } + + if (state.mParticipantAvatarsUris != null) { + final Uri avatarUri = state.mParticipantAvatarsUris.get(0); + final AvatarRequestDescriptor descriptor = new AvatarRequestDescriptor(avatarUri, + sIconWidth, sIconHeight, OsUtil.isAtLeastL()); + final MediaRequest imageRequest = descriptor.buildSyncMediaRequest( + context); + + synchronized (sPendingNotifications) { + sPendingNotifications.add(state); + } + + // Synchronously load the avatar. + final ImageResource avatarImage = + MediaResourceManager.get().requestMediaResourceSync(imageRequest); + if (avatarImage != null) { + ImageResource avatarHiRes = null; + try { + if (isWearCompanionAppInstalled()) { + // For Wear users, we need to request a high-res avatar image to use as the + // notification card background. If the sender has a contact photo, we'll + // request the display photo from the Contacts provider. Otherwise, we ask + // the local content provider for a hi-res version of the generic avatar + // (e.g. letter with colored background). + avatarHiRes = requestContactDisplayPhoto(context, + getDisplayPhotoUri(avatarUri)); + if (avatarHiRes == null) { + final AvatarRequestDescriptor hiResDesc = + new AvatarRequestDescriptor(avatarUri, + sWearableImageWidth, + sWearableImageHeight, + false /* cropToCircle */, + true /* isWearBackground */); + avatarHiRes = MediaResourceManager.get().requestMediaResourceSync( + hiResDesc.buildSyncMediaRequest(context)); + } + } + + // We have to make copies of the bitmaps to hand to the NotificationManager + // because the bitmap in the ImageResource is managed and will automatically + // get released. + Bitmap avatarBitmap = Bitmap.createBitmap(avatarImage.getBitmap()); + Bitmap avatarHiResBitmap = (avatarHiRes != null) ? + Bitmap.createBitmap(avatarHiRes.getBitmap()) : null; + sendNotification(state, avatarBitmap, avatarHiResBitmap); + return; + } finally { + avatarImage.release(); + if (avatarHiRes != null) { + avatarHiRes.release(); + } + } + } + } + // We have no avatar. Post the notification anyway. + sendNotification(state, null, null); + } + + /** + * Returns the thumbnailUri from the avatar URI, or null if avatar URI does not have thumbnail. + */ + private static Uri getThumbnailUri(final Uri avatarUri) { + Uri localUri = null; + final String avatarType = AvatarUriUtil.getAvatarType(avatarUri); + if (TextUtils.equals(avatarType, AvatarUriUtil.TYPE_LOCAL_RESOURCE_URI)) { + localUri = AvatarUriUtil.getPrimaryUri(avatarUri); + } else if (UriUtil.isLocalResourceUri(avatarUri)) { + localUri = avatarUri; + } + if (localUri != null && localUri.getAuthority().equals(ContactsContract.AUTHORITY)) { + // Contact photos are of the form: content://com.android.contacts/contacts/123/photo + final List pathParts = localUri.getPathSegments(); + if (pathParts.size() == 3 && + pathParts.get(2).equals(Contacts.Photo.CONTENT_DIRECTORY)) { + return localUri; + } + } + return null; + } + + /** + * Returns the displayPhotoUri from the avatar URI, or null if avatar URI + * does not have a displayPhotoUri. + */ + private static Uri getDisplayPhotoUri(final Uri avatarUri) { + final Uri thumbnailUri = getThumbnailUri(avatarUri); + if (thumbnailUri == null) { + return null; + } + final List originalPaths = thumbnailUri.getPathSegments(); + final int originalPathsSize = originalPaths.size(); + final StringBuilder newPathBuilder = new StringBuilder(); + // Change content://com.android.contacts/contacts("_corp")/123/photo to + // content://com.android.contacts/contacts("_corp")/123/display_photo + for (int i = 0; i < originalPathsSize; i++) { + newPathBuilder.append('/'); + if (i == 2) { + newPathBuilder.append(ContactsContract.Contacts.Photo.DISPLAY_PHOTO); + } else { + newPathBuilder.append(originalPaths.get(i)); + } + } + return thumbnailUri.buildUpon().path(newPathBuilder.toString()).build(); + } + + private static ImageResource requestContactDisplayPhoto(final Context context, + final Uri displayPhotoUri) { + final UriImageRequestDescriptor bgDescriptor = + new UriImageRequestDescriptor(displayPhotoUri, + sWearableImageWidth, + sWearableImageHeight, + false, /* allowCompression */ + true, /* isStatic */ + false /* cropToCircle */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + return MediaResourceManager.get().requestMediaResourceSync( + bgDescriptor.buildSyncMediaRequest(context)); + } + + private static void createMessageNotification(final boolean silent, + final String conversationId) { + final NotificationState state = MessageNotificationState.getNotificationState(); + final boolean softSound = DataModel.get().isNewMessageObservable(conversationId); + if (state == null) { + cancel(PendingIntentConstants.SMS_NOTIFICATION_ID); + if (softSound && !TextUtils.isEmpty(conversationId)) { + final Uri ringtoneUri = getNotificationRingtoneUriForConversationId(conversationId); + playObservableConversationNotificationSound(ringtoneUri); + } + return; + } + processAndSend(state, silent, softSound); + + // The rest of the logic here is for supporting Android Wear devices, specifically for when + // we are notifying about multiple conversations. In that case, the Inbox-style summary + // notification (which we already processed above) appears on the phone (as it always has), + // but wearables show per-conversation notifications, bundled together in a group. + + // It is valid to replace a notification group with another group with fewer conversations, + // or even with one notification for a single conversation. In either case, we need to + // explicitly cancel any children from the old group which are not being notified about now. + final Context context = Factory.get().getApplicationContext(); + final ConversationIdSet oldGroupChildIds = getGroupChildIds(context); + if (oldGroupChildIds != null && oldGroupChildIds.size() > 0) { + cancelStaleGroupChildren(oldGroupChildIds, state); + } + + // Send per-conversation notifications (if there are multiple conversations). + final ConversationIdSet groupChildIds = new ConversationIdSet(); + if (state instanceof MultiConversationNotificationState) { + for (final NotificationState child : + ((MultiConversationNotificationState) state).mChildren) { + processAndSend(child, true /* silent */, softSound); + if (child.mConversationIds != null) { + groupChildIds.add(child.mConversationIds.first()); + } + } + } + + // Record the new set of group children. + writeGroupChildIds(context, groupChildIds); + } + + private static void updateBuilderAudioVibrate(final NotificationState state, + final NotificationCompat.Builder notifBuilder, final boolean silent, + final Uri ringtoneUri, final String conversationId) { + int defaults = Notification.DEFAULT_LIGHTS; + if (!silent) { + final BuglePrefs prefs = Factory.get().getApplicationPrefs(); + final long latestNotificationTimestamp = prefs.getLong( + BuglePrefsKeys.LATEST_NOTIFICATION_MESSAGE_TIMESTAMP, Long.MIN_VALUE); + final long latestReceivedTimestamp = state.getLatestReceivedTimestamp(); + prefs.putLong( + BuglePrefsKeys.LATEST_NOTIFICATION_MESSAGE_TIMESTAMP, + Math.max(latestNotificationTimestamp, latestReceivedTimestamp)); + if (latestReceivedTimestamp > latestNotificationTimestamp) { + synchronized (mLock) { + // Find out the last time we dinged for this conversation + Long lastTime = sLastMessageDingTime.get(conversationId); + if (sTimeBetweenDingsMs == 0) { + sTimeBetweenDingsMs = BugleGservices.get().getInt( + BugleGservicesKeys.NOTIFICATION_TIME_BETWEEN_RINGS_SECONDS, + BugleGservicesKeys.NOTIFICATION_TIME_BETWEEN_RINGS_SECONDS_DEFAULT) * + 1000; + } + if (lastTime == null + || SystemClock.elapsedRealtime() - lastTime > sTimeBetweenDingsMs) { + sLastMessageDingTime.put(conversationId, SystemClock.elapsedRealtime()); + notifBuilder.setSound(ringtoneUri); + if (shouldVibrate(state)) { + defaults |= Notification.DEFAULT_VIBRATE; + } + } + } + } + } + notifBuilder.setDefaults(defaults); + } + + // TODO: this doesn't seem to be defined in NotificationCompat yet. Temporarily + // define it here until it makes its way from Notification -> NotificationCompat. + /** + * Notification category: incoming direct message (SMS, instant message, etc.). + */ + private static final String CATEGORY_MESSAGE = "msg"; + + private static void sendNotification(final NotificationState notificationState, + final Bitmap avatarIcon, final Bitmap avatarHiRes) { + final Context context = Factory.get().getApplicationContext(); + if (notificationState.mCanceled) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "sendNotification: Notification already cancelled; dropping it"); + } + return; + } + + synchronized (sPendingNotifications) { + if (sPendingNotifications.contains(notificationState)) { + sPendingNotifications.remove(notificationState); + } + } + + notificationState.mNotificationBuilder + .setSmallIcon(notificationState.getIcon()) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setColor(context.getResources().getColor(R.color.notification_accent_color)) +// .setPublicVersion(null) // TODO: when/if we ever support different + // text on the lockscreen, instead of "contents hidden" + .setCategory(CATEGORY_MESSAGE); + + if (avatarIcon != null) { + notificationState.mNotificationBuilder.setLargeIcon(avatarIcon); + } + + if (notificationState.mParticipantContactUris != null && + notificationState.mParticipantContactUris.size() > 0) { + for (final Uri contactUri : notificationState.mParticipantContactUris) { + notificationState.mNotificationBuilder.addPerson(contactUri.toString()); + } + } + + final Uri attachmentUri = notificationState.getAttachmentUri(); + final String attachmentType = notificationState.getAttachmentType(); + Bitmap attachmentBitmap = null; + + // For messages with photo/video attachment, request an image to show in the notification. + if (attachmentUri != null && notificationState.mNotificationStyle != null && + (notificationState.mNotificationStyle instanceof + NotificationCompat.BigPictureStyle) && + (ContentType.isImageType(attachmentType) || + ContentType.isVideoType(attachmentType))) { + final boolean isVideo = ContentType.isVideoType(attachmentType); + + MediaRequest imageRequest; + if (isVideo) { + Assert.isTrue(VideoThumbnailRequest.shouldShowIncomingVideoThumbnails()); + final MessagePartVideoThumbnailRequestDescriptor videoDescriptor = + new MessagePartVideoThumbnailRequestDescriptor(attachmentUri); + imageRequest = videoDescriptor.buildSyncMediaRequest(context); + } else { + final UriImageRequestDescriptor imageDescriptor = + new UriImageRequestDescriptor(attachmentUri, + sWearableImageWidth, + sWearableImageHeight, + false /* allowCompression */, + true /* isStatic */, + false /* cropToCircle */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + imageRequest = imageDescriptor.buildSyncMediaRequest(context); + } + final ImageResource imageResource = + MediaResourceManager.get().requestMediaResourceSync(imageRequest); + if (imageResource != null) { + try { + // Copy the bitmap, because the one in the ImageResource is managed by + // MediaResourceManager. + Bitmap imageResourceBitmap = imageResource.getBitmap(); + Config config = imageResourceBitmap.getConfig(); + + // Make sure our bitmap has a valid format. + if (config == null) { + config = Bitmap.Config.ARGB_8888; + } + attachmentBitmap = imageResourceBitmap.copy(config, true); + } finally { + imageResource.release(); + } + } + } + + fireOffNotification(notificationState, attachmentBitmap, avatarIcon, avatarHiRes); + } + + private static void fireOffNotification(final NotificationState notificationState, + final Bitmap attachmentBitmap, final Bitmap avatarBitmap, Bitmap avatarHiResBitmap) { + if (notificationState.mCanceled) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Firing off notification, but notification already canceled"); + } + return; + } + + final Context context = Factory.get().getApplicationContext(); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "MMS picture loaded, bitmap: " + attachmentBitmap); + } + + final NotificationCompat.Builder notifBuilder = notificationState.mNotificationBuilder; + notifBuilder.setStyle(notificationState.mNotificationStyle); + notifBuilder.setColor(context.getResources().getColor(R.color.notification_accent_color)); + + final WearableExtender wearableExtender = new WearableExtender(); + setWearableGroupOptions(notifBuilder, notificationState); + + if (avatarHiResBitmap != null) { + wearableExtender.setBackground(avatarHiResBitmap); + } else if (avatarBitmap != null) { + // Nothing to do here; we already set avatarBitmap as the notification icon + } else { + final Bitmap defaultBackground = BitmapFactory.decodeResource( + context.getResources(), R.drawable.bg_sms); + wearableExtender.setBackground(defaultBackground); + } + + if (notificationState instanceof MultiMessageNotificationState) { + if (attachmentBitmap != null) { + // When we've got a picture attachment, we do some switcheroo trickery. When + // the notification is expanded, we show the picture as a bigPicture. The small + // icon shows the sender's avatar. When that same notification is collapsed, the + // picture is shown in the location where the avatar is normally shown. The lines + // below make all that happen. + + // Here we're taking the picture attachment and making a small, scaled, center + // cropped version of the picture we can stuff into the place where the avatar + // goes when the notification is collapsed. + final Bitmap smallBitmap = ImageUtils.scaleCenterCrop(attachmentBitmap, sIconWidth, + sIconHeight); + ((NotificationCompat.BigPictureStyle) notificationState.mNotificationStyle) + .bigPicture(attachmentBitmap) + .bigLargeIcon(avatarBitmap); + notificationState.mNotificationBuilder.setLargeIcon(smallBitmap); + + // Add a wearable page with no visible card so you can more easily see the photo. + final NotificationCompat.Builder photoPageNotifBuilder = + new NotificationCompat.Builder(Factory.get().getApplicationContext()); + final WearableExtender photoPageWearableExtender = new WearableExtender(); + photoPageWearableExtender.setHintShowBackgroundOnly(true); + if (attachmentBitmap != null) { + final Bitmap wearBitmap = ImageUtils.scaleCenterCrop(attachmentBitmap, + sWearableImageWidth, sWearableImageHeight); + photoPageWearableExtender.setBackground(wearBitmap); + } + photoPageNotifBuilder.extend(photoPageWearableExtender); + wearableExtender.addPage(photoPageNotifBuilder.build()); + } + + maybeAddWearableConversationLog(wearableExtender, + (MultiMessageNotificationState) notificationState); + addDownloadMmsAction(notifBuilder, wearableExtender, notificationState); + addWearableVoiceReplyAction(wearableExtender, notificationState); + } + + // Apply the wearable options and build & post the notification + notifBuilder.extend(wearableExtender); + doNotify(notifBuilder.build(), notificationState); + } + + private static void setWearableGroupOptions(final NotificationCompat.Builder notifBuilder, + final NotificationState notificationState) { + final String groupKey = "groupkey"; + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Group key (for wearables)=" + groupKey); + } + if (notificationState instanceof MultiConversationNotificationState) { + notifBuilder.setGroup(groupKey).setGroupSummary(true); + } else if (notificationState instanceof BundledMessageNotificationState) { + final int order = ((BundledMessageNotificationState) notificationState).mGroupOrder; + // Convert the order to a zero-padded string ("00", "01", "02", etc). + // The Wear library orders notifications within a bundle lexicographically + // by the sort key, hence the need for zeroes to preserve the ordering. + final String sortKey = String.format(Locale.US, "%02d", order); + notifBuilder.setGroup(groupKey).setSortKey(sortKey); + } + } + + private static void maybeAddWearableConversationLog( + final WearableExtender wearableExtender, + final MultiMessageNotificationState notificationState) { + if (!isWearCompanionAppInstalled()) { + return; + } + final String convId = notificationState.mConversationIds.first(); + ConversationLineInfo convInfo = notificationState.mConvList.mConvInfos.get(0); + final Notification page = MessageNotificationState.buildConversationPageForWearable( + convId, + convInfo.mParticipantCount); + if (page != null) { + wearableExtender.addPage(page); + } + } + + private static void addWearableVoiceReplyAction( + final WearableExtender wearableExtender, final NotificationState notificationState) { + if (!(notificationState instanceof MultiMessageNotificationState)) { + return; + } + final MultiMessageNotificationState multiMessageNotificationState = + (MultiMessageNotificationState) notificationState; + final Context context = Factory.get().getApplicationContext(); + + final String conversationId = notificationState.mConversationIds.first(); + final ConversationLineInfo convInfo = + multiMessageNotificationState.mConvList.mConvInfos.get(0); + final String selfId = convInfo.mSelfParticipantId; + + final boolean requiresMms = + MmsSmsUtils.getRequireMmsForEmailAddress( + convInfo.mIncludeEmailAddress, convInfo.mSubId) || + (convInfo.mIsGroup && MmsUtils.groupMmsEnabled(convInfo.mSubId)); + + final int requestCode = multiMessageNotificationState.getReplyIntentRequestCode(); + final PendingIntent replyPendingIntent = UIIntents.get() + .getPendingIntentForSendingMessageToConversation(context, + conversationId, selfId, requiresMms, requestCode); + + final int replyLabelRes = requiresMms ? R.string.notification_reply_via_mms : + R.string.notification_reply_via_sms; + + final NotificationCompat.Action.Builder actionBuilder = + new NotificationCompat.Action.Builder(R.drawable.ic_wear_reply, + context.getString(replyLabelRes), replyPendingIntent); + final String[] choices = context.getResources().getStringArray( + R.array.notification_reply_choices); + final RemoteInput remoteInput = new RemoteInput.Builder(Intent.EXTRA_TEXT).setLabel( + context.getString(R.string.notification_reply_prompt)). + setChoices(choices) + .build(); + actionBuilder.addRemoteInput(remoteInput); + wearableExtender.addAction(actionBuilder.build()); + } + + private static void addDownloadMmsAction(final NotificationCompat.Builder notifBuilder, + final WearableExtender wearableExtender, final NotificationState notificationState) { + if (!(notificationState instanceof MultiMessageNotificationState)) { + return; + } + final MultiMessageNotificationState multiMessageNotificationState = + (MultiMessageNotificationState) notificationState; + final ConversationLineInfo convInfo = + multiMessageNotificationState.mConvList.mConvInfos.get(0); + if (!convInfo.getDoesLatestMessageNeedDownload()) { + return; + } + final String messageId = convInfo.getLatestMessageId(); + if (messageId == null) { + // No message Id, no download for you + return; + } + final Context context = Factory.get().getApplicationContext(); + final PendingIntent downloadPendingIntent = + RedownloadMmsAction.getPendingIntentForRedownloadMms(context, messageId); + + final NotificationCompat.Action.Builder actionBuilder = + new NotificationCompat.Action.Builder(R.drawable.ic_file_download_light, + context.getString(R.string.notification_download_mms), + downloadPendingIntent); + final NotificationCompat.Action downloadAction = actionBuilder.build(); + notifBuilder.addAction(downloadAction); + + // Support the action on a wearable device as well + wearableExtender.addAction(downloadAction); + } + + private static synchronized void doNotify(final Notification notification, + final NotificationState notificationState) { + if (notification == null) { + return; + } + final int type = notificationState.mType; + final ConversationIdSet conversationIds = notificationState.mConversationIds; + final boolean isBundledNotification = + (notificationState instanceof BundledMessageNotificationState); + + // Mark the notification as finished + notificationState.mCanceled = true; + + final NotificationManagerCompat notificationManager = + NotificationManagerCompat.from(Factory.get().getApplicationContext()); + // Only need conversationId for tags with a single conversation. + String conversationId = null; + if (conversationIds != null && conversationIds.size() == 1) { + conversationId = conversationIds.first(); + } + final String notificationTag = buildNotificationTag(type, + conversationId, isBundledNotification); + + notification.flags |= Notification.FLAG_AUTO_CANCEL; + notification.defaults |= Notification.DEFAULT_LIGHTS; + + notificationManager.notify(notificationTag, type, notification); + + LogUtil.i(TAG, "Notifying for conversation " + conversationId + "; " + + "tag = " + notificationTag + ", type = " + type); + } + + // This is the message string used in each line of an inboxStyle notification. + // TODO: add attachment type + static CharSequence formatInboxMessage(final String sender, + final CharSequence message, final Uri attachmentUri, final String attachmentType) { + final Context context = Factory.get().getApplicationContext(); + final TextAppearanceSpan notificationSenderSpan = new TextAppearanceSpan( + context, R.style.NotificationSenderText); + + final TextAppearanceSpan notificationTertiaryText = new TextAppearanceSpan( + context, R.style.NotificationTertiaryText); + + final SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(); + if (!TextUtils.isEmpty(sender)) { + spannableStringBuilder.append(sender); + spannableStringBuilder.setSpan(notificationSenderSpan, 0, sender.length(), 0); + } + final String separator = context.getString(R.string.notification_separator); + + if (!TextUtils.isEmpty(message)) { + if (spannableStringBuilder.length() > 0) { + spannableStringBuilder.append(separator); + } + final int start = spannableStringBuilder.length(); + spannableStringBuilder.append(message); + spannableStringBuilder.setSpan(notificationTertiaryText, start, + start + message.length(), 0); + } + if (attachmentUri != null) { + if (spannableStringBuilder.length() > 0) { + spannableStringBuilder.append(separator); + } + spannableStringBuilder.append(formatAttachmentTag(null, attachmentType)); + } + return spannableStringBuilder; + } + + protected static CharSequence buildColonSeparatedMessage( + final String title, final CharSequence content, final Uri attachmentUri, + final String attachmentType) { + return buildBoldedMessage(title, content, attachmentUri, attachmentType, + R.string.notification_ticker_separator); + } + + protected static CharSequence buildSpaceSeparatedMessage( + final String title, final CharSequence content, final Uri attachmentUri, + final String attachmentType) { + return buildBoldedMessage(title, content, attachmentUri, attachmentType, + R.string.notification_space_separator); + } + + /** + * buildBoldedMessage - build a formatted message where the title is bold, there's a + * separator, then the message. + */ + private static CharSequence buildBoldedMessage( + final String title, final CharSequence message, final Uri attachmentUri, + final String attachmentType, + final int separatorId) { + final Context context = Factory.get().getApplicationContext(); + final SpannableStringBuilder spanBuilder = new SpannableStringBuilder(); + + // Boldify the title (which is the sender's name) + if (!TextUtils.isEmpty(title)) { + spanBuilder.append(title); + spanBuilder.setSpan(new StyleSpan(Typeface.BOLD), 0, title.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (!TextUtils.isEmpty(message)) { + if (spanBuilder.length() > 0) { + spanBuilder.append(context.getString(separatorId)); + } + spanBuilder.append(message); + } + if (attachmentUri != null) { + if (spanBuilder.length() > 0) { + final String separator = context.getString(R.string.notification_separator); + spanBuilder.append(separator); + } + spanBuilder.append(formatAttachmentTag(null, attachmentType)); + } + return spanBuilder; + } + + static CharSequence formatAttachmentTag(final String author, final String attachmentType) { + final Context context = Factory.get().getApplicationContext(); + final TextAppearanceSpan notificationSecondaryText = new TextAppearanceSpan( + context, R.style.NotificationSecondaryText); + final SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(); + if (!TextUtils.isEmpty(author)) { + final TextAppearanceSpan notificationSenderSpan = new TextAppearanceSpan( + context, R.style.NotificationSenderText); + spannableStringBuilder.append(author); + spannableStringBuilder.setSpan(notificationSenderSpan, 0, author.length(), 0); + final String separator = context.getString(R.string.notification_separator); + spannableStringBuilder.append(separator); + } + final int start = spannableStringBuilder.length(); + // The default attachment type is an image, since that's what was originally + // supported. When there's no content type, assume it's an image. + int message = R.string.notification_picture; + if (ContentType.isAudioType(attachmentType)) { + message = R.string.notification_audio; + } else if (ContentType.isVideoType(attachmentType)) { + message = R.string.notification_video; + } else if (ContentType.isVCardType(attachmentType)) { + message = R.string.notification_vcard; + } + spannableStringBuilder.append(context.getText(message)); + spannableStringBuilder.setSpan(notificationSecondaryText, start, + spannableStringBuilder.length(), 0); + return spannableStringBuilder; + } + + /** + * Play the observable conversation notification sound (it's the regular notification sound, but + * played at half-volume) + */ + private static void playObservableConversationNotificationSound(final Uri ringtoneUri) { + final Context context = Factory.get().getApplicationContext(); + final AudioManager audioManager = (AudioManager) context + .getSystemService(Context.AUDIO_SERVICE); + final boolean silenced = + audioManager.getRingerMode() != AudioManager.RINGER_MODE_NORMAL; + if (silenced) { + return; + } + + final NotificationPlayer player = new NotificationPlayer(LogUtil.BUGLE_TAG); + player.play(ringtoneUri, false, + AudioManager.STREAM_NOTIFICATION, + OBSERVABLE_CONVERSATION_NOTIFICATION_VOLUME); + + // Stop the sound after five seconds to handle continuous ringtones + ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() { + @Override + public void run() { + player.stop(); + } + }, 5000); + } + + public static boolean isWearCompanionAppInstalled() { + boolean found = false; + try { + Factory.get().getApplicationContext().getPackageManager() + .getPackageInfo(WEARABLE_COMPANION_APP_PACKAGE, 0); + found = true; + } catch (final NameNotFoundException e) { + // Ignore; found is already false + } + return found; + } + + /** + * When we go to the conversation list, call this to mark all messages as seen. That means + * we won't show a notification again for the same message. + */ + public static void markAllMessagesAsSeen() { + MarkAsSeenAction.markAllAsSeen(); + resetLastMessageDing(null); // reset the ding timeout for all conversations + } + + /** + * When we open a particular conversation, call this to mark all messages as read. + */ + public static void markMessagesAsRead(final String conversationId) { + MarkAsReadAction.markAsRead(conversationId); + resetLastMessageDing(conversationId); + } + + /** + * Returns the conversation ids of all active, grouped notifications, or + * {code null} if no notifications are currently active and grouped. + */ + private static ConversationIdSet getGroupChildIds(final Context context) { + final String prefKey = context.getString(R.string.notifications_group_children_key); + final String groupChildIdsText = BuglePrefs.getApplicationPrefs().getString(prefKey, ""); + if (!TextUtils.isEmpty(groupChildIdsText)) { + return ConversationIdSet.createSet(groupChildIdsText); + } else { + return null; + } + } + + /** + * Records the conversation ids of the currently active grouped notifications. + */ + private static void writeGroupChildIds(final Context context, + final ConversationIdSet childIds) { + final ConversationIdSet oldChildIds = getGroupChildIds(context); + if (childIds.equals(oldChildIds)) { + return; + } + final String prefKey = context.getString(R.string.notifications_group_children_key); + BuglePrefs.getApplicationPrefs().putString(prefKey, childIds.getDelimitedString()); + } + + /** + * Reset the timer for a notification ding on a particular conversation or all conversations. + */ + public static void resetLastMessageDing(final String conversationId) { + synchronized (mLock) { + if (TextUtils.isEmpty(conversationId)) { + // reset all conversation dings + sLastMessageDingTime.clear(); + } else { + sLastMessageDingTime.remove(conversationId); + } + } + } + + public static void notifyEmergencySmsFailed(final String emergencyNumber, + final String conversationId) { + final Context context = Factory.get().getApplicationContext(); + + final CharSequence line1 = MessageNotificationState.applyWarningTextColor(context, + context.getString(R.string.notification_emergency_send_failure_line1, + emergencyNumber)); + final String line2 = context.getString(R.string.notification_emergency_send_failure_line2, + emergencyNumber); + final PendingIntent destinationIntent = UIIntents.get() + .getPendingIntentForConversationActivity(context, conversationId, null /* draft */); + + final NotificationCompat.Builder builder = new NotificationCompat.Builder(context); + builder.setTicker(line1) + .setContentTitle(line1) + .setContentText(line2) + .setStyle(new NotificationCompat.BigTextStyle(builder).bigText(line2)) + .setSmallIcon(R.drawable.ic_failed_light) + .setContentIntent(destinationIntent) + .setSound(UriUtil.getUriForResourceId(context, R.raw.message_failure)); + + final String tag = context.getPackageName() + ":emergency_sms_error"; + NotificationManagerCompat.from(context).notify( + tag, + PendingIntentConstants.MSG_SEND_ERROR, + builder.build()); + } +} + diff --git a/src/com/android/messaging/datamodel/BugleRecipientEntry.java b/src/com/android/messaging/datamodel/BugleRecipientEntry.java new file mode 100644 index 0000000..2a9e5ff --- /dev/null +++ b/src/com/android/messaging/datamodel/BugleRecipientEntry.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel; + +import android.net.Uri; +import android.text.TextUtils; + +import com.android.ex.chips.RecipientEntry; + +/** + * An extension of RecipientEntry for Bugle's use since Bugle uses phone numbers to identify + * participants / recipients instead of contact ids. This allows the user to send to multiple + * phone numbers of the same contact. + */ +public class BugleRecipientEntry extends RecipientEntry { + + protected BugleRecipientEntry(final int entryType, final String displayName, + final String destination, final int destinationType, final String destinationLabel, + final long contactId, final Long directoryId, final long dataId, + final Uri photoThumbnailUri, final boolean isFirstLevel, final boolean isValid, + final String lookupKey) { + super(entryType, displayName, destination, destinationType, destinationLabel, contactId, + directoryId, dataId, photoThumbnailUri, isFirstLevel, isValid, lookupKey); + } + + public static BugleRecipientEntry constructTopLevelEntry(final String displayName, + final int displayNameSource, final String destination, final int destinationType, + final String destinationLabel, final long contactId, final Long directoryId, + final long dataId, final String thumbnailUriAsString, final boolean isValid, + final String lookupKey) { + return new BugleRecipientEntry(ENTRY_TYPE_PERSON, displayName, destination, destinationType, + destinationLabel, contactId, directoryId, dataId, (thumbnailUriAsString != null + ? Uri.parse(thumbnailUriAsString) : null), true, isValid, lookupKey); + } + + public static BugleRecipientEntry constructSecondLevelEntry(final String displayName, + final int displayNameSource, final String destination, final int destinationType, + final String destinationLabel, final long contactId, final Long directoryId, + final long dataId, final String thumbnailUriAsString, final boolean isValid, + final String lookupKey) { + return new BugleRecipientEntry(ENTRY_TYPE_PERSON, displayName, destination, destinationType, + destinationLabel, contactId, directoryId, dataId, (thumbnailUriAsString != null + ? Uri.parse(thumbnailUriAsString) : null), false, isValid, lookupKey); + } + + @Override + public boolean isSamePerson(final RecipientEntry entry) { + return getDestination() != null && entry.getDestination() != null && + TextUtils.equals(getDestination(), entry.getDestination()); + } +} diff --git a/src/com/android/messaging/datamodel/ConversationImagePartsView.java b/src/com/android/messaging/datamodel/ConversationImagePartsView.java new file mode 100644 index 0000000..70ba381 --- /dev/null +++ b/src/com/android/messaging/datamodel/ConversationImagePartsView.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.provider.BaseColumns; + +import com.android.ex.photo.provider.PhotoContract.PhotoViewColumns; + +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseHelper.PartColumns; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.util.ContentType; + +/** + * View for the image parts for the conversation. It is used to provide the photoviewer with a + * a data source for all the photos in a conversation, so that the photoviewer can support paging + * through all the photos of the conversation. The columns of the view are a superset of + * {@link com.android.ex.photo.provider.PhotoContract.PhotoViewColumns}. + */ +public class ConversationImagePartsView { + private static final String VIEW_NAME = "conversation_image_parts_view"; + + private static final String CREATE_SQL = "CREATE VIEW " + + VIEW_NAME + " AS SELECT " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.CONVERSATION_ID + + " as " + Columns.CONVERSATION_ID + ", " + + DatabaseHelper.PARTS_TABLE + '.' + PartColumns.CONTENT_URI + + " as " + Columns.URI + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.FULL_NAME + + " as " + Columns.SENDER_FULL_NAME + ", " + + DatabaseHelper.PARTS_TABLE + '.' + PartColumns.CONTENT_URI + + " as " + Columns.CONTENT_URI + ", " + // Use NULL as the thumbnail uri + + " NULL as " + Columns.THUMBNAIL_URI + ", " + + DatabaseHelper.PARTS_TABLE + '.' + PartColumns.CONTENT_TYPE + + " as " + Columns.CONTENT_TYPE + ", " + // + // Columns in addition to those specified by PhotoContract + // + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.DISPLAY_DESTINATION + + " as " + Columns.DISPLAY_DESTINATION + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP + + " as " + Columns.RECEIVED_TIMESTAMP + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.STATUS + + " as " + Columns.STATUS + " " + + + " FROM " + DatabaseHelper.MESSAGES_TABLE + " LEFT JOIN " + DatabaseHelper.PARTS_TABLE + + " ON (" + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns._ID + + "=" + DatabaseHelper.PARTS_TABLE + "." + PartColumns.MESSAGE_ID + ") " + + " LEFT JOIN " + DatabaseHelper.PARTICIPANTS_TABLE + " ON (" + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENDER_PARTICIPANT_ID + + '=' + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns._ID + ")" + + // "content_type like 'image/%'" + + " WHERE " + DatabaseHelper.PARTS_TABLE + "." + PartColumns.CONTENT_TYPE + + " like '" + ContentType.IMAGE_PREFIX + "%'" + + + " ORDER BY " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP + " ASC, " + + DatabaseHelper.PARTS_TABLE + '.' + PartColumns._ID + " ASC"; + + static class Columns implements BaseColumns { + static final String CONVERSATION_ID = MessageColumns.CONVERSATION_ID; + static final String URI = PhotoViewColumns.URI; + static final String SENDER_FULL_NAME = PhotoViewColumns.NAME; + static final String CONTENT_URI = PhotoViewColumns.CONTENT_URI; + static final String THUMBNAIL_URI = PhotoViewColumns.THUMBNAIL_URI; + static final String CONTENT_TYPE = PhotoViewColumns.CONTENT_TYPE; + // Columns in addition to those specified by PhotoContract + static final String DISPLAY_DESTINATION = ParticipantColumns.DISPLAY_DESTINATION; + static final String RECEIVED_TIMESTAMP = MessageColumns.RECEIVED_TIMESTAMP; + static final String STATUS = MessageColumns.STATUS; + } + + public interface PhotoViewQuery { + public final String[] PROJECTION = { + PhotoViewColumns.URI, + PhotoViewColumns.NAME, + PhotoViewColumns.CONTENT_URI, + PhotoViewColumns.THUMBNAIL_URI, + PhotoViewColumns.CONTENT_TYPE, + // Columns in addition to those specified by PhotoContract + Columns.DISPLAY_DESTINATION, + Columns.RECEIVED_TIMESTAMP, + Columns.STATUS, + }; + + public final int INDEX_URI = 0; + public final int INDEX_SENDER_FULL_NAME = 1; + public final int INDEX_CONTENT_URI = 2; + public final int INDEX_THUMBNAIL_URI = 3; + public final int INDEX_CONTENT_TYPE = 4; + // Columns in addition to those specified by PhotoContract + public final int INDEX_DISPLAY_DESTINATION = 5; + public final int INDEX_RECEIVED_TIMESTAMP = 6; + public final int INDEX_STATUS = 7; + } + + static final String getViewName() { + return VIEW_NAME; + } + + static final String getCreateSql() { + return CREATE_SQL; + } +} diff --git a/src/com/android/messaging/datamodel/CursorQueryData.java b/src/com/android/messaging/datamodel/CursorQueryData.java new file mode 100644 index 0000000..3e6a656 --- /dev/null +++ b/src/com/android/messaging/datamodel/CursorQueryData.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; + +import com.android.messaging.util.Assert; +import com.google.common.annotations.VisibleForTesting; + +/** + * Holds parameters and data (such as content URI) for performing queries on the content provider. + * This class could then be used to perform a query using either a BoundCursorLoader or querying + * on the content resolver directly. + * + * This class is used for cases where the way to load a cursor is not fixed. For example, + * when using ContactUtil to query for phone numbers, the ContactPickerFragment wants to use + * a CursorLoader to asynchronously load the data and tie in nicely with its data binding + * paradigm, whereas ContactRecipientAdapter wants to synchronously perform the query on the + * worker thread. + */ +public class CursorQueryData { + protected final Uri mUri; + protected final String[] mProjection; + protected final String mSelection; + protected final String[] mSelectionArgs; + protected final String mSortOrder; + protected final Context mContext; + + public CursorQueryData(final Context context, final Uri uri, final String[] projection, + final String selection, final String[] selectionArgs, final String sortOrder) { + mContext = context; + mUri = uri; + mProjection = projection; + mSelection = selection; + mSelectionArgs = selectionArgs; + mSortOrder = sortOrder; + } + + public BoundCursorLoader createBoundCursorLoader(final String bindingId) { + return new BoundCursorLoader(bindingId, mContext, mUri, mProjection, mSelection, + mSelectionArgs, mSortOrder); + } + + public Cursor performSynchronousQuery() { + Assert.isNotMainThread(); + if (mUri == null) { + // See {@link #getEmptyQueryData} + return null; + } else { + return mContext.getContentResolver().query(mUri, mProjection, mSelection, + mSelectionArgs, mSortOrder); + } + } + + @VisibleForTesting + public Uri getUri() { + return mUri; + } + + /** + * Representation of an invalid query. {@link #performSynchronousQuery} will return + * a null Cursor. + */ + public static CursorQueryData getEmptyQueryData() { + return new CursorQueryData(null, null, null, null, null, null); + } +} diff --git a/src/com/android/messaging/datamodel/DataModel.java b/src/com/android/messaging/datamodel/DataModel.java new file mode 100644 index 0000000..936b51c --- /dev/null +++ b/src/com/android/messaging/datamodel/DataModel.java @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.action.Action; +import com.android.messaging.datamodel.action.ActionService; +import com.android.messaging.datamodel.action.BackgroundWorker; +import com.android.messaging.datamodel.data.BlockedParticipantsData; +import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener; +import com.android.messaging.datamodel.data.ContactListItemData; +import com.android.messaging.datamodel.data.ContactPickerData; +import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataListener; +import com.android.messaging.datamodel.data.ConversationData; +import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.GalleryGridItemData; +import com.android.messaging.datamodel.data.LaunchConversationData; +import com.android.messaging.datamodel.data.LaunchConversationData.LaunchConversationDataListener; +import com.android.messaging.datamodel.data.MediaPickerData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.ParticipantListItemData; +import com.android.messaging.datamodel.data.PeopleAndOptionsData; +import com.android.messaging.datamodel.data.PeopleAndOptionsData.PeopleAndOptionsDataListener; +import com.android.messaging.datamodel.data.PeopleOptionsItemData; +import com.android.messaging.datamodel.data.SettingsData; +import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener; +import com.android.messaging.datamodel.data.SubscriptionListData; +import com.android.messaging.datamodel.data.VCardContactItemData; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.ConnectivityUtil; + +public abstract class DataModel { + private String mFocusedConversation; + private boolean mConversationListScrolledToNewestConversation; + + public static DataModel get() { + return Factory.get().getDataModel(); + } + + public static final void startActionService(final Action action) { + get().getActionService().startAction(action); + } + + public static final void scheduleAction(final Action action, + final int code, final long delayMs) { + get().getActionService().scheduleAction(action, code, delayMs); + } + + public abstract ConversationListData createConversationListData(final Context context, + final ConversationListDataListener listener, final boolean archivedMode); + + public abstract ConversationData createConversationData(final Context context, + final ConversationDataListener listener, final String conversationId); + + public abstract ContactListItemData createContactListItemData(); + + public abstract ContactPickerData createContactPickerData(final Context context, + final ContactPickerDataListener listener); + + public abstract MediaPickerData createMediaPickerData(final Context context); + + public abstract GalleryGridItemData createGalleryGridItemData(); + + public abstract LaunchConversationData createLaunchConversationData( + LaunchConversationDataListener listener); + + public abstract PeopleOptionsItemData createPeopleOptionsItemData(final Context context); + + public abstract PeopleAndOptionsData createPeopleAndOptionsData(final String conversationId, + final Context context, final PeopleAndOptionsDataListener listener); + + public abstract VCardContactItemData createVCardContactItemData(final Context context, + final MessagePartData data); + + public abstract VCardContactItemData createVCardContactItemData(final Context context, + final Uri vCardUri); + + public abstract ParticipantListItemData createParticipantListItemData( + final ParticipantData participant); + + public abstract BlockedParticipantsData createBlockedParticipantsData(Context context, + BlockedParticipantsDataListener listener); + + public abstract SubscriptionListData createSubscriptonListData(Context context); + + public abstract SettingsData createSettingsData(Context context, SettingsDataListener listener); + + public abstract DraftMessageData createDraftMessageData(String conversationId); + + public abstract ActionService getActionService(); + + public abstract BackgroundWorker getBackgroundWorkerForActionService(); + + @DoesNotRunOnMainThread + public abstract DatabaseWrapper getDatabase(); + + // Allow DataModel to coordinate with activity lifetime events. + public abstract void onActivityResume(); + + abstract void onCreateTables(final SQLiteDatabase db); + + public void setFocusedConversation(final String conversationId) { + mFocusedConversation = conversationId; + } + + public boolean isFocusedConversation(final String conversationId) { + return !TextUtils.isEmpty(mFocusedConversation) + && TextUtils.equals(mFocusedConversation, conversationId); + } + + public void setConversationListScrolledToNewestConversation( + final boolean scrolledToNewestConversation) { + mConversationListScrolledToNewestConversation = scrolledToNewestConversation; + } + + public boolean isConversationListScrolledToNewestConversation() { + return mConversationListScrolledToNewestConversation; + } + + /** + * If a new message is received in the specified conversation, will the user be able to + * observe it in some UI within the app? + * @param conversationId conversation with the new incoming message + */ + public boolean isNewMessageObservable(final String conversationId) { + return isConversationListScrolledToNewestConversation() + || isFocusedConversation(conversationId); + } + + public abstract void onApplicationCreated(); + + public abstract ConnectivityUtil getConnectivityUtil(); + + public abstract SyncManager getSyncManager(); +} diff --git a/src/com/android/messaging/datamodel/DataModelException.java b/src/com/android/messaging/datamodel/DataModelException.java new file mode 100644 index 0000000..7084438 --- /dev/null +++ b/src/com/android/messaging/datamodel/DataModelException.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +public class DataModelException extends Exception { + private static final long serialVersionUID = 1L; + + private static final int FIRST = 100; + + // ERRORS GENERATED INTERNALLY BY DATA MODEL. + + // ERRORS RELATED WITH SMS. + public static final int ERROR_SMS_TEMPORARY_FAILURE = 116; + public static final int ERROR_SMS_PERMANENT_FAILURE = 117; + public static final int ERROR_MMS_TEMPORARY_FAILURE = 118; + public static final int ERROR_MMS_PERMANENT_UNKNOWN_FAILURE = 119; + + // Request expired. + public static final int ERROR_EXPIRED = 120; + // Request canceled by user. + public static final int ERROR_CANCELED = 121; + + public static final int ERROR_MOBILE_DATA_DISABLED = 123; + public static final int ERROR_MMS_SERVICE_BLOCKED = 124; + public static final int ERROR_MMS_INVALID_ADDRESS = 125; + public static final int ERROR_MMS_NETWORK_PROBLEM = 126; + public static final int ERROR_MMS_MESSAGE_NOT_FOUND = 127; + public static final int ERROR_MMS_MESSAGE_FORMAT_CORRUPT = 128; + public static final int ERROR_MMS_CONTENT_NOT_ACCEPTED = 129; + public static final int ERROR_MMS_MESSAGE_NOT_SUPPORTED = 130; + public static final int ERROR_MMS_REPLY_CHARGING_ERROR = 131; + public static final int ERROR_MMS_ADDRESS_HIDING_NOT_SUPPORTED = 132; + public static final int ERROR_MMS_LACK_OF_PREPAID = 133; + public static final int ERROR_MMS_CAN_NOT_PERSIST = 134; + public static final int ERROR_MMS_NO_AVAILABLE_APN = 135; + public static final int ERROR_MMS_INVALID_MESSAGE_TO_SEND = 136; + public static final int ERROR_MMS_INVALID_MESSAGE_RECEIVED = 137; + public static final int ERROR_MMS_NO_CONFIGURATION = 138; + + private static final int LAST = 138; + + private final boolean mIsInjection; + private final int mErrorCode; + private final String mMessage; + private final long mBackoff; + + public DataModelException(final int errorCode, final Exception innerException, + final long backoff, final boolean injection, final String message) { + // Since some of the exceptions passed in may not be serializable, only record message + // instead of setting inner exception for Exception class. Otherwise, we will get + // serialization issues when we pass ServerRequestException as intent extra later. + if (errorCode < FIRST || errorCode > LAST) { + throw new IllegalArgumentException("error code out of range: " + errorCode); + } + mIsInjection = injection; + mErrorCode = errorCode; + if (innerException != null) { + mMessage = innerException.getMessage() + " -- " + + (mIsInjection ? "[INJECTED] -- " : "") + message; + } else { + mMessage = (mIsInjection ? "[INJECTED] -- " : "") + message; + } + + mBackoff = backoff; + } + + public DataModelException(final int errorCode) { + this(errorCode, null, 0, false, null); + } + + public DataModelException(final int errorCode, final Exception innerException) { + this(errorCode, innerException, 0, false, null); + } + + public DataModelException(final int errorCode, final String message) { + this(errorCode, null, 0, false, message); + } + + @Override + public String getMessage() { + return mMessage; + } + + public int getErrorCode() { + return mErrorCode; + } +} diff --git a/src/com/android/messaging/datamodel/DataModelImpl.java b/src/com/android/messaging/datamodel/DataModelImpl.java new file mode 100644 index 0000000..6ab3f00 --- /dev/null +++ b/src/com/android/messaging/datamodel/DataModelImpl.java @@ -0,0 +1,236 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.telephony.SubscriptionManager; + +import com.android.messaging.datamodel.action.ActionService; +import com.android.messaging.datamodel.action.BackgroundWorker; +import com.android.messaging.datamodel.action.FixupMessageStatusOnStartupAction; +import com.android.messaging.datamodel.action.ProcessPendingMessagesAction; +import com.android.messaging.datamodel.data.BlockedParticipantsData; +import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener; +import com.android.messaging.datamodel.data.ContactListItemData; +import com.android.messaging.datamodel.data.ContactPickerData; +import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataListener; +import com.android.messaging.datamodel.data.ConversationData; +import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.GalleryGridItemData; +import com.android.messaging.datamodel.data.LaunchConversationData; +import com.android.messaging.datamodel.data.LaunchConversationData.LaunchConversationDataListener; +import com.android.messaging.datamodel.data.MediaPickerData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.ParticipantListItemData; +import com.android.messaging.datamodel.data.PeopleAndOptionsData; +import com.android.messaging.datamodel.data.PeopleAndOptionsData.PeopleAndOptionsDataListener; +import com.android.messaging.datamodel.data.PeopleOptionsItemData; +import com.android.messaging.datamodel.data.SettingsData; +import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener; +import com.android.messaging.datamodel.data.SubscriptionListData; +import com.android.messaging.datamodel.data.VCardContactItemData; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.ConnectivityUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +public class DataModelImpl extends DataModel { + private final Context mContext; + private final ActionService mActionService; + private final BackgroundWorker mDataModelWorker; + private final DatabaseHelper mDatabaseHelper; + private final ConnectivityUtil mConnectivityUtil; + private final SyncManager mSyncManager; + + public DataModelImpl(final Context context) { + super(); + mContext = context; + mActionService = new ActionService(); + mDataModelWorker = new BackgroundWorker(); + mDatabaseHelper = DatabaseHelper.getInstance(context); + mConnectivityUtil = new ConnectivityUtil(context); + mSyncManager = new SyncManager(); + } + + @Override + public ConversationListData createConversationListData(final Context context, + final ConversationListDataListener listener, final boolean archivedMode) { + return new ConversationListData(context, listener, archivedMode); + } + + @Override + public ConversationData createConversationData(final Context context, + final ConversationDataListener listener, final String conversationId) { + return new ConversationData(context, listener, conversationId); + } + + @Override + public ContactListItemData createContactListItemData() { + return new ContactListItemData(); + } + + @Override + public ContactPickerData createContactPickerData(final Context context, + final ContactPickerDataListener listener) { + return new ContactPickerData(context, listener); + } + + @Override + public BlockedParticipantsData createBlockedParticipantsData( + final Context context, final BlockedParticipantsDataListener listener) { + return new BlockedParticipantsData(context, listener); + } + + @Override + public MediaPickerData createMediaPickerData(final Context context) { + return new MediaPickerData(context); + } + + @Override + public GalleryGridItemData createGalleryGridItemData() { + return new GalleryGridItemData(); + } + + @Override + public LaunchConversationData createLaunchConversationData( + final LaunchConversationDataListener listener) { + return new LaunchConversationData(listener); + } + + @Override + public PeopleOptionsItemData createPeopleOptionsItemData(final Context context) { + return new PeopleOptionsItemData(context); + } + + @Override + public PeopleAndOptionsData createPeopleAndOptionsData(final String conversationId, + final Context context, final PeopleAndOptionsDataListener listener) { + return new PeopleAndOptionsData(conversationId, context, listener); + } + + @Override + public VCardContactItemData createVCardContactItemData(final Context context, + final MessagePartData data) { + return new VCardContactItemData(context, data); + } + + @Override + public VCardContactItemData createVCardContactItemData(final Context context, + final Uri vCardUri) { + return new VCardContactItemData(context, vCardUri); + } + + @Override + public ParticipantListItemData createParticipantListItemData( + final ParticipantData participant) { + return new ParticipantListItemData(participant); + } + + @Override + public SubscriptionListData createSubscriptonListData(Context context) { + return new SubscriptionListData(context); + } + + @Override + public SettingsData createSettingsData(Context context, SettingsDataListener listener) { + return new SettingsData(context, listener); + } + + @Override + public DraftMessageData createDraftMessageData(String conversationId) { + return new DraftMessageData(conversationId); + } + + @Override + public ActionService getActionService() { + // We need to allow access to this on the UI thread since it's used to start actions. + return mActionService; + } + + @Override + public BackgroundWorker getBackgroundWorkerForActionService() { + return mDataModelWorker; + } + + @Override + @DoesNotRunOnMainThread + public DatabaseWrapper getDatabase() { + // We prevent the main UI thread from accessing the database since we have to allow + // public access to this class to enable sub-packages to access data. + Assert.isNotMainThread(); + return mDatabaseHelper.getDatabase(); + } + + @Override + public ConnectivityUtil getConnectivityUtil() { + return mConnectivityUtil; + } + + @Override + public SyncManager getSyncManager() { + return mSyncManager; + } + + @Override + void onCreateTables(final SQLiteDatabase db) { + LogUtil.w(LogUtil.BUGLE_TAG, "Rebuilt databases: reseting related state"); + // Clear other things that implicitly reference the DB + SyncManager.resetLastSyncTimestamps(); + } + + @Override + public void onActivityResume() { + // Perform an incremental sync and register for changes if necessary + mSyncManager.updateSyncObserver(mContext); + + // Trigger a participant refresh if needed, we should only need to refresh if there is + // contact change while the activity was paused. + ParticipantRefresh.refreshParticipantsIfNeeded(); + } + + @Override + public void onApplicationCreated() { + FixupMessageStatusOnStartupAction.fixupMessageStatus(); + ProcessPendingMessagesAction.processFirstPendingMessage(); + SyncManager.immediateSync(); + + if (OsUtil.isAtLeastL_MR1()) { + // Start listening for subscription change events for refreshing self participants. + PhoneUtils.getDefault().toLMr1().registerOnSubscriptionsChangedListener( + new SubscriptionManager.OnSubscriptionsChangedListener() { + @Override + public void onSubscriptionsChanged() { + // TODO: This dynamically changes the mms config that app is + // currently using. It may cause inconsistency in some cases. We need + // to check the usage of mms config and handle the dynamic change + // gracefully + MmsConfig.loadAsync(); + ParticipantRefresh.refreshSelfParticipants(); + } + }); + } + } +} diff --git a/src/com/android/messaging/datamodel/DatabaseHelper.java b/src/com/android/messaging/datamodel/DatabaseHelper.java new file mode 100644 index 0000000..f16bb3c --- /dev/null +++ b/src/com/android/messaging/datamodel/DatabaseHelper.java @@ -0,0 +1,813 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.content.Context; +import android.database.Cursor; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.provider.BaseColumns; + +import com.android.messaging.BugleApplication; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.LogUtil; +import com.google.common.annotations.VisibleForTesting; + +/** + * TODO: Open Issues: + * - Should we be storing the draft messages in the regular messages table or should we have a + * separate table for drafts to keep the normal messages query as simple as possible? + */ + +/** + * Allows access to the SQL database. This is package private. + */ +public class DatabaseHelper extends SQLiteOpenHelper { + public static final String DATABASE_NAME = "bugle_db"; + + private static final int getDatabaseVersion(final Context context) { + return Integer.parseInt(context.getResources().getString(R.string.database_version)); + } + + /** Table containing names of all other tables and views */ + private static final String MASTER_TABLE = "sqlite_master"; + /** Column containing the name of the tables and views */ + private static final String[] MASTER_COLUMNS = new String[] { "name", }; + + // Table names + public static final String CONVERSATIONS_TABLE = "conversations"; + public static final String MESSAGES_TABLE = "messages"; + public static final String PARTS_TABLE = "parts"; + public static final String PARTICIPANTS_TABLE = "participants"; + public static final String CONVERSATION_PARTICIPANTS_TABLE = "conversation_participants"; + + // Views + static final String DRAFT_PARTS_VIEW = "draft_parts_view"; + + // Conversations table schema + public static class ConversationColumns implements BaseColumns { + /* SMS/MMS Thread ID from the system provider */ + public static final String SMS_THREAD_ID = "sms_thread_id"; + + /* Display name for the conversation */ + public static final String NAME = "name"; + + /* Latest Message ID for the read status to display in conversation list */ + public static final String LATEST_MESSAGE_ID = "latest_message_id"; + + /* Latest text snippet for display in conversation list */ + public static final String SNIPPET_TEXT = "snippet_text"; + + /* Latest text subject for display in conversation list, empty string if none exists */ + public static final String SUBJECT_TEXT = "subject_text"; + + /* Preview Uri */ + public static final String PREVIEW_URI = "preview_uri"; + + /* The preview uri's content type */ + public static final String PREVIEW_CONTENT_TYPE = "preview_content_type"; + + /* If we should display the current draft snippet/preview pair or snippet/preview pair */ + public static final String SHOW_DRAFT = "show_draft"; + + /* Latest draft text subject for display in conversation list, empty string if none exists*/ + public static final String DRAFT_SUBJECT_TEXT = "draft_subject_text"; + + /* Latest draft text snippet for display, empty string if none exists */ + public static final String DRAFT_SNIPPET_TEXT = "draft_snippet_text"; + + /* Draft Preview Uri, empty string if none exists */ + public static final String DRAFT_PREVIEW_URI = "draft_preview_uri"; + + /* The preview uri's content type */ + public static final String DRAFT_PREVIEW_CONTENT_TYPE = "draft_preview_content_type"; + + /* If this conversation is archived */ + public static final String ARCHIVE_STATUS = "archive_status"; + + /* Timestamp for sorting purposes */ + public static final String SORT_TIMESTAMP = "sort_timestamp"; + + /* Last read message timestamp */ + public static final String LAST_READ_TIMESTAMP = "last_read_timestamp"; + + /* Avatar for the conversation. Could be for group of individual */ + public static final String ICON = "icon"; + + /* Participant contact ID if this conversation has a single participant. -1 otherwise */ + public static final String PARTICIPANT_CONTACT_ID = "participant_contact_id"; + + /* Participant lookup key if this conversation has a single participant. null otherwise */ + public static final String PARTICIPANT_LOOKUP_KEY = "participant_lookup_key"; + + /* + * Participant's normalized destination if this conversation has a single participant. + * null otherwise. + */ + public static final String OTHER_PARTICIPANT_NORMALIZED_DESTINATION = + "participant_normalized_destination"; + + /* Default self participant for the conversation */ + public static final String CURRENT_SELF_ID = "current_self_id"; + + /* Participant count not including self (so will be 1 for 1:1 or bigger for group) */ + public static final String PARTICIPANT_COUNT = "participant_count"; + + /* Should notifications be enabled for this conversation? */ + public static final String NOTIFICATION_ENABLED = "notification_enabled"; + + /* Notification sound used for the conversation */ + public static final String NOTIFICATION_SOUND_URI = "notification_sound_uri"; + + /* Should vibrations be enabled for the conversation's notification? */ + public static final String NOTIFICATION_VIBRATION = "notification_vibration"; + + /* Conversation recipients include email address */ + public static final String INCLUDE_EMAIL_ADDRESS = "include_email_addr"; + + // Record the last received sms's service center info if it indicates that the reply path + // is present (TP-Reply-Path), so that we could use it for the subsequent message to send. + // Refer to TS 23.040 D.6 and SmsMessageSender.java in Android Messaging app. + public static final String SMS_SERVICE_CENTER = "sms_service_center"; + } + + // Conversation table SQL + private static final String CREATE_CONVERSATIONS_TABLE_SQL = + "CREATE TABLE " + CONVERSATIONS_TABLE + "(" + + ConversationColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + // TODO : Int? Required not default? + + ConversationColumns.SMS_THREAD_ID + " INT DEFAULT(0), " + + ConversationColumns.NAME + " TEXT, " + + ConversationColumns.LATEST_MESSAGE_ID + " INT, " + + ConversationColumns.SNIPPET_TEXT + " TEXT, " + + ConversationColumns.SUBJECT_TEXT + " TEXT, " + + ConversationColumns.PREVIEW_URI + " TEXT, " + + ConversationColumns.PREVIEW_CONTENT_TYPE + " TEXT, " + + ConversationColumns.SHOW_DRAFT + " INT DEFAULT(0), " + + ConversationColumns.DRAFT_SNIPPET_TEXT + " TEXT, " + + ConversationColumns.DRAFT_SUBJECT_TEXT + " TEXT, " + + ConversationColumns.DRAFT_PREVIEW_URI + " TEXT, " + + ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE + " TEXT, " + + ConversationColumns.ARCHIVE_STATUS + " INT DEFAULT(0), " + + ConversationColumns.SORT_TIMESTAMP + " INT DEFAULT(0), " + + ConversationColumns.LAST_READ_TIMESTAMP + " INT DEFAULT(0), " + + ConversationColumns.ICON + " TEXT, " + + ConversationColumns.PARTICIPANT_CONTACT_ID + " INT DEFAULT ( " + + ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED + "), " + + ConversationColumns.PARTICIPANT_LOOKUP_KEY + " TEXT, " + + ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION + " TEXT, " + + ConversationColumns.CURRENT_SELF_ID + " TEXT, " + + ConversationColumns.PARTICIPANT_COUNT + " INT DEFAULT(0), " + + ConversationColumns.NOTIFICATION_ENABLED + " INT DEFAULT(1), " + + ConversationColumns.NOTIFICATION_SOUND_URI + " TEXT, " + + ConversationColumns.NOTIFICATION_VIBRATION + " INT DEFAULT(1), " + + ConversationColumns.INCLUDE_EMAIL_ADDRESS + " INT DEFAULT(0), " + + ConversationColumns.SMS_SERVICE_CENTER + " TEXT " + + ");"; + + private static final String CONVERSATIONS_TABLE_SMS_THREAD_ID_INDEX_SQL = + "CREATE INDEX index_" + CONVERSATIONS_TABLE + "_" + ConversationColumns.SMS_THREAD_ID + + " ON " + CONVERSATIONS_TABLE + + "(" + ConversationColumns.SMS_THREAD_ID + ")"; + + private static final String CONVERSATIONS_TABLE_ARCHIVE_STATUS_INDEX_SQL = + "CREATE INDEX index_" + CONVERSATIONS_TABLE + "_" + ConversationColumns.ARCHIVE_STATUS + + " ON " + CONVERSATIONS_TABLE + + "(" + ConversationColumns.ARCHIVE_STATUS + ")"; + + private static final String CONVERSATIONS_TABLE_SORT_TIMESTAMP_INDEX_SQL = + "CREATE INDEX index_" + CONVERSATIONS_TABLE + "_" + ConversationColumns.SORT_TIMESTAMP + + " ON " + CONVERSATIONS_TABLE + + "(" + ConversationColumns.SORT_TIMESTAMP + ")"; + + // Messages table schema + public static class MessageColumns implements BaseColumns { + /* conversation id that this message belongs to */ + public static final String CONVERSATION_ID = "conversation_id"; + + /* participant which send this message */ + public static final String SENDER_PARTICIPANT_ID = "sender_id"; + + /* This is bugle's internal status for the message */ + public static final String STATUS = "message_status"; + + /* Type of message: SMS, MMS or MMS notification */ + public static final String PROTOCOL = "message_protocol"; + + /* This is the time that the sender sent the message */ + public static final String SENT_TIMESTAMP = "sent_timestamp"; + + /* Time that we received the message on this device */ + public static final String RECEIVED_TIMESTAMP = "received_timestamp"; + + /* When the message has been seen by a user in a notification */ + public static final String SEEN = "seen"; + + /* When the message has been read by a user */ + public static final String READ = "read"; + + /* participant representing the sim which processed this message */ + public static final String SELF_PARTICIPANT_ID = "self_id"; + + /* + * Time when a retry is initiated. This is used to compute the retry window + * when we retry sending/downloading a message. + */ + public static final String RETRY_START_TIMESTAMP = "retry_start_timestamp"; + + // Columns which map to the SMS provider + + /* Message ID from the platform provider */ + public static final String SMS_MESSAGE_URI = "sms_message_uri"; + + /* The message priority for MMS message */ + public static final String SMS_PRIORITY = "sms_priority"; + + /* The message size for MMS message */ + public static final String SMS_MESSAGE_SIZE = "sms_message_size"; + + /* The subject for MMS message */ + public static final String MMS_SUBJECT = "mms_subject"; + + /* Transaction id for MMS notificaiton */ + public static final String MMS_TRANSACTION_ID = "mms_transaction_id"; + + /* Content location for MMS notificaiton */ + public static final String MMS_CONTENT_LOCATION = "mms_content_location"; + + /* The expiry time (ms) for MMS message */ + public static final String MMS_EXPIRY = "mms_expiry"; + + /* The detailed status (RESPONSE_STATUS or RETRIEVE_STATUS) for MMS message */ + public static final String RAW_TELEPHONY_STATUS = "raw_status"; + } + + // Messages table SQL + private static final String CREATE_MESSAGES_TABLE_SQL = + "CREATE TABLE " + MESSAGES_TABLE + " (" + + MessageColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + MessageColumns.CONVERSATION_ID + " INT, " + + MessageColumns.SENDER_PARTICIPANT_ID + " INT, " + + MessageColumns.SENT_TIMESTAMP + " INT DEFAULT(0), " + + MessageColumns.RECEIVED_TIMESTAMP + " INT DEFAULT(0), " + + MessageColumns.PROTOCOL + " INT DEFAULT(0), " + + MessageColumns.STATUS + " INT DEFAULT(0), " + + MessageColumns.SEEN + " INT DEFAULT(0), " + + MessageColumns.READ + " INT DEFAULT(0), " + + MessageColumns.SMS_MESSAGE_URI + " TEXT, " + + MessageColumns.SMS_PRIORITY + " INT DEFAULT(0), " + + MessageColumns.SMS_MESSAGE_SIZE + " INT DEFAULT(0), " + + MessageColumns.MMS_SUBJECT + " TEXT, " + + MessageColumns.MMS_TRANSACTION_ID + " TEXT, " + + MessageColumns.MMS_CONTENT_LOCATION + " TEXT, " + + MessageColumns.MMS_EXPIRY + " INT DEFAULT(0), " + + MessageColumns.RAW_TELEPHONY_STATUS + " INT DEFAULT(0), " + + MessageColumns.SELF_PARTICIPANT_ID + " INT, " + + MessageColumns.RETRY_START_TIMESTAMP + " INT DEFAULT(0), " + + "FOREIGN KEY (" + MessageColumns.CONVERSATION_ID + ") REFERENCES " + + CONVERSATIONS_TABLE + "(" + ConversationColumns._ID + ") ON DELETE CASCADE " + + "FOREIGN KEY (" + MessageColumns.SENDER_PARTICIPANT_ID + ") REFERENCES " + + PARTICIPANTS_TABLE + "(" + ParticipantColumns._ID + ") ON DELETE SET NULL " + + "FOREIGN KEY (" + MessageColumns.SELF_PARTICIPANT_ID + ") REFERENCES " + + PARTICIPANTS_TABLE + "(" + ParticipantColumns._ID + ") ON DELETE SET NULL " + + ");"; + + // Primary sort index for messages table : by conversation id, status, received timestamp. + private static final String MESSAGES_TABLE_SORT_INDEX_SQL = + "CREATE INDEX index_" + MESSAGES_TABLE + "_sort ON " + MESSAGES_TABLE + "(" + + MessageColumns.CONVERSATION_ID + ", " + + MessageColumns.STATUS + ", " + + MessageColumns.RECEIVED_TIMESTAMP + ")"; + + private static final String MESSAGES_TABLE_STATUS_SEEN_INDEX_SQL = + "CREATE INDEX index_" + MESSAGES_TABLE + "_status_seen ON " + MESSAGES_TABLE + "(" + + MessageColumns.STATUS + ", " + + MessageColumns.SEEN + ")"; + + // Parts table schema + // A part may contain text or a media url, but not both. + public static class PartColumns implements BaseColumns { + /* message id that this part belongs to */ + public static final String MESSAGE_ID = "message_id"; + + /* conversation id that this part belongs to */ + public static final String CONVERSATION_ID = "conversation_id"; + + /* text for this part */ + public static final String TEXT = "text"; + + /* content uri for this part */ + public static final String CONTENT_URI = "uri"; + + /* content type for this part */ + public static final String CONTENT_TYPE = "content_type"; + + /* cached width for this part (for layout while loading) */ + public static final String WIDTH = "width"; + + /* cached height for this part (for layout while loading) */ + public static final String HEIGHT = "height"; + + /* de-normalized copy of timestamp from the messages table. This is populated + * via an insert trigger on the parts table. + */ + public static final String TIMESTAMP = "timestamp"; + } + + // Message part table SQL + private static final String CREATE_PARTS_TABLE_SQL = + "CREATE TABLE " + PARTS_TABLE + "(" + + PartColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + PartColumns.MESSAGE_ID + " INT," + + PartColumns.TEXT + " TEXT," + + PartColumns.CONTENT_URI + " TEXT," + + PartColumns.CONTENT_TYPE + " TEXT," + + PartColumns.WIDTH + " INT DEFAULT(" + + MessagingContentProvider.UNSPECIFIED_SIZE + ")," + + PartColumns.HEIGHT + " INT DEFAULT(" + + MessagingContentProvider.UNSPECIFIED_SIZE + ")," + + PartColumns.TIMESTAMP + " INT, " + + PartColumns.CONVERSATION_ID + " INT NOT NULL," + + "FOREIGN KEY (" + PartColumns.MESSAGE_ID + ") REFERENCES " + + MESSAGES_TABLE + "(" + MessageColumns._ID + ") ON DELETE CASCADE " + + "FOREIGN KEY (" + PartColumns.CONVERSATION_ID + ") REFERENCES " + + CONVERSATIONS_TABLE + "(" + ConversationColumns._ID + ") ON DELETE CASCADE " + + ");"; + + public static final String CREATE_PARTS_TRIGGER_SQL = + "CREATE TRIGGER " + PARTS_TABLE + "_TRIGGER" + " AFTER INSERT ON " + PARTS_TABLE + + " FOR EACH ROW " + + " BEGIN UPDATE " + PARTS_TABLE + + " SET " + PartColumns.TIMESTAMP + "=" + + " (SELECT received_timestamp FROM " + MESSAGES_TABLE + " WHERE " + MESSAGES_TABLE + + "." + MessageColumns._ID + "=" + "NEW." + PartColumns.MESSAGE_ID + ")" + + " WHERE " + PARTS_TABLE + "." + PartColumns._ID + "=" + "NEW." + PartColumns._ID + + "; END"; + + public static final String CREATE_MESSAGES_TRIGGER_SQL = + "CREATE TRIGGER " + MESSAGES_TABLE + "_TRIGGER" + " AFTER UPDATE OF " + + MessageColumns.RECEIVED_TIMESTAMP + " ON " + MESSAGES_TABLE + + " FOR EACH ROW BEGIN UPDATE " + PARTS_TABLE + " SET " + PartColumns.TIMESTAMP + + " = NEW." + MessageColumns.RECEIVED_TIMESTAMP + " WHERE " + PARTS_TABLE + "." + + PartColumns.MESSAGE_ID + " = NEW." + MessageColumns._ID + + "; END;"; + + // Primary sort index for parts table : by message_id + private static final String PARTS_TABLE_MESSAGE_INDEX_SQL = + "CREATE INDEX index_" + PARTS_TABLE + "_message_id ON " + PARTS_TABLE + "(" + + PartColumns.MESSAGE_ID + ")"; + + // Participants table schema + public static class ParticipantColumns implements BaseColumns { + /* The subscription id for the sim associated with this self participant. + * Introduced in L. For earlier versions will always be default_sub_id (-1). + * For multi sim devices (or cases where the sim was changed) single device + * may have several different sub_id values */ + public static final String SUB_ID = "sub_id"; + + /* The slot of the active SIM (inserted in the device) for this self-participant. If the + * self-participant doesn't correspond to any active SIM, this will be + * {@link android.telephony.SubscriptionManager#INVALID_SLOT_ID}. + * The column is ignored for all non-self participants. + */ + public static final String SIM_SLOT_ID = "sim_slot_id"; + + /* The phone number stored in a standard E164 format if possible. This is unique for a + * given participant. We can't handle multiple participants with the same phone number + * since we don't know which of them a message comes from. This can also be an email + * address, in which case this is the same as the displayed address */ + public static final String NORMALIZED_DESTINATION = "normalized_destination"; + + /* The phone number as originally supplied and used for dialing. Not necessarily in E164 + * format or unique */ + public static final String SEND_DESTINATION = "send_destination"; + + /* The user-friendly formatting of the phone number according to the region setting of + * the device when the row was added. */ + public static final String DISPLAY_DESTINATION = "display_destination"; + + /* A string with this participant's full name or a pretty printed phone number */ + public static final String FULL_NAME = "full_name"; + + /* A string with just this participant's first name */ + public static final String FIRST_NAME = "first_name"; + + /* A local URI to an asset for the icon for this participant */ + public static final String PROFILE_PHOTO_URI = "profile_photo_uri"; + + /* Contact id for matching local contact for this participant */ + public static final String CONTACT_ID = "contact_id"; + + /* String that contains hints on how to find contact information in a contact lookup */ + public static final String LOOKUP_KEY = "lookup_key"; + + /* If this participant is blocked */ + public static final String BLOCKED = "blocked"; + + /* The color of the subscription (FOR SELF PARTICIPANTS ONLY) */ + public static final String SUBSCRIPTION_COLOR = "subscription_color"; + + /* The name of the subscription (FOR SELF PARTICIPANTS ONLY) */ + public static final String SUBSCRIPTION_NAME = "subscription_name"; + + /* The exact destination stored in Contacts for this participant */ + public static final String CONTACT_DESTINATION = "contact_destination"; + } + + // Participants table SQL + private static final String CREATE_PARTICIPANTS_TABLE_SQL = + "CREATE TABLE " + PARTICIPANTS_TABLE + "(" + + ParticipantColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + ParticipantColumns.SUB_ID + " INT DEFAULT(" + + ParticipantData.OTHER_THAN_SELF_SUB_ID + ")," + + ParticipantColumns.SIM_SLOT_ID + " INT DEFAULT(" + + ParticipantData.INVALID_SLOT_ID + ")," + + ParticipantColumns.NORMALIZED_DESTINATION + " TEXT," + + ParticipantColumns.SEND_DESTINATION + " TEXT," + + ParticipantColumns.DISPLAY_DESTINATION + " TEXT," + + ParticipantColumns.FULL_NAME + " TEXT," + + ParticipantColumns.FIRST_NAME + " TEXT," + + ParticipantColumns.PROFILE_PHOTO_URI + " TEXT, " + + ParticipantColumns.CONTACT_ID + " INT DEFAULT( " + + ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED + "), " + + ParticipantColumns.LOOKUP_KEY + " STRING, " + + ParticipantColumns.BLOCKED + " INT DEFAULT(0), " + + ParticipantColumns.SUBSCRIPTION_NAME + " TEXT, " + + ParticipantColumns.SUBSCRIPTION_COLOR + " INT DEFAULT(0), " + + ParticipantColumns.CONTACT_DESTINATION + " TEXT, " + + "UNIQUE (" + ParticipantColumns.NORMALIZED_DESTINATION + ", " + + ParticipantColumns.SUB_ID + ") ON CONFLICT FAIL" + ");"; + + private static final String CREATE_SELF_PARTICIPANT_SQL = + "INSERT INTO " + PARTICIPANTS_TABLE + + " ( " + ParticipantColumns.SUB_ID + " ) VALUES ( %s )"; + + static String getCreateSelfParticipantSql(int subId) { + return String.format(CREATE_SELF_PARTICIPANT_SQL, subId); + } + + // Conversation Participants table schema - contains a list of participants excluding the user + // in a given conversation. + public static class ConversationParticipantsColumns implements BaseColumns { + /* participant id of someone in this conversation */ + public static final String PARTICIPANT_ID = "participant_id"; + + /* conversation id that this participant belongs to */ + public static final String CONVERSATION_ID = "conversation_id"; + } + + // Conversation Participants table SQL + private static final String CREATE_CONVERSATION_PARTICIPANTS_TABLE_SQL = + "CREATE TABLE " + CONVERSATION_PARTICIPANTS_TABLE + "(" + + ConversationParticipantsColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + ConversationParticipantsColumns.CONVERSATION_ID + " INT," + + ConversationParticipantsColumns.PARTICIPANT_ID + " INT," + + "UNIQUE (" + ConversationParticipantsColumns.CONVERSATION_ID + "," + + ConversationParticipantsColumns.PARTICIPANT_ID + ") ON CONFLICT FAIL, " + + "FOREIGN KEY (" + ConversationParticipantsColumns.CONVERSATION_ID + ") " + + "REFERENCES " + CONVERSATIONS_TABLE + "(" + ConversationColumns._ID + ")" + + " ON DELETE CASCADE " + + "FOREIGN KEY (" + ConversationParticipantsColumns.PARTICIPANT_ID + ")" + + " REFERENCES " + PARTICIPANTS_TABLE + "(" + ParticipantColumns._ID + "));"; + + // Primary access pattern for conversation participants is to look them up for a specific + // conversation. + private static final String CONVERSATION_PARTICIPANTS_TABLE_CONVERSATION_ID_INDEX_SQL = + "CREATE INDEX index_" + CONVERSATION_PARTICIPANTS_TABLE + "_" + + ConversationParticipantsColumns.CONVERSATION_ID + + " ON " + CONVERSATION_PARTICIPANTS_TABLE + + "(" + ConversationParticipantsColumns.CONVERSATION_ID + ")"; + + // View for getting parts which are for draft messages. + static final String DRAFT_PARTS_VIEW_SQL = "CREATE VIEW " + + DRAFT_PARTS_VIEW + " AS SELECT " + + PARTS_TABLE + '.' + PartColumns._ID + + " as " + PartColumns._ID + ", " + + PARTS_TABLE + '.' + PartColumns.MESSAGE_ID + + " as " + PartColumns.MESSAGE_ID + ", " + + PARTS_TABLE + '.' + PartColumns.TEXT + + " as " + PartColumns.TEXT + ", " + + PARTS_TABLE + '.' + PartColumns.CONTENT_URI + + " as " + PartColumns.CONTENT_URI + ", " + + PARTS_TABLE + '.' + PartColumns.CONTENT_TYPE + + " as " + PartColumns.CONTENT_TYPE + ", " + + PARTS_TABLE + '.' + PartColumns.WIDTH + + " as " + PartColumns.WIDTH + ", " + + PARTS_TABLE + '.' + PartColumns.HEIGHT + + " as " + PartColumns.HEIGHT + ", " + + MESSAGES_TABLE + '.' + MessageColumns.CONVERSATION_ID + + " as " + MessageColumns.CONVERSATION_ID + " " + + " FROM " + MESSAGES_TABLE + " LEFT JOIN " + PARTS_TABLE + " ON (" + + MESSAGES_TABLE + "." + MessageColumns._ID + + "=" + PARTS_TABLE + "." + PartColumns.MESSAGE_ID + ")" + // Exclude draft messages from main view + + " WHERE " + MESSAGES_TABLE + "." + MessageColumns.STATUS + + " = " + MessageData.BUGLE_STATUS_OUTGOING_DRAFT; + + // List of all our SQL tables + private static final String[] CREATE_TABLE_SQLS = new String[] { + CREATE_CONVERSATIONS_TABLE_SQL, + CREATE_MESSAGES_TABLE_SQL, + CREATE_PARTS_TABLE_SQL, + CREATE_PARTICIPANTS_TABLE_SQL, + CREATE_CONVERSATION_PARTICIPANTS_TABLE_SQL, + }; + + // List of all our indices + private static final String[] CREATE_INDEX_SQLS = new String[] { + CONVERSATIONS_TABLE_SMS_THREAD_ID_INDEX_SQL, + CONVERSATIONS_TABLE_ARCHIVE_STATUS_INDEX_SQL, + CONVERSATIONS_TABLE_SORT_TIMESTAMP_INDEX_SQL, + MESSAGES_TABLE_SORT_INDEX_SQL, + MESSAGES_TABLE_STATUS_SEEN_INDEX_SQL, + PARTS_TABLE_MESSAGE_INDEX_SQL, + CONVERSATION_PARTICIPANTS_TABLE_CONVERSATION_ID_INDEX_SQL, + }; + + // List of all our SQL triggers + private static final String[] CREATE_TRIGGER_SQLS = new String[] { + CREATE_PARTS_TRIGGER_SQL, + CREATE_MESSAGES_TRIGGER_SQL, + }; + + // List of all our views + private static final String[] CREATE_VIEW_SQLS = new String[] { + ConversationListItemData.getConversationListViewSql(), + ConversationImagePartsView.getCreateSql(), + DRAFT_PARTS_VIEW_SQL, + }; + + private static final Object sLock = new Object(); + private final Context mApplicationContext; + private static DatabaseHelper sHelperInstance; // Protected by sLock. + + private final Object mDatabaseWrapperLock = new Object(); + private DatabaseWrapper mDatabaseWrapper; // Protected by mDatabaseWrapperLock. + private final DatabaseUpgradeHelper mUpgradeHelper = new DatabaseUpgradeHelper(); + + /** + * Get a (singleton) instance of {@link DatabaseHelper}, creating one if there isn't one yet. + * This is the only public method for getting a new instance of the class. + * @param context Should be the application context (or something that will live for the + * lifetime of the application). + * @return The current (or a new) DatabaseHelper instance. + */ + public static DatabaseHelper getInstance(final Context context) { + synchronized (sLock) { + if (sHelperInstance == null) { + sHelperInstance = new DatabaseHelper(context); + } + return sHelperInstance; + } + } + + /** + * Private constructor, used from {@link #getInstance()}. + * @param context Should be the application context (or something that will live for the + * lifetime of the application). + */ + private DatabaseHelper(final Context context) { + super(context, DATABASE_NAME, null, getDatabaseVersion(context), null); + mApplicationContext = context; + } + + /** + * Test method that always instantiates a new DatabaseHelper instance. This should + * be used ONLY by the tests and never by the real application. + * @param context Test context. + * @return Brand new DatabaseHelper instance. + */ + @VisibleForTesting + static DatabaseHelper getNewInstanceForTest(final Context context) { + Assert.isEngBuild(); + Assert.isTrue(BugleApplication.isRunningTests()); + return new DatabaseHelper(context); + } + + /** + * Get the (singleton) instance of @{link DatabaseWrapper}. + *

The database is always opened as a writeable database. + * @return The current (or a new) DatabaseWrapper instance. + */ + @DoesNotRunOnMainThread + DatabaseWrapper getDatabase() { + // We prevent the main UI thread from accessing the database here since we have to allow + // public access to this class to enable sub-packages to access data. + Assert.isNotMainThread(); + + synchronized (mDatabaseWrapperLock) { + if (mDatabaseWrapper == null) { + mDatabaseWrapper = new DatabaseWrapper(mApplicationContext, getWritableDatabase()); + } + return mDatabaseWrapper; + } + } + + @Override + public void onDowngrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) { + mUpgradeHelper.onDowngrade(db, oldVersion, newVersion); + } + + /** + * Drops and recreates all tables. + */ + public static void rebuildTables(final SQLiteDatabase db) { + // Drop tables first, then views, and indices. + dropAllTables(db); + dropAllViews(db); + dropAllIndexes(db); + dropAllTriggers(db); + + // Recreate the whole database. + createDatabase(db); + } + + /** + * Drop and rebuild a given view. + */ + static void rebuildView(final SQLiteDatabase db, final String viewName, + final String createViewSql) { + dropView(db, viewName, true /* throwOnFailure */); + db.execSQL(createViewSql); + } + + private static void dropView(final SQLiteDatabase db, final String viewName, + final boolean throwOnFailure) { + final String dropPrefix = "DROP VIEW IF EXISTS "; + try { + db.execSQL(dropPrefix + viewName); + } catch (final SQLException ex) { + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) { + LogUtil.d(LogUtil.BUGLE_TAG, "unable to drop view " + viewName + " " + + ex); + } + + if (throwOnFailure) { + throw ex; + } + } + } + + /** + * Drops all user-defined tables from the given database. + */ + private static void dropAllTables(final SQLiteDatabase db) { + final Cursor tableCursor = + db.query(MASTER_TABLE, MASTER_COLUMNS, "type='table'", null, null, null, null); + if (tableCursor != null) { + try { + final String dropPrefix = "DROP TABLE IF EXISTS "; + while (tableCursor.moveToNext()) { + final String tableName = tableCursor.getString(0); + + // Skip special tables + if (tableName.startsWith("android_") || tableName.startsWith("sqlite_")) { + continue; + } + try { + db.execSQL(dropPrefix + tableName); + } catch (final SQLException ex) { + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) { + LogUtil.d(LogUtil.BUGLE_TAG, "unable to drop table " + tableName + " " + + ex); + } + } + } + } finally { + tableCursor.close(); + } + } + } + + /** + * Drops all user-defined triggers from the given database. + */ + private static void dropAllTriggers(final SQLiteDatabase db) { + final Cursor triggerCursor = + db.query(MASTER_TABLE, MASTER_COLUMNS, "type='trigger'", null, null, null, null); + if (triggerCursor != null) { + try { + final String dropPrefix = "DROP TRIGGER IF EXISTS "; + while (triggerCursor.moveToNext()) { + final String triggerName = triggerCursor.getString(0); + + // Skip special tables + if (triggerName.startsWith("android_") || triggerName.startsWith("sqlite_")) { + continue; + } + try { + db.execSQL(dropPrefix + triggerName); + } catch (final SQLException ex) { + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) { + LogUtil.d(LogUtil.BUGLE_TAG, "unable to drop trigger " + triggerName + + " " + ex); + } + } + } + } finally { + triggerCursor.close(); + } + } + } + + /** + * Drops all user-defined views from the given database. + */ + private static void dropAllViews(final SQLiteDatabase db) { + final Cursor viewCursor = + db.query(MASTER_TABLE, MASTER_COLUMNS, "type='view'", null, null, null, null); + if (viewCursor != null) { + try { + while (viewCursor.moveToNext()) { + final String viewName = viewCursor.getString(0); + dropView(db, viewName, false /* throwOnFailure */); + } + } finally { + viewCursor.close(); + } + } + } + + /** + * Drops all user-defined views from the given database. + */ + private static void dropAllIndexes(final SQLiteDatabase db) { + final Cursor indexCursor = + db.query(MASTER_TABLE, MASTER_COLUMNS, "type='index'", null, null, null, null); + if (indexCursor != null) { + try { + final String dropPrefix = "DROP INDEX IF EXISTS "; + while (indexCursor.moveToNext()) { + final String indexName = indexCursor.getString(0); + try { + db.execSQL(dropPrefix + indexName); + } catch (final SQLException ex) { + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) { + LogUtil.d(LogUtil.BUGLE_TAG, "unable to drop index " + indexName + " " + + ex); + } + } + } + } finally { + indexCursor.close(); + } + } + } + + private static void createDatabase(final SQLiteDatabase db) { + for (final String sql : CREATE_TABLE_SQLS) { + db.execSQL(sql); + } + + for (final String sql : CREATE_INDEX_SQLS) { + db.execSQL(sql); + } + + for (final String sql : CREATE_VIEW_SQLS) { + db.execSQL(sql); + } + + for (final String sql : CREATE_TRIGGER_SQLS) { + db.execSQL(sql); + } + + // Enable foreign key constraints + db.execSQL("PRAGMA foreign_keys=ON;"); + + // Add the default self participant. The default self will be assigned a proper slot id + // during participant refresh. + db.execSQL(getCreateSelfParticipantSql(ParticipantData.DEFAULT_SELF_SUB_ID)); + + DataModel.get().onCreateTables(db); + } + + @Override + public void onCreate(SQLiteDatabase db) { + createDatabase(db); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + mUpgradeHelper.doOnUpgrade(db, oldVersion, newVersion); + } +} diff --git a/src/com/android/messaging/datamodel/DatabaseUpgradeHelper.java b/src/com/android/messaging/datamodel/DatabaseUpgradeHelper.java new file mode 100644 index 0000000..d112533 --- /dev/null +++ b/src/com/android/messaging/datamodel/DatabaseUpgradeHelper.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel; + +import android.database.sqlite.SQLiteDatabase; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +public class DatabaseUpgradeHelper { + private static final String TAG = LogUtil.BUGLE_DATABASE_TAG; + + public void doOnUpgrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) { + Assert.isTrue(newVersion >= oldVersion); + if (oldVersion == newVersion) { + return; + } + + LogUtil.i(TAG, "Database upgrade started from version " + oldVersion + " to " + newVersion); + + // Add future upgrade code here + } + + public void onDowngrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) { + DatabaseHelper.rebuildTables(db); + LogUtil.e(TAG, "Database downgrade requested for version " + + oldVersion + " version " + newVersion + ", forcing db rebuild!"); + } +} diff --git a/src/com/android/messaging/datamodel/DatabaseWrapper.java b/src/com/android/messaging/datamodel/DatabaseWrapper.java new file mode 100644 index 0000000..ca7a331 --- /dev/null +++ b/src/com/android/messaging/datamodel/DatabaseWrapper.java @@ -0,0 +1,482 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteFullException; +import android.database.sqlite.SQLiteQueryBuilder; +import android.database.sqlite.SQLiteStatement; +import android.util.SparseArray; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.UiUtils; + +import java.util.Locale; +import java.util.Stack; +import java.util.regex.Pattern; + +public class DatabaseWrapper { + private static final String TAG = LogUtil.BUGLE_DATABASE_TAG; + + private final SQLiteDatabase mDatabase; + private final Context mContext; + private final boolean mLog; + /** + * Set mExplainQueryPlanRegexp (via {@link BugleGservicesKeys#EXPLAIN_QUERY_PLAN_REGEXP} + * to regex matching queries to see query plans. For example, ".*" to show all query plans. + */ + // See + private final String mExplainQueryPlanRegexp; + private static final int sTimingThreshold = 50; // in milliseconds + + public static final int INDEX_INSERT_MESSAGE_PART = 0; + public static final int INDEX_INSERT_MESSAGE = 1; + public static final int INDEX_QUERY_CONVERSATIONS_LATEST_MESSAGE = 2; + public static final int INDEX_QUERY_MESSAGES_LATEST_MESSAGE = 3; + + private final SparseArray mCompiledStatements; + + static class TransactionData { + long time; + boolean transactionSuccessful; + } + + // track transaction on a per thread basis + private static ThreadLocal> sTransactionDepth = + new ThreadLocal>() { + @Override + public Stack initialValue() { + return new Stack(); + } + }; + + private static String[] sFormatStrings = new String[] { + "took %d ms to %s", + " took %d ms to %s", + " took %d ms to %s", + }; + + DatabaseWrapper(final Context context, final SQLiteDatabase db) { + mLog = LogUtil.isLoggable(LogUtil.BUGLE_DATABASE_PERF_TAG, LogUtil.VERBOSE); + mExplainQueryPlanRegexp = Factory.get().getBugleGservices().getString( + BugleGservicesKeys.EXPLAIN_QUERY_PLAN_REGEXP, null); + mDatabase = db; + mContext = context; + mCompiledStatements = new SparseArray(); + } + + public SQLiteStatement getStatementInTransaction(final int index, final String statement) { + // Use transaction to serialize access to statements + Assert.isTrue(mDatabase.inTransaction()); + SQLiteStatement compiled = mCompiledStatements.get(index); + if (compiled == null) { + compiled = mDatabase.compileStatement(statement); + Assert.isTrue(compiled.toString().contains(statement.trim())); + mCompiledStatements.put(index, compiled); + } + return compiled; + } + + private void maybePlayDebugNoise() { + DebugUtils.maybePlayDebugNoise(mContext, DebugUtils.DEBUG_SOUND_DB_OP); + } + + private static void printTiming(final long t1, final String msg) { + final int transactionDepth = sTransactionDepth.get().size(); + final long t2 = System.currentTimeMillis(); + final long delta = t2 - t1; + if (delta > sTimingThreshold) { + LogUtil.v(LogUtil.BUGLE_DATABASE_PERF_TAG, String.format(Locale.US, + sFormatStrings[Math.min(sFormatStrings.length - 1, transactionDepth)], + delta, + msg)); + } + } + + public Context getContext() { + return mContext; + } + + public void beginTransaction() { + final long t1 = System.currentTimeMillis(); + + // push the current time onto the transaction stack + final TransactionData f = new TransactionData(); + f.time = t1; + sTransactionDepth.get().push(f); + + mDatabase.beginTransaction(); + } + + public void setTransactionSuccessful() { + final TransactionData f = sTransactionDepth.get().peek(); + f.transactionSuccessful = true; + mDatabase.setTransactionSuccessful(); + } + + public void endTransaction() { + long t1 = 0; + long transactionStartTime = 0; + final TransactionData f = sTransactionDepth.get().pop(); + if (f.transactionSuccessful == false) { + LogUtil.w(TAG, "endTransaction without setting successful"); + for (final StackTraceElement st : (new Exception()).getStackTrace()) { + LogUtil.w(TAG, " " + st.toString()); + } + } + if (mLog) { + transactionStartTime = f.time; + t1 = System.currentTimeMillis(); + } + try { + mDatabase.endTransaction(); + } catch (SQLiteFullException ex) { + LogUtil.e(TAG, "Database full, unable to endTransaction", ex); + UiUtils.showToastAtBottom(R.string.db_full); + } + if (mLog) { + printTiming(t1, String.format(Locale.US, + ">>> endTransaction (total for this transaction: %d)", + (System.currentTimeMillis() - transactionStartTime))); + } + } + + public void yieldTransaction() { + long yieldStartTime = 0; + if (mLog) { + yieldStartTime = System.currentTimeMillis(); + } + final boolean wasYielded = mDatabase.yieldIfContendedSafely(); + if (wasYielded && mLog) { + printTiming(yieldStartTime, "yieldTransaction"); + } + } + + public void insertWithOnConflict(final String searchTable, final String nullColumnHack, + final ContentValues initialValues, final int conflictAlgorithm) { + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + try { + mDatabase.insertWithOnConflict(searchTable, nullColumnHack, initialValues, + conflictAlgorithm); + } catch (SQLiteFullException ex) { + LogUtil.e(TAG, "Database full, unable to insertWithOnConflict", ex); + UiUtils.showToastAtBottom(R.string.db_full); + } + if (mLog) { + printTiming(t1, String.format(Locale.US, + "insertWithOnConflict with ", searchTable)); + } + } + + private void explainQueryPlan(final SQLiteQueryBuilder qb, final SQLiteDatabase db, + final String[] projection, final String selection, + @SuppressWarnings("unused") + final String[] queryArgs, + final String groupBy, + @SuppressWarnings("unused") + final String having, + final String sortOrder, final String limit) { + final String queryString = qb.buildQuery( + projection, + selection, + groupBy, + null/*having*/, + sortOrder, + limit); + explainQueryPlan(db, queryString, queryArgs); + } + + private void explainQueryPlan(final SQLiteDatabase db, final String sql, + final String[] queryArgs) { + if (!Pattern.matches(mExplainQueryPlanRegexp, sql)) { + return; + } + final Cursor planCursor = db.rawQuery("explain query plan " + sql, queryArgs); + try { + if (planCursor != null && planCursor.moveToFirst()) { + final int detailColumn = planCursor.getColumnIndex("detail"); + final StringBuilder sb = new StringBuilder(); + do { + sb.append(planCursor.getString(detailColumn)); + sb.append("\n"); + } while (planCursor.moveToNext()); + if (sb.length() > 0) { + sb.setLength(sb.length() - 1); + } + LogUtil.v(TAG, "for query " + sql + "\nplan is: " + + sb.toString()); + } + } catch (final Exception e) { + LogUtil.w(TAG, "Query plan failed ", e); + } finally { + if (planCursor != null) { + planCursor.close(); + } + } + } + + public Cursor query(final String searchTable, final String[] projection, + final String selection, final String[] selectionArgs, final String groupBy, + final String having, final String orderBy, final String limit) { + if (mExplainQueryPlanRegexp != null) { + final SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); + qb.setTables(searchTable); + explainQueryPlan(qb, mDatabase, projection, selection, selectionArgs, + groupBy, having, orderBy, limit); + } + + maybePlayDebugNoise(); + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + final Cursor cursor = mDatabase.query(searchTable, projection, selection, selectionArgs, + groupBy, having, orderBy, limit); + if (mLog) { + printTiming( + t1, + String.format(Locale.US, "query %s with %s ==> %d", + searchTable, selection, cursor.getCount())); + } + return cursor; + } + + public Cursor query(final String searchTable, final String[] columns, + final String selection, final String[] selectionArgs, final String groupBy, + final String having, final String orderBy) { + return query( + searchTable, columns, selection, selectionArgs, + groupBy, having, orderBy, null); + } + + public Cursor query(final SQLiteQueryBuilder qb, + final String[] projection, final String selection, final String[] queryArgs, + final String groupBy, final String having, final String sortOrder, final String limit) { + if (mExplainQueryPlanRegexp != null) { + explainQueryPlan(qb, mDatabase, projection, selection, queryArgs, + groupBy, having, sortOrder, limit); + } + maybePlayDebugNoise(); + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + final Cursor cursor = qb.query(mDatabase, projection, selection, queryArgs, groupBy, + having, sortOrder, limit); + if (mLog) { + printTiming( + t1, + String.format(Locale.US, "query %s with %s ==> %d", + qb.getTables(), selection, cursor.getCount())); + } + return cursor; + } + + public long queryNumEntries(final String table, final String selection, + final String[] selectionArgs) { + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + maybePlayDebugNoise(); + final long retval = + DatabaseUtils.queryNumEntries(mDatabase, table, selection, selectionArgs); + if (mLog){ + printTiming( + t1, + String.format(Locale.US, "queryNumEntries %s with %s ==> %d", table, + selection, retval)); + } + return retval; + } + + public Cursor rawQuery(final String sql, final String[] args) { + if (mExplainQueryPlanRegexp != null) { + explainQueryPlan(mDatabase, sql, args); + } + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + maybePlayDebugNoise(); + final Cursor cursor = mDatabase.rawQuery(sql, args); + if (mLog) { + printTiming( + t1, + String.format(Locale.US, "rawQuery %s ==> %d", sql, cursor.getCount())); + } + return cursor; + } + + public int update(final String table, final ContentValues values, + final String selection, final String[] selectionArgs) { + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + maybePlayDebugNoise(); + int count = 0; + try { + count = mDatabase.update(table, values, selection, selectionArgs); + } catch (SQLiteFullException ex) { + LogUtil.e(TAG, "Database full, unable to update", ex); + UiUtils.showToastAtBottom(R.string.db_full); + } + if (mLog) { + printTiming(t1, String.format(Locale.US, "update %s with %s ==> %d", + table, selection, count)); + } + return count; + } + + public int delete(final String table, final String whereClause, final String[] whereArgs) { + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + maybePlayDebugNoise(); + int count = 0; + try { + count = mDatabase.delete(table, whereClause, whereArgs); + } catch (SQLiteFullException ex) { + LogUtil.e(TAG, "Database full, unable to delete", ex); + UiUtils.showToastAtBottom(R.string.db_full); + } + if (mLog) { + printTiming(t1, + String.format(Locale.US, "delete from %s with %s ==> %d", table, + whereClause, count)); + } + return count; + } + + public long insert(final String table, final String nullColumnHack, + final ContentValues values) { + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + maybePlayDebugNoise(); + long rowId = -1; + try { + rowId = mDatabase.insert(table, nullColumnHack, values); + } catch (SQLiteFullException ex) { + LogUtil.e(TAG, "Database full, unable to insert", ex); + UiUtils.showToastAtBottom(R.string.db_full); + } + if (mLog) { + printTiming(t1, String.format(Locale.US, "insert to %s", table)); + } + return rowId; + } + + public long replace(final String table, final String nullColumnHack, + final ContentValues values) { + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + maybePlayDebugNoise(); + long rowId = -1; + try { + rowId = mDatabase.replace(table, nullColumnHack, values); + } catch (SQLiteFullException ex) { + LogUtil.e(TAG, "Database full, unable to replace", ex); + UiUtils.showToastAtBottom(R.string.db_full); + } + if (mLog) { + printTiming(t1, String.format(Locale.US, "replace to %s", table)); + } + return rowId; + } + + public void setLocale(final Locale locale) { + mDatabase.setLocale(locale); + } + + public void execSQL(final String sql, final String[] bindArgs) { + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + maybePlayDebugNoise(); + try { + mDatabase.execSQL(sql, bindArgs); + } catch (SQLiteFullException ex) { + LogUtil.e(TAG, "Database full, unable to execSQL", ex); + UiUtils.showToastAtBottom(R.string.db_full); + } + + if (mLog) { + printTiming(t1, String.format(Locale.US, "execSQL %s", sql)); + } + } + + public void execSQL(final String sql) { + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + maybePlayDebugNoise(); + try { + mDatabase.execSQL(sql); + } catch (SQLiteFullException ex) { + LogUtil.e(TAG, "Database full, unable to execSQL", ex); + UiUtils.showToastAtBottom(R.string.db_full); + } + + if (mLog) { + printTiming(t1, String.format(Locale.US, "execSQL %s", sql)); + } + } + + public int execSQLUpdateDelete(final String sql) { + long t1 = 0; + if (mLog) { + t1 = System.currentTimeMillis(); + } + maybePlayDebugNoise(); + final SQLiteStatement statement = mDatabase.compileStatement(sql); + int rowsUpdated = 0; + try { + rowsUpdated = statement.executeUpdateDelete(); + } catch (SQLiteFullException ex) { + LogUtil.e(TAG, "Database full, unable to execSQLUpdateDelete", ex); + UiUtils.showToastAtBottom(R.string.db_full); + } + if (mLog) { + printTiming(t1, String.format(Locale.US, "execSQLUpdateDelete %s", sql)); + } + return rowsUpdated; + } + + public SQLiteDatabase getDatabase() { + return mDatabase; + } +} diff --git a/src/com/android/messaging/datamodel/FileProvider.java b/src/com/android/messaging/datamodel/FileProvider.java new file mode 100644 index 0000000..ee332cd --- /dev/null +++ b/src/com/android/messaging/datamodel/FileProvider.java @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.content.ContentProvider; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.text.TextUtils; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Random; + +/** + * A very simple content provider that can serve files. + */ +public abstract class FileProvider extends ContentProvider { + // Object to generate random id for temp images. + private static final Random RANDOM_ID = new Random(); + + abstract File getFile(final String path, final String extension); + + private static final String FILE_EXTENSION_PARAM_KEY = "ext"; + + /** + * Check if filename conforms to requirement for our provider + * @param fileId filename (optionally starting with path character + * @return true if filename consists only of digits + */ + protected static boolean isValidFileId(final String fileId) { + // Ignore initial "/" + for (int index = (fileId.startsWith("/") ? 1 : 0); index < fileId.length(); index++) { + final Character c = fileId.charAt(index); + if (!Character.isDigit(c)) { + return false; + } + } + return true; + } + + /** + * Create a temp file (to allow writing to that one particular file) + * @param file the file to create + * @return true if file successfully created + */ + protected static boolean ensureFileExists(final File file) { + try { + final File parentDir = file.getParentFile(); + if (parentDir.exists() || parentDir.mkdirs()) { + return file.createNewFile(); + } + } catch (final IOException e) { + // fail on exceptions creating the file + } + return false; + } + + /** + * Build uri for a new temporary file (creating file) + * @param authority authority with which to populate uri + * @param extension optional file extension + * @return unique uri that can be used to write temporary files + */ + protected static Uri buildFileUri(final String authority, final String extension) { + final long fileId = Math.abs(RANDOM_ID.nextLong()); + final Uri.Builder builder = (new Uri.Builder()).authority(authority).scheme( + ContentResolver.SCHEME_CONTENT); + builder.appendPath(String.valueOf(fileId)); + if (!TextUtils.isEmpty(extension)) { + builder.appendQueryParameter(FILE_EXTENSION_PARAM_KEY, extension); + } + return builder.build(); + } + + @Override + public boolean onCreate() { + return true; + } + + @Override + public int delete(final Uri uri, final String selection, final String[] selectionArgs) { + final String fileId = uri.getPath(); + if (isValidFileId(fileId)) { + final File file = getFile(fileId, getExtensionFromUri(uri)); + return file.delete() ? 1 : 0; + } + return 0; + } + + @Override + public ParcelFileDescriptor openFile(final Uri uri, final String fileMode) + throws FileNotFoundException { + final String fileId = uri.getPath(); + if (isValidFileId(fileId)) { + final File file = getFile(fileId, getExtensionFromUri(uri)); + final int mode = + (TextUtils.equals(fileMode, "r") ? ParcelFileDescriptor.MODE_READ_ONLY : + ParcelFileDescriptor.MODE_WRITE_ONLY | ParcelFileDescriptor.MODE_TRUNCATE); + return ParcelFileDescriptor.open(file, mode); + } + return null; + } + + protected static String getExtensionFromUri(final Uri uri) { + return uri.getQueryParameter(FILE_EXTENSION_PARAM_KEY); + } + + @Override + public Cursor query(final Uri uri, final String[] projection, final String selection, + final String[] selectionArgs, final String sortOrder) { + // Don't support queries. + return null; + } + + @Override + public Uri insert(final Uri uri, final ContentValues values) { + // Don't support inserts. + return null; + } + + @Override + public int update(final Uri uri, final ContentValues values, final String selection, + final String[] selectionArgs) { + // Don't support updates. + return 0; + } + + @Override + public String getType(final Uri uri) { + // No need for mime types. + return null; + } +} diff --git a/src/com/android/messaging/datamodel/FrequentContactsCursorBuilder.java b/src/com/android/messaging/datamodel/FrequentContactsCursorBuilder.java new file mode 100644 index 0000000..62483a0 --- /dev/null +++ b/src/com/android/messaging/datamodel/FrequentContactsCursorBuilder.java @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel; + +import android.database.Cursor; +import android.database.MatrixCursor; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.support.v4.util.SimpleArrayMap; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContactUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; + +/** + * A cursor builder that takes the frequent contacts cursor and aggregate it with the all contacts + * cursor to fill in contact details such as phone numbers and strip away invalid contacts. + * + * Because the frequent contact list depends on the loading of two cursors, it needs to temporarily + * store the cursor that it receives with setFrequents() and setAllContacts() calls. Because it + * doesn't know which one will be finished first, it always checks whether both cursors are ready + * to pull data from and construct the aggregate cursor when it's ready to do so. Note that + * this cursor builder doesn't assume ownership of the cursors passed in - it merely references + * them and always does a isClosed() check before consuming them. The ownership still belongs to + * the loader framework and the cursor may be closed when the UI is torn down. + */ +public class FrequentContactsCursorBuilder { + private Cursor mAllContactsCursor; + private Cursor mFrequentContactsCursor; + + /** + * Sets the frequent contacts cursor as soon as it is loaded, or null if it's reset. + * @return this builder instance for chained operations + */ + public FrequentContactsCursorBuilder setFrequents(final Cursor frequentContactsCursor) { + mFrequentContactsCursor = frequentContactsCursor; + return this; + } + + /** + * Sets the all contacts cursor as soon as it is loaded, or null if it's reset. + * @return this builder instance for chained operations + */ + public FrequentContactsCursorBuilder setAllContacts(final Cursor allContactsCursor) { + mAllContactsCursor = allContactsCursor; + return this; + } + + /** + * Reset this builder. Must be called when the consumer resets its data. + */ + public void resetBuilder() { + mAllContactsCursor = null; + mFrequentContactsCursor = null; + } + + /** + * Attempt to build the cursor records from the frequent and all contacts cursor if they + * are both ready to be consumed. + * @return the frequent contact cursor if built successfully, or null if it can't be built yet. + */ + public Cursor build() { + if (mFrequentContactsCursor != null && mAllContactsCursor != null) { + Assert.isTrue(!mFrequentContactsCursor.isClosed()); + Assert.isTrue(!mAllContactsCursor.isClosed()); + + // Frequent contacts cursor has one record per contact, plus it doesn't contain info + // such as phone number and type. In order for the records to be usable by Bugle, we + // would like to populate it with information from the all contacts cursor. + final MatrixCursor retCursor = new MatrixCursor(ContactUtil.PhoneQuery.PROJECTION); + + // First, go through the frequents cursor and take note of all lookup keys and their + // corresponding rank in the frequents list. + final SimpleArrayMap lookupKeyToRankMap = + new SimpleArrayMap(); + int oldPosition = mFrequentContactsCursor.getPosition(); + int rank = 0; + mFrequentContactsCursor.moveToPosition(-1); + while (mFrequentContactsCursor.moveToNext()) { + final String lookupKey = mFrequentContactsCursor.getString( + ContactUtil.INDEX_LOOKUP_KEY_FREQUENT); + lookupKeyToRankMap.put(lookupKey, rank++); + } + mFrequentContactsCursor.moveToPosition(oldPosition); + + // Second, go through the all contacts cursor once and retrieve all information + // (multiple phone numbers etc.) and store that in an array list. Since the all + // contacts list only contains phone contacts, this step will ensure that we filter + // out any invalid/email contacts in the frequents list. + final ArrayList rows = + new ArrayList(mFrequentContactsCursor.getCount()); + oldPosition = mAllContactsCursor.getPosition(); + mAllContactsCursor.moveToPosition(-1); + while (mAllContactsCursor.moveToNext()) { + final String lookupKey = mAllContactsCursor.getString(ContactUtil.INDEX_LOOKUP_KEY); + if (lookupKeyToRankMap.containsKey(lookupKey)) { + final Object[] row = new Object[ContactUtil.PhoneQuery.PROJECTION.length]; + row[ContactUtil.INDEX_DATA_ID] = + mAllContactsCursor.getLong(ContactUtil.INDEX_DATA_ID); + row[ContactUtil.INDEX_CONTACT_ID] = + mAllContactsCursor.getLong(ContactUtil.INDEX_CONTACT_ID); + row[ContactUtil.INDEX_LOOKUP_KEY] = + mAllContactsCursor.getString(ContactUtil.INDEX_LOOKUP_KEY); + row[ContactUtil.INDEX_DISPLAY_NAME] = + mAllContactsCursor.getString(ContactUtil.INDEX_DISPLAY_NAME); + row[ContactUtil.INDEX_PHOTO_URI] = + mAllContactsCursor.getString(ContactUtil.INDEX_PHOTO_URI); + row[ContactUtil.INDEX_PHONE_EMAIL] = + mAllContactsCursor.getString(ContactUtil.INDEX_PHONE_EMAIL); + row[ContactUtil.INDEX_PHONE_EMAIL_TYPE] = + mAllContactsCursor.getInt(ContactUtil.INDEX_PHONE_EMAIL_TYPE); + row[ContactUtil.INDEX_PHONE_EMAIL_LABEL] = + mAllContactsCursor.getString(ContactUtil.INDEX_PHONE_EMAIL_LABEL); + rows.add(row); + } + } + mAllContactsCursor.moveToPosition(oldPosition); + + // Now we have a list of rows containing frequent contacts in alphabetical order. + // Therefore, sort all the rows according to their actual ranks in the frequents list. + Collections.sort(rows, new Comparator() { + @Override + public int compare(final Object[] lhs, final Object[] rhs) { + final String lookupKeyLhs = (String) lhs[ContactUtil.INDEX_LOOKUP_KEY]; + final String lookupKeyRhs = (String) rhs[ContactUtil.INDEX_LOOKUP_KEY]; + Assert.isTrue(lookupKeyToRankMap.containsKey(lookupKeyLhs) && + lookupKeyToRankMap.containsKey(lookupKeyRhs)); + final int rankLhs = lookupKeyToRankMap.get(lookupKeyLhs); + final int rankRhs = lookupKeyToRankMap.get(lookupKeyRhs); + if (rankLhs < rankRhs) { + return -1; + } else if (rankLhs > rankRhs) { + return 1; + } else { + // Same rank, so it's two contact records for the same contact. + // Perform secondary sorting on the phone type. Always place + // mobile before everything else. + final int phoneTypeLhs = (int) lhs[ContactUtil.INDEX_PHONE_EMAIL_TYPE]; + final int phoneTypeRhs = (int) rhs[ContactUtil.INDEX_PHONE_EMAIL_TYPE]; + if (phoneTypeLhs == Phone.TYPE_MOBILE && + phoneTypeRhs == Phone.TYPE_MOBILE) { + return 0; + } else if (phoneTypeLhs == Phone.TYPE_MOBILE) { + return -1; + } else if (phoneTypeRhs == Phone.TYPE_MOBILE) { + return 1; + } else { + // Use the default sort order, i.e. sort by phoneType value. + return phoneTypeLhs < phoneTypeRhs ? -1 : + (phoneTypeLhs == phoneTypeRhs ? 0 : 1); + } + } + } + }); + + // Finally, add all the rows to this cursor. + for (final Object[] row : rows) { + retCursor.addRow(row); + } + return retCursor; + } + return null; + } +} diff --git a/src/com/android/messaging/datamodel/FrequentContactsCursorQueryData.java b/src/com/android/messaging/datamodel/FrequentContactsCursorQueryData.java new file mode 100644 index 0000000..d1759ad --- /dev/null +++ b/src/com/android/messaging/datamodel/FrequentContactsCursorQueryData.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; + +import com.android.messaging.util.FallbackStrategies; +import com.android.messaging.util.FallbackStrategies.Strategy; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; + +/** + * Helper for querying frequent (and/or starred) contacts. + */ +public class FrequentContactsCursorQueryData extends CursorQueryData { + private static final String TAG = LogUtil.BUGLE_TAG; + + private static class FrequentContactsCursorLoader extends BoundCursorLoader { + private final Uri mOriginalUri; + + FrequentContactsCursorLoader(String bindingId, Context context, Uri uri, + String[] projection, String selection, String[] selectionArgs, String sortOrder) { + super(bindingId, context, uri, projection, selection, selectionArgs, sortOrder); + mOriginalUri = uri; + } + + @Override + public Cursor loadInBackground() { + return FallbackStrategies + .startWith(new PrimaryStrequentContactsQueryStrategy()) + .thenTry(new FrequentOnlyContactsQueryStrategy()) + .thenTry(new PhoneOnlyStrequentContactsQueryStrategy()) + .execute(null); + } + + private abstract class StrequentContactsQueryStrategy implements Strategy { + @Override + public Cursor execute(Void params) throws Exception { + final Uri uri = getUri(); + if (uri != null) { + setUri(uri); + } + return FrequentContactsCursorLoader.super.loadInBackground(); + } + protected abstract Uri getUri(); + } + + private class PrimaryStrequentContactsQueryStrategy extends StrequentContactsQueryStrategy { + @Override + protected Uri getUri() { + // Use the original URI requested. + return mOriginalUri; + } + } + + private class FrequentOnlyContactsQueryStrategy extends StrequentContactsQueryStrategy { + @Override + protected Uri getUri() { + // Some phones have a buggy implementation of the Contacts provider which crashes + // when we query for strequent (starred+frequent) contacts (b/17991485). + // If this happens, switch to just querying for frequent contacts. + return Contacts.CONTENT_FREQUENT_URI; + } + } + + private class PhoneOnlyStrequentContactsQueryStrategy extends + StrequentContactsQueryStrategy { + @Override + protected Uri getUri() { + // Some 3rd party ROMs have content provider + // implementation where invalid SQL queries are returned for regular strequent + // queries. Using strequent_phone_only query as a fallback to display only phone + // contacts. This is the last-ditch effort; if this fails, we will display an + // empty frequent list (b/18354836). + final String strequentQueryParam = OsUtil.isAtLeastL() ? + ContactsContract.STREQUENT_PHONE_ONLY : "strequent_phone_only"; + // TODO: Handle enterprise contacts post M once contacts provider supports it + return Contacts.CONTENT_STREQUENT_URI.buildUpon() + .appendQueryParameter(strequentQueryParam, "true").build(); + } + } + } + + public FrequentContactsCursorQueryData(Context context, String[] projection, + String selection, String[] selectionArgs, String sortOrder) { + // TODO: Handle enterprise contacts post M once contacts provider supports it + super(context, Contacts.CONTENT_STREQUENT_URI, projection, selection, selectionArgs, + sortOrder); + } + + @Override + public BoundCursorLoader createBoundCursorLoader(String bindingId) { + return new FrequentContactsCursorLoader(bindingId, mContext, mUri, mProjection, mSelection, + mSelectionArgs, mSortOrder); + } +} diff --git a/src/com/android/messaging/datamodel/GalleryBoundCursorLoader.java b/src/com/android/messaging/datamodel/GalleryBoundCursorLoader.java new file mode 100644 index 0000000..28ec303 --- /dev/null +++ b/src/com/android/messaging/datamodel/GalleryBoundCursorLoader.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.content.Context; +import android.net.Uri; +import android.provider.MediaStore.Files; +import android.provider.MediaStore.Files.FileColumns; +import android.provider.MediaStore.Images.Media; + +import com.android.messaging.datamodel.data.GalleryGridItemData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.google.common.base.Joiner; + +/** + * A BoundCursorLoader that reads local media on the device. + */ +public class GalleryBoundCursorLoader extends BoundCursorLoader { + public static final String MEDIA_SCANNER_VOLUME_EXTERNAL = "external"; + private static final Uri STORAGE_URI = Files.getContentUri(MEDIA_SCANNER_VOLUME_EXTERNAL); + private static final String SORT_ORDER = Media.DATE_MODIFIED + " DESC"; + private static final String IMAGE_SELECTION = createSelection( + MessagePartData.ACCEPTABLE_IMAGE_TYPES, + new Integer[] { FileColumns.MEDIA_TYPE_IMAGE }); + + public GalleryBoundCursorLoader(final String bindingId, final Context context) { + super(bindingId, context, STORAGE_URI, GalleryGridItemData.IMAGE_PROJECTION, + IMAGE_SELECTION, null, SORT_ORDER); + } + + private static String createSelection(final String[] mimeTypes, Integer[] mediaTypes) { + return Media.MIME_TYPE + " IN ('" + Joiner.on("','").join(mimeTypes) + "') AND " + + FileColumns.MEDIA_TYPE + " IN (" + Joiner.on(',').join(mediaTypes) + ")"; + } +} diff --git a/src/com/android/messaging/datamodel/MediaScratchFileProvider.java b/src/com/android/messaging/datamodel/MediaScratchFileProvider.java new file mode 100644 index 0000000..29ae4f4 --- /dev/null +++ b/src/com/android/messaging/datamodel/MediaScratchFileProvider.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.MatrixCursor.RowBuilder; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.support.v4.util.SimpleArrayMap; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.google.common.annotations.VisibleForTesting; + +import java.io.File; +import java.util.List; + +/** + * A very simple content provider that can serve media files from our cache directory. + */ +public class MediaScratchFileProvider extends FileProvider { + private static final String TAG = LogUtil.BUGLE_TAG; + + private static final SimpleArrayMap sUriToDisplayNameMap = + new SimpleArrayMap(); + + @VisibleForTesting + public static final String AUTHORITY = + "com.android.messaging.datamodel.MediaScratchFileProvider"; + private static final String MEDIA_SCRATCH_SPACE_DIR = "mediascratchspace"; + + public static boolean isMediaScratchSpaceUri(final Uri uri) { + if (uri == null) { + return false; + } + + final List segments = uri.getPathSegments(); + return (TextUtils.equals(uri.getScheme(), ContentResolver.SCHEME_CONTENT) && + TextUtils.equals(uri.getAuthority(), AUTHORITY) && + segments.size() == 1 && FileProvider.isValidFileId(segments.get(0))); + } + + /** + * Returns a uri that can be used to access a raw mms file. + * + * @return the URI for an raw mms file + */ + public static Uri buildMediaScratchSpaceUri(final String extension) { + final Uri uri = FileProvider.buildFileUri(AUTHORITY, extension); + final File file = getFileWithExtension(uri.getPath(), extension); + if (!ensureFileExists(file)) { + LogUtil.e(TAG, "Failed to create temp file " + file.getAbsolutePath()); + } + return uri; + } + + public static File getFileFromUri(final Uri uri) { + Assert.equals(AUTHORITY, uri.getAuthority()); + return getFileWithExtension(uri.getPath(), getExtensionFromUri(uri)); + } + + public static Uri.Builder getUriBuilder() { + return (new Uri.Builder()).authority(AUTHORITY).scheme(ContentResolver.SCHEME_CONTENT); + } + + @Override + File getFile(final String path, final String extension) { + return getFileWithExtension(path, extension); + } + + private static File getFileWithExtension(final String path, final String extension) { + final Context context = Factory.get().getApplicationContext(); + return new File(getDirectory(context), + TextUtils.isEmpty(extension) ? path : path + "." + extension); + } + + private static File getDirectory(final Context context) { + return new File(context.getCacheDir(), MEDIA_SCRATCH_SPACE_DIR); + } + + @Override + public Cursor query(final Uri uri, final String[] projection, final String selection, + final String[] selectionArgs, final String sortOrder) { + if (projection != null && projection.length > 0 && + TextUtils.equals(projection[0], OpenableColumns.DISPLAY_NAME) && + isMediaScratchSpaceUri(uri)) { + // Retrieve the display name associated with a temp file. This is used by the Contacts + // ImportVCardActivity to retrieve the name of the contact(s) being imported. + String displayName; + synchronized (sUriToDisplayNameMap) { + displayName = sUriToDisplayNameMap.get(uri); + } + if (!TextUtils.isEmpty(displayName)) { + MatrixCursor cursor = + new MatrixCursor(new String[] { OpenableColumns.DISPLAY_NAME }); + RowBuilder row = cursor.newRow(); + row.add(displayName); + return cursor; + } + } + return null; + } + + public static void addUriToDisplayNameEntry(final Uri scratchFileUri, + final String displayName) { + if (TextUtils.isEmpty(displayName)) { + return; + } + synchronized (sUriToDisplayNameMap) { + sUriToDisplayNameMap.put(scratchFileUri, displayName); + } + } +} diff --git a/src/com/android/messaging/datamodel/MemoryCacheManager.java b/src/com/android/messaging/datamodel/MemoryCacheManager.java new file mode 100644 index 0000000..0968cff --- /dev/null +++ b/src/com/android/messaging/datamodel/MemoryCacheManager.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import com.android.messaging.Factory; + +import java.util.HashSet; + +/** + * Utility abstraction which allows MemoryCaches in an application to register and then when there + * is memory pressure provide a callback to reclaim the memory in the caches. + */ +public class MemoryCacheManager { + private final HashSet mMemoryCaches = new HashSet(); + private final Object mMemoryCacheLock = new Object(); + + public static MemoryCacheManager get() { + return Factory.get().getMemoryCacheManager(); + } + + /** + * Extend this interface to provide a reclaim method on a memory cache. + */ + public interface MemoryCache { + void reclaim(); + } + + /** + * Register the memory cache with the application. + */ + public void registerMemoryCache(final MemoryCache cache) { + synchronized (mMemoryCacheLock) { + mMemoryCaches.add(cache); + } + } + + /** + * Unregister the memory cache with the application. + */ + public void unregisterMemoryCache(final MemoryCache cache) { + synchronized (mMemoryCacheLock) { + mMemoryCaches.remove(cache); + } + } + + /** + * Reclaim memory in all the memory caches in the application. + */ + @SuppressWarnings("unchecked") + public void reclaimMemory() { + // We're creating a cache copy in the lock to ensure we're not working on a concurrently + // modified set, then reclaim outside of the lock to minimize the time within the lock. + final HashSet shallowCopy; + synchronized (mMemoryCacheLock) { + shallowCopy = (HashSet) mMemoryCaches.clone(); + } + for (final MemoryCache cache : shallowCopy) { + cache.reclaim(); + } + } +} diff --git a/src/com/android/messaging/datamodel/MessageNotificationState.java b/src/com/android/messaging/datamodel/MessageNotificationState.java new file mode 100644 index 0000000..0bd4aaa --- /dev/null +++ b/src/com/android/messaging/datamodel/MessageNotificationState.java @@ -0,0 +1,1342 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Typeface; +import android.net.Uri; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.NotificationCompat.Builder; +import android.support.v4.app.NotificationCompat.WearableExtender; +import android.support.v4.app.NotificationManagerCompat; +import android.text.Html; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.text.style.TextAppearanceSpan; +import android.text.style.URLSpan; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.ConversationMessageData; +import com.android.messaging.datamodel.data.ConversationParticipantsData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.media.VideoThumbnailRequest; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.ConversationIdSet; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.PendingIntentConstants; +import com.android.messaging.util.UriUtil; +import com.google.common.collect.Lists; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Notification building class for conversation messages. + * + * Message Notifications are built in several stages with several utility classes. + * 1) Perform a database query and fill a data structure with information on messages and + * conversations which need to be notified. + * 2) Based on the data structure choose an appropriate NotificationState subclass to + * represent all the notifications. + * -- For one or more messages in one conversation: MultiMessageNotificationState. + * -- For multiple messages in multiple conversations: MultiConversationNotificationState + * + * A three level structure is used to coalesce the data from the database. From bottom to top: + * 1) NotificationLineInfo - A single message that needs to be notified. + * 2) ConversationLineInfo - A list of NotificationLineInfo in a single conversation. + * 3) ConversationInfoList - A list of ConversationLineInfo and the total number of messages. + * + * The createConversationInfoList function performs the query and creates the data structure. + */ +public abstract class MessageNotificationState extends NotificationState { + // Logging + static final String TAG = LogUtil.BUGLE_NOTIFICATIONS_TAG; + private static final int MAX_MESSAGES_IN_WEARABLE_PAGE = 20; + + private static final int MAX_CHARACTERS_IN_GROUP_NAME = 30; + + private static final int REPLY_INTENT_REQUEST_CODE_OFFSET = 0; + private static final int NUM_EXTRA_REQUEST_CODES_NEEDED = 1; + protected String mTickerSender = null; + protected CharSequence mTickerText = null; + protected String mTitle = null; + protected CharSequence mContent = null; + protected Uri mAttachmentUri = null; + protected String mAttachmentType = null; + protected boolean mTickerNoContent; + + @Override + protected Uri getAttachmentUri() { + return mAttachmentUri; + } + + @Override + protected String getAttachmentType() { + return mAttachmentType; + } + + @Override + public int getIcon() { + return R.drawable.ic_sms_light; + } + + @Override + public int getPriority() { + // Returning PRIORITY_HIGH causes L to put up a HUD notification. Without it, the ticker + // isn't displayed. + return Notification.PRIORITY_HIGH; + } + + /** + * Base class for single notification events for messages. Multiple of these + * may be grouped into a single conversation. + */ + static class NotificationLineInfo { + + final int mNotificationType; + + NotificationLineInfo() { + mNotificationType = BugleNotifications.LOCAL_SMS_NOTIFICATION; + } + + NotificationLineInfo(final int notificationType) { + mNotificationType = notificationType; + } + } + + /** + * Information on a single chat message which should be shown in a notification. + */ + static class MessageLineInfo extends NotificationLineInfo { + final CharSequence mText; + Uri mAttachmentUri; + String mAttachmentType; + final String mAuthorFullName; + final String mAuthorFirstName; + boolean mIsManualDownloadNeeded; + final String mMessageId; + + MessageLineInfo(final boolean isGroup, final String authorFullName, + final String authorFirstName, final CharSequence text, final Uri attachmentUrl, + final String attachmentType, final boolean isManualDownloadNeeded, + final String messageId) { + super(BugleNotifications.LOCAL_SMS_NOTIFICATION); + mAuthorFullName = authorFullName; + mAuthorFirstName = authorFirstName; + mText = text; + mAttachmentUri = attachmentUrl; + mAttachmentType = attachmentType; + mIsManualDownloadNeeded = isManualDownloadNeeded; + mMessageId = messageId; + } + } + + /** + * Information on all the notification messages within a single conversation. + */ + static class ConversationLineInfo { + // Conversation id of the latest message in the notification for this merged conversation. + final String mConversationId; + + // True if this represents a group conversation. + final boolean mIsGroup; + + // Name of the group conversation if available. + final String mGroupConversationName; + + // True if this conversation's recipients includes one or more email address(es) + // (see ConversationColumns.INCLUDE_EMAIL_ADDRESS) + final boolean mIncludeEmailAddress; + + // Timestamp of the latest message + final long mReceivedTimestamp; + + // Self participant id. + final String mSelfParticipantId; + + // List of individual line notifications to be parsed later. + final List mLineInfos; + + // Total number of messages. Might be different that mLineInfos.size() as the number of + // line infos is capped. + int mTotalMessageCount; + + // Custom ringtone if set + final String mRingtoneUri; + + // Should notification be enabled for this conversation? + final boolean mNotificationEnabled; + + // Should notifications vibrate for this conversation? + final boolean mNotificationVibrate; + + // Avatar uri of sender + final Uri mAvatarUri; + + // Contact uri of sender + final Uri mContactUri; + + // Subscription id. + final int mSubId; + + // Number of participants + final int mParticipantCount; + + public ConversationLineInfo(final String conversationId, + final boolean isGroup, + final String groupConversationName, + final boolean includeEmailAddress, + final long receivedTimestamp, + final String selfParticipantId, + final String ringtoneUri, + final boolean notificationEnabled, + final boolean notificationVibrate, + final Uri avatarUri, + final Uri contactUri, + final int subId, + final int participantCount) { + mConversationId = conversationId; + mIsGroup = isGroup; + mGroupConversationName = groupConversationName; + mIncludeEmailAddress = includeEmailAddress; + mReceivedTimestamp = receivedTimestamp; + mSelfParticipantId = selfParticipantId; + mLineInfos = new ArrayList(); + mTotalMessageCount = 0; + mRingtoneUri = ringtoneUri; + mAvatarUri = avatarUri; + mContactUri = contactUri; + mNotificationEnabled = notificationEnabled; + mNotificationVibrate = notificationVibrate; + mSubId = subId; + mParticipantCount = participantCount; + } + + public int getLatestMessageNotificationType() { + final MessageLineInfo messageLineInfo = getLatestMessageLineInfo(); + if (messageLineInfo == null) { + return BugleNotifications.LOCAL_SMS_NOTIFICATION; + } + return messageLineInfo.mNotificationType; + } + + public String getLatestMessageId() { + final MessageLineInfo messageLineInfo = getLatestMessageLineInfo(); + if (messageLineInfo == null) { + return null; + } + return messageLineInfo.mMessageId; + } + + public boolean getDoesLatestMessageNeedDownload() { + final MessageLineInfo messageLineInfo = getLatestMessageLineInfo(); + if (messageLineInfo == null) { + return false; + } + return messageLineInfo.mIsManualDownloadNeeded; + } + + private MessageLineInfo getLatestMessageLineInfo() { + // The latest message is stored at index zero of the message line infos. + if (mLineInfos.size() > 0 && mLineInfos.get(0) instanceof MessageLineInfo) { + return (MessageLineInfo) mLineInfos.get(0); + } + return null; + } + } + + /** + * Information on all the notification messages across all conversations. + */ + public static class ConversationInfoList { + final int mMessageCount; + final List mConvInfos; + public ConversationInfoList(final int count, final List infos) { + mMessageCount = count; + mConvInfos = infos; + } + } + + final ConversationInfoList mConvList; + private long mLatestReceivedTimestamp; + + private static ConversationIdSet makeConversationIdSet(final ConversationInfoList convList) { + ConversationIdSet set = null; + if (convList != null && convList.mConvInfos != null && convList.mConvInfos.size() > 0) { + set = new ConversationIdSet(); + for (final ConversationLineInfo info : convList.mConvInfos) { + set.add(info.mConversationId); + } + } + return set; + } + + protected MessageNotificationState(final ConversationInfoList convList) { + super(makeConversationIdSet(convList)); + mConvList = convList; + mType = PendingIntentConstants.SMS_NOTIFICATION_ID; + mLatestReceivedTimestamp = Long.MIN_VALUE; + if (convList != null) { + for (final ConversationLineInfo info : convList.mConvInfos) { + mLatestReceivedTimestamp = Math.max(mLatestReceivedTimestamp, + info.mReceivedTimestamp); + } + } + } + + @Override + public long getLatestReceivedTimestamp() { + return mLatestReceivedTimestamp; + } + + @Override + public int getNumRequestCodesNeeded() { + // Get additional request codes for the Reply PendingIntent (wearables only) + // and the DND PendingIntent. + return super.getNumRequestCodesNeeded() + NUM_EXTRA_REQUEST_CODES_NEEDED; + } + + private int getBaseExtraRequestCode() { + return mBaseRequestCode + super.getNumRequestCodesNeeded(); + } + + public int getReplyIntentRequestCode() { + return getBaseExtraRequestCode() + REPLY_INTENT_REQUEST_CODE_OFFSET; + } + + @Override + public PendingIntent getClearIntent() { + return UIIntents.get().getPendingIntentForClearingNotifications( + Factory.get().getApplicationContext(), + BugleNotifications.UPDATE_MESSAGES, + mConversationIds, + getClearIntentRequestCode()); + } + + /** + * Notification for multiple messages in at least 2 different conversations. + */ + public static class MultiConversationNotificationState extends MessageNotificationState { + + public final List + mChildren = new ArrayList(); + + public MultiConversationNotificationState( + final ConversationInfoList convList, final MessageNotificationState state) { + super(convList); + mAttachmentUri = null; + mAttachmentType = null; + + // Pull the ticker title/text from the single notification + mTickerSender = state.getTitle(); + mTitle = Factory.get().getApplicationContext().getResources().getQuantityString( + R.plurals.notification_new_messages, + convList.mMessageCount, convList.mMessageCount); + mTickerText = state.mContent; + + // Create child notifications for each conversation, + // which will be displayed (only) on a wearable device. + for (int i = 0; i < convList.mConvInfos.size(); i++) { + final ConversationLineInfo convInfo = convList.mConvInfos.get(i); + if (!(convInfo.mLineInfos.get(0) instanceof MessageLineInfo)) { + continue; + } + setPeopleForConversation(convInfo.mConversationId); + final ConversationInfoList list = new ConversationInfoList( + convInfo.mTotalMessageCount, Lists.newArrayList(convInfo)); + mChildren.add(new BundledMessageNotificationState(list, i)); + } + } + + @Override + public int getIcon() { + return R.drawable.ic_sms_multi_light; + } + + @Override + protected NotificationCompat.Style build(final Builder builder) { + builder.setContentTitle(mTitle); + NotificationCompat.InboxStyle inboxStyle = null; + inboxStyle = new NotificationCompat.InboxStyle(builder); + + final Context context = Factory.get().getApplicationContext(); + // enumeration_comma is defined as ", " + final String separator = context.getString(R.string.enumeration_comma); + final StringBuilder senders = new StringBuilder(); + long when = 0; + for (int i = 0; i < mConvList.mConvInfos.size(); i++) { + final ConversationLineInfo convInfo = mConvList.mConvInfos.get(i); + if (convInfo.mReceivedTimestamp > when) { + when = convInfo.mReceivedTimestamp; + } + String sender; + CharSequence text; + final NotificationLineInfo lineInfo = convInfo.mLineInfos.get(0); + final MessageLineInfo messageLineInfo = (MessageLineInfo) lineInfo; + if (convInfo.mIsGroup) { + sender = (convInfo.mGroupConversationName.length() > + MAX_CHARACTERS_IN_GROUP_NAME) ? + truncateGroupMessageName(convInfo.mGroupConversationName) + : convInfo.mGroupConversationName; + } else { + sender = messageLineInfo.mAuthorFullName; + } + text = messageLineInfo.mText; + mAttachmentUri = messageLineInfo.mAttachmentUri; + mAttachmentType = messageLineInfo.mAttachmentType; + + inboxStyle.addLine(BugleNotifications.formatInboxMessage( + sender, text, mAttachmentUri, mAttachmentType)); + if (sender != null) { + if (senders.length() > 0) { + senders.append(separator); + } + senders.append(sender); + } + } + // for collapsed state + mContent = senders; + builder.setContentText(senders) + .setTicker(getTicker()) + .setWhen(when); + + return inboxStyle; + } + } + + /** + * Truncate group conversation name to be displayed in the notifications. This either truncates + * the entire group name or finds the last comma in the available length and truncates the name + * at that point + */ + private static String truncateGroupMessageName(final String conversationName) { + int endIndex = MAX_CHARACTERS_IN_GROUP_NAME; + for (int i = MAX_CHARACTERS_IN_GROUP_NAME; i >= 0; i--) { + // The dividing marker should stay consistent with ConversationListItemData.DIVIDER_TEXT + if (conversationName.charAt(i) == ',') { + endIndex = i; + break; + } + } + return conversationName.substring(0, endIndex) + '\u2026'; + } + + /** + * Notification for multiple messages in a single conversation. Also used if there is a single + * message in a single conversation. + */ + public static class MultiMessageNotificationState extends MessageNotificationState { + + public MultiMessageNotificationState(final ConversationInfoList convList) { + super(convList); + // This conversation has been accepted. + final ConversationLineInfo convInfo = convList.mConvInfos.get(0); + setAvatarUrlsForConversation(convInfo.mConversationId); + setPeopleForConversation(convInfo.mConversationId); + + final Context context = Factory.get().getApplicationContext(); + MessageLineInfo messageInfo = (MessageLineInfo) convInfo.mLineInfos.get(0); + // attached photo + mAttachmentUri = messageInfo.mAttachmentUri; + mAttachmentType = messageInfo.mAttachmentType; + mContent = messageInfo.mText; + + if (mAttachmentUri != null) { + // The default attachment type is an image, since that's what was originally + // supported. When there's no content type, assume it's an image. + int message = R.string.notification_picture; + if (ContentType.isAudioType(mAttachmentType)) { + message = R.string.notification_audio; + } else if (ContentType.isVideoType(mAttachmentType)) { + message = R.string.notification_video; + } else if (ContentType.isVCardType(mAttachmentType)) { + message = R.string.notification_vcard; + } + final String attachment = context.getString(message); + final SpannableStringBuilder spanBuilder = new SpannableStringBuilder(); + if (!TextUtils.isEmpty(mContent)) { + spanBuilder.append(mContent).append(System.getProperty("line.separator")); + } + final int start = spanBuilder.length(); + spanBuilder.append(attachment); + spanBuilder.setSpan(new StyleSpan(Typeface.ITALIC), start, spanBuilder.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + mContent = spanBuilder; + } + if (convInfo.mIsGroup) { + // When the message is part of a group, the sender's first name + // is prepended to the message, but not for the ticker message. + mTickerText = mContent; + mTickerSender = messageInfo.mAuthorFullName; + // append the bold name to the front of the message + mContent = BugleNotifications.buildSpaceSeparatedMessage( + messageInfo.mAuthorFullName, mContent, mAttachmentUri, + mAttachmentType); + mTitle = convInfo.mGroupConversationName; + } else { + // No matter how many messages there are, since this is a 1:1, just + // get the author full name from the first one. + messageInfo = (MessageLineInfo) convInfo.mLineInfos.get(0); + mTitle = messageInfo.mAuthorFullName; + } + } + + @Override + protected NotificationCompat.Style build(final Builder builder) { + builder.setContentTitle(mTitle) + .setTicker(getTicker()); + + NotificationCompat.Style notifStyle = null; + final ConversationLineInfo convInfo = mConvList.mConvInfos.get(0); + final List lineInfos = convInfo.mLineInfos; + final int messageCount = lineInfos.size(); + // At this point, all the messages come from the same conversation. We need to load + // the sender's avatar and then finish building the notification on a callback. + + builder.setContentText(mContent); // for collapsed state + + if (messageCount == 1) { + final boolean shouldShowImage = ContentType.isImageType(mAttachmentType) + || (ContentType.isVideoType(mAttachmentType) + && VideoThumbnailRequest.shouldShowIncomingVideoThumbnails()); + if (mAttachmentUri != null && shouldShowImage) { + // Show "Picture" as the content + final MessageLineInfo messageLineInfo = (MessageLineInfo) lineInfos.get(0); + String authorFirstName = messageLineInfo.mAuthorFirstName; + + // For the collapsed state, just show "picture" unless this is a + // group conversation. If it's a group, show the sender name and + // "picture". + final CharSequence tickerTag = + BugleNotifications.formatAttachmentTag(authorFirstName, + mAttachmentType); + // For 1:1 notifications don't show first name in the notification, but + // do show it in the ticker text + CharSequence pictureTag = tickerTag; + if (!convInfo.mIsGroup) { + authorFirstName = null; + pictureTag = BugleNotifications.formatAttachmentTag(authorFirstName, + mAttachmentType); + } + builder.setContentText(pictureTag); + builder.setTicker(tickerTag); + + notifStyle = new NotificationCompat.BigPictureStyle(builder) + .setSummaryText(BugleNotifications.formatInboxMessage( + authorFirstName, + null, null, + null)); // expanded state, just show sender + } else { + notifStyle = new NotificationCompat.BigTextStyle(builder) + .bigText(mContent); + } + } else { + // We've got multiple messages for the same sender. + // Starting with the oldest new message, display the full text of each message. + // Begin a line for each subsequent message. + final SpannableStringBuilder buf = new SpannableStringBuilder(); + + for (int i = lineInfos.size() - 1; i >= 0; --i) { + final NotificationLineInfo info = lineInfos.get(i); + final MessageLineInfo messageLineInfo = (MessageLineInfo) info; + mAttachmentUri = messageLineInfo.mAttachmentUri; + mAttachmentType = messageLineInfo.mAttachmentType; + CharSequence text = messageLineInfo.mText; + if (!TextUtils.isEmpty(text) || mAttachmentUri != null) { + if (convInfo.mIsGroup) { + // append the bold name to the front of the message + text = BugleNotifications.buildSpaceSeparatedMessage( + messageLineInfo.mAuthorFullName, text, mAttachmentUri, + mAttachmentType); + } else { + text = BugleNotifications.buildSpaceSeparatedMessage( + null, text, mAttachmentUri, mAttachmentType); + } + buf.append(text); + if (i > 0) { + buf.append('\n'); + } + } + } + + // Show a single notification -- big style with the text of all the messages + notifStyle = new NotificationCompat.BigTextStyle(builder).bigText(buf); + } + builder.setWhen(convInfo.mReceivedTimestamp); + return notifStyle; + } + + } + + private static boolean firstNameUsedMoreThanOnce( + final HashMap map, final String firstName) { + if (map == null) { + return false; + } + if (firstName == null) { + return false; + } + final Integer count = map.get(firstName); + if (count != null) { + return count > 1; + } else { + return false; + } + } + + private static HashMap scanFirstNames(final String conversationId) { + final Context context = Factory.get().getApplicationContext(); + final Uri uri = + MessagingContentProvider.buildConversationParticipantsUri(conversationId); + final Cursor participantsCursor = context.getContentResolver().query( + uri, ParticipantData.ParticipantsQuery.PROJECTION, null, null, null); + final ConversationParticipantsData participantsData = new ConversationParticipantsData(); + participantsData.bind(participantsCursor); + final Iterator iter = participantsData.iterator(); + + final HashMap firstNames = new HashMap(); + boolean seenSelf = false; + while (iter.hasNext()) { + final ParticipantData participant = iter.next(); + // Make sure we only add the self participant once + if (participant.isSelf()) { + if (seenSelf) { + continue; + } else { + seenSelf = true; + } + } + + final String firstName = participant.getFirstName(); + if (firstName == null) { + continue; + } + + final int currentCount = firstNames.containsKey(firstName) + ? firstNames.get(firstName) + : 0; + firstNames.put(firstName, currentCount + 1); + } + return firstNames; + } + + // Essentially, we're building a list of the past 20 messages for this conversation to display + // on the wearable. + public static Notification buildConversationPageForWearable(final String conversationId, + int participantCount) { + final Context context = Factory.get().getApplicationContext(); + + // Limit the number of messages to show. We just want enough to provide context for the + // notification. Fetch one more than we need, so we can tell if there are more messages + // before the one we're showing. + // TODO: in the query, a multipart message will contain a row for each part. + // We might need a smarter GROUP_BY. On the other hand, we might want to show each of the + // parts as separate messages on the wearable. + final int limit = MAX_MESSAGES_IN_WEARABLE_PAGE + 1; + + final List messages = Lists.newArrayList(); + boolean hasSeenMessagesBeforeNotification = false; + Cursor convMessageCursor = null; + try { + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final String[] queryArgs = { conversationId }; + final String convPageSql = ConversationMessageData.getWearableQuerySql() + " LIMIT " + + limit; + convMessageCursor = db.rawQuery( + convPageSql, + queryArgs); + + if (convMessageCursor == null || !convMessageCursor.moveToFirst()) { + return null; + } + final ConversationMessageData convMessageData = + new ConversationMessageData(); + + final HashMap firstNames = scanFirstNames(conversationId); + do { + convMessageData.bind(convMessageCursor); + + final String authorFullName = convMessageData.getSenderFullName(); + final String authorFirstName = convMessageData.getSenderFirstName(); + String text = convMessageData.getText(); + + final boolean isSmsPushNotification = convMessageData.getIsMmsNotification(); + + // if auto-download was off to show a message to tap to download the message. We + // might need to get that working again. + if (isSmsPushNotification && text != null) { + text = convertHtmlAndStripUrls(text).toString(); + } + // Skip messages without any content + if (TextUtils.isEmpty(text) && !convMessageData.hasAttachments()) { + continue; + } + // Track whether there are messages prior to the one(s) shown in the notification. + if (convMessageData.getIsSeen()) { + hasSeenMessagesBeforeNotification = true; + } + + final boolean usedMoreThanOnce = firstNameUsedMoreThanOnce( + firstNames, authorFirstName); + String displayName = usedMoreThanOnce ? authorFullName : authorFirstName; + if (TextUtils.isEmpty(displayName)) { + if (convMessageData.getIsIncoming()) { + displayName = convMessageData.getSenderDisplayDestination(); + if (TextUtils.isEmpty(displayName)) { + displayName = context.getString(R.string.unknown_sender); + } + } else { + displayName = context.getString(R.string.unknown_self_participant); + } + } + + Uri attachmentUri = null; + String attachmentType = null; + final List attachments = convMessageData.getAttachments(); + for (final MessagePartData messagePartData : attachments) { + // Look for the first attachment that's not the text piece. + if (!messagePartData.isText()) { + attachmentUri = messagePartData.getContentUri(); + attachmentType = messagePartData.getContentType(); + break; + } + } + + final CharSequence message = BugleNotifications.buildSpaceSeparatedMessage( + displayName, text, attachmentUri, attachmentType); + messages.add(message); + + } while (convMessageCursor.moveToNext()); + } finally { + if (convMessageCursor != null) { + convMessageCursor.close(); + } + } + + // If there is no conversation history prior to what is already visible in the main + // notification, there's no need to include the conversation log, too. + final int maxMessagesInNotification = getMaxMessagesInConversationNotification(); + if (!hasSeenMessagesBeforeNotification && messages.size() <= maxMessagesInNotification) { + return null; + } + + final SpannableStringBuilder bigText = new SpannableStringBuilder(); + // There is at least 1 message prior to the first one that we're going to show. + // Indicate this by inserting an ellipsis at the beginning of the conversation log. + if (convMessageCursor.getCount() == limit) { + bigText.append(context.getString(R.string.ellipsis) + "\n\n"); + if (messages.size() > MAX_MESSAGES_IN_WEARABLE_PAGE) { + messages.remove(messages.size() - 1); + } + } + // Messages are sorted in descending timestamp order, so iterate backwards + // to get them back in ascending order for display purposes. + for (int i = messages.size() - 1; i >= 0; --i) { + bigText.append(messages.get(i)); + if (i > 0) { + bigText.append("\n\n"); + } + } + ++participantCount; // Add in myself + + if (participantCount > 2) { + final SpannableString statusText = new SpannableString( + context.getResources().getQuantityString(R.plurals.wearable_participant_count, + participantCount, participantCount)); + statusText.setSpan(new ForegroundColorSpan(context.getResources().getColor( + R.color.wearable_notification_participants_count)), 0, statusText.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + bigText.append("\n\n").append(statusText); + } + + final NotificationCompat.Builder notifBuilder = new NotificationCompat.Builder(context); + final NotificationCompat.Style notifStyle = + new NotificationCompat.BigTextStyle(notifBuilder).bigText(bigText); + notifBuilder.setStyle(notifStyle); + + final WearableExtender wearableExtender = new WearableExtender(); + wearableExtender.setStartScrollBottom(true); + notifBuilder.extend(wearableExtender); + + return notifBuilder.build(); + } + + /** + * Notification for one or more messages in a single conversation, which is bundled together + * with notifications for other conversations on a wearable device. + */ + public static class BundledMessageNotificationState extends MultiMessageNotificationState { + public int mGroupOrder; + public BundledMessageNotificationState(final ConversationInfoList convList, + final int groupOrder) { + super(convList); + mGroupOrder = groupOrder; + } + } + + /** + * Performs a query on the database. + */ + private static ConversationInfoList createConversationInfoList() { + // Map key is conversation id. We use LinkedHashMap to ensure that entries are iterated in + // the same order they were originally added. We scan unseen messages from newest to oldest, + // so the corresponding conversations are added in that order, too. + final Map convLineInfos = new LinkedHashMap<>(); + int messageCount = 0; + + Cursor convMessageCursor = null; + try { + final Context context = Factory.get().getApplicationContext(); + final DatabaseWrapper db = DataModel.get().getDatabase(); + + convMessageCursor = db.rawQuery( + ConversationMessageData.getNotificationQuerySql(), + null); + + if (convMessageCursor != null && convMessageCursor.moveToFirst()) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "MessageNotificationState: Found unseen message notifications."); + } + final ConversationMessageData convMessageData = + new ConversationMessageData(); + + HashMap firstNames = null; + String conversationIdForFirstNames = null; + String groupConversationName = null; + final int maxMessages = getMaxMessagesInConversationNotification(); + + do { + convMessageData.bind(convMessageCursor); + + // First figure out if this is a valid message. + String authorFullName = convMessageData.getSenderFullName(); + String authorFirstName = convMessageData.getSenderFirstName(); + final String messageText = convMessageData.getText(); + + final String convId = convMessageData.getConversationId(); + final String messageId = convMessageData.getMessageId(); + + CharSequence text = messageText; + final boolean isManualDownloadNeeded = convMessageData.getIsMmsNotification(); + if (isManualDownloadNeeded) { + // Don't try and convert the text from html if it's sms and not a sms push + // notification. + Assert.equals(MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD, + convMessageData.getStatus()); + text = context.getResources().getString( + R.string.message_title_manual_download); + } + ConversationLineInfo currConvInfo = convLineInfos.get(convId); + if (currConvInfo == null) { + final ConversationListItemData convData = + ConversationListItemData.getExistingConversation(db, convId); + if (!convData.getNotificationEnabled()) { + // Skip conversations that have notifications disabled. + continue; + } + final int subId = BugleDatabaseOperations.getSelfSubscriptionId(db, + convData.getSelfId()); + groupConversationName = convData.getName(); + final Uri avatarUri = AvatarUriUtil.createAvatarUri( + convMessageData.getSenderProfilePhotoUri(), + convMessageData.getSenderFullName(), + convMessageData.getSenderNormalizedDestination(), + convMessageData.getSenderContactLookupKey()); + currConvInfo = new ConversationLineInfo(convId, + convData.getIsGroup(), + groupConversationName, + convData.getIncludeEmailAddress(), + convMessageData.getReceivedTimeStamp(), + convData.getSelfId(), + convData.getNotificationSoundUri(), + convData.getNotificationEnabled(), + convData.getNotifiationVibrate(), + avatarUri, + convMessageData.getSenderContactLookupUri(), + subId, + convData.getParticipantCount()); + convLineInfos.put(convId, currConvInfo); + } + // Prepare the message line + if (currConvInfo.mTotalMessageCount < maxMessages) { + if (currConvInfo.mIsGroup) { + if (authorFirstName == null) { + // authorFullName might be null as well. In that case, we won't + // show an author. That is better than showing all the group + // names again on the 2nd line. + authorFirstName = authorFullName; + } + } else { + // don't recompute this if we don't need to + if (!TextUtils.equals(conversationIdForFirstNames, convId)) { + firstNames = scanFirstNames(convId); + conversationIdForFirstNames = convId; + } + if (firstNames != null) { + final Integer count = firstNames.get(authorFirstName); + if (count != null && count > 1) { + authorFirstName = authorFullName; + } + } + + if (authorFullName == null) { + authorFullName = groupConversationName; + } + if (authorFirstName == null) { + authorFirstName = groupConversationName; + } + } + final String subjectText = MmsUtils.cleanseMmsSubject( + context.getResources(), + convMessageData.getMmsSubject()); + if (!TextUtils.isEmpty(subjectText)) { + final String subjectLabel = + context.getString(R.string.subject_label); + final SpannableStringBuilder spanBuilder = + new SpannableStringBuilder(); + + spanBuilder.append(context.getString(R.string.notification_subject, + subjectLabel, subjectText)); + spanBuilder.setSpan(new TextAppearanceSpan( + context, R.style.NotificationSubjectText), 0, + subjectLabel.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + if (!TextUtils.isEmpty(text)) { + // Now add the actual message text below the subject header. + spanBuilder.append(System.getProperty("line.separator") + text); + } + text = spanBuilder; + } + // If we've got attachments, find the best one. If one of the messages is + // a photo, save the url so we'll display a big picture notification. + // Otherwise, show the first one we find. + Uri attachmentUri = null; + String attachmentType = null; + final MessagePartData messagePartData = + getMostInterestingAttachment(convMessageData); + if (messagePartData != null) { + attachmentUri = messagePartData.getContentUri(); + attachmentType = messagePartData.getContentType(); + } + currConvInfo.mLineInfos.add(new MessageLineInfo(currConvInfo.mIsGroup, + authorFullName, authorFirstName, text, + attachmentUri, attachmentType, isManualDownloadNeeded, messageId)); + } + messageCount++; + currConvInfo.mTotalMessageCount++; + } while (convMessageCursor.moveToNext()); + } + } finally { + if (convMessageCursor != null) { + convMessageCursor.close(); + } + } + if (convLineInfos.isEmpty()) { + return null; + } else { + return new ConversationInfoList(messageCount, + Lists.newLinkedList(convLineInfos.values())); + } + } + + /** + * Scans all the attachments for a message and returns the most interesting one that we'll + * show in a notification. By order of importance, in case there are multiple attachments: + * 1- an image (because we can show the image as a BigPictureNotification) + * 2- a video (because we can show a video frame as a BigPictureNotification) + * 3- a vcard + * 4- an audio attachment + * @return MessagePartData for the most interesting part. Can be null. + */ + private static MessagePartData getMostInterestingAttachment( + final ConversationMessageData convMessageData) { + final List attachments = convMessageData.getAttachments(); + + MessagePartData imagePart = null; + MessagePartData audioPart = null; + MessagePartData vcardPart = null; + MessagePartData videoPart = null; + + // 99.99% of the time there will be 0 or 1 part, since receiving slideshows is so + // uncommon. + + // Remember the first of each type of part. + for (final MessagePartData messagePartData : attachments) { + if (messagePartData.isImage() && imagePart == null) { + imagePart = messagePartData; + } + if (messagePartData.isVideo() && videoPart == null) { + videoPart = messagePartData; + } + if (messagePartData.isVCard() && vcardPart == null) { + vcardPart = messagePartData; + } + if (messagePartData.isAudio() && audioPart == null) { + audioPart = messagePartData; + } + } + if (imagePart != null) { + return imagePart; + } else if (videoPart != null) { + return videoPart; + } else if (audioPart != null) { + return audioPart; + } else if (vcardPart != null) { + return vcardPart; + } + return null; + } + + private static int getMaxMessagesInConversationNotification() { + if (!BugleNotifications.isWearCompanionAppInstalled()) { + return BugleGservices.get().getInt( + BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION, + BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_DEFAULT); + } + return BugleGservices.get().getInt( + BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_WITH_WEARABLE, + BugleGservicesKeys.MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_WITH_WEARABLE_DEFAULT); + } + + /** + * Scans the database for messages that need to go into notifications. Creates the appropriate + * MessageNotificationState depending on if there are multiple senders, or + * messages from one sender. + * @return NotificationState for the notification created. + */ + public static NotificationState getNotificationState() { + MessageNotificationState state = null; + final ConversationInfoList convList = createConversationInfoList(); + + if (convList == null || convList.mConvInfos.size() == 0) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "MessageNotificationState: No unseen notifications"); + } + } else { + final ConversationLineInfo convInfo = convList.mConvInfos.get(0); + state = new MultiMessageNotificationState(convList); + + if (convList.mConvInfos.size() > 1) { + // We've got notifications across multiple conversations. Pass in the notification + // we just built of the most recent notification so we can use that to show the + // user the new message in the ticker. + state = new MultiConversationNotificationState(convList, state); + } else { + // For now, only show avatars for notifications for a single conversation. + if (convInfo.mAvatarUri != null) { + if (state.mParticipantAvatarsUris == null) { + state.mParticipantAvatarsUris = new ArrayList(1); + } + state.mParticipantAvatarsUris.add(convInfo.mAvatarUri); + } + if (convInfo.mContactUri != null) { + if (state.mParticipantContactUris == null) { + state.mParticipantContactUris = new ArrayList(1); + } + state.mParticipantContactUris.add(convInfo.mContactUri); + } + } + } + if (state != null && LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "MessageNotificationState: Notification state created" + + ", title = " + LogUtil.sanitizePII(state.mTitle) + + ", content = " + LogUtil.sanitizePII(state.mContent.toString())); + } + return state; + } + + protected String getTitle() { + return mTitle; + } + + @Override + public int getLatestMessageNotificationType() { + // This function is called to determine whether the most recent notification applies + // to an sms conversation or a hangout conversation. We have different ringtone/vibrate + // settings for both types of conversations. + if (mConvList.mConvInfos.size() > 0) { + final ConversationLineInfo convInfo = mConvList.mConvInfos.get(0); + return convInfo.getLatestMessageNotificationType(); + } + return BugleNotifications.LOCAL_SMS_NOTIFICATION; + } + + @Override + public String getRingtoneUri() { + if (mConvList.mConvInfos.size() > 0) { + return mConvList.mConvInfos.get(0).mRingtoneUri; + } + return null; + } + + @Override + public boolean getNotificationVibrate() { + if (mConvList.mConvInfos.size() > 0) { + return mConvList.mConvInfos.get(0).mNotificationVibrate; + } + return false; + } + + protected CharSequence getTicker() { + return BugleNotifications.buildColonSeparatedMessage( + mTickerSender != null ? mTickerSender : mTitle, + mTickerText != null ? mTickerText : (mTickerNoContent ? null : mContent), null, + null); + } + + private static CharSequence convertHtmlAndStripUrls(final String s) { + final Spanned text = Html.fromHtml(s); + if (text instanceof Spannable) { + stripUrls((Spannable) text); + } + return text; + } + + // Since we don't want to show URLs in notifications, a function + // to remove them in place. + private static void stripUrls(final Spannable text) { + final URLSpan[] spans = text.getSpans(0, text.length(), URLSpan.class); + for (final URLSpan span : spans) { + text.removeSpan(span); + } + } + + /* + private static void updateAlertStatusMessages(final long thresholdDeltaMs) { + // TODO may need this when supporting error notifications + final EsDatabaseHelper helper = EsDatabaseHelper.getDatabaseHelper(); + final ContentValues values = new ContentValues(); + final long nowMicros = System.currentTimeMillis() * 1000; + values.put(MessageColumns.ALERT_STATUS, "1"); + final String selection = + MessageColumns.ALERT_STATUS + "=0 AND (" + + MessageColumns.STATUS + "=" + EsProvider.MESSAGE_STATUS_FAILED_TO_SEND + " OR (" + + MessageColumns.STATUS + "!=" + EsProvider.MESSAGE_STATUS_ON_SERVER + " AND " + + MessageColumns.TIMESTAMP + "+" + thresholdDeltaMs*1000 + "<" + nowMicros + ")) "; + + final int updateCount = helper.getWritableDatabaseWrapper().update( + EsProvider.MESSAGES_TABLE, + values, + selection, + null); + if (updateCount > 0) { + EsConversationsData.notifyConversationsChanged(); + } + }*/ + + static CharSequence applyWarningTextColor(final Context context, + final CharSequence text) { + if (text == null) { + return null; + } + final SpannableStringBuilder spanBuilder = new SpannableStringBuilder(); + spanBuilder.append(text); + spanBuilder.setSpan(new ForegroundColorSpan(context.getResources().getColor( + R.color.notification_warning_color)), 0, text.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spanBuilder; + } + + /** + * Check for failed messages and post notifications as needed. + * TODO: Rewrite this as a NotificationState. + */ + public static void checkFailedMessages() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final Cursor messageDataCursor = db.query(DatabaseHelper.MESSAGES_TABLE, + MessageData.getProjection(), + FailedMessageQuery.FAILED_MESSAGES_WHERE_CLAUSE, + null /*selectionArgs*/, + null /*groupBy*/, + null /*having*/, + FailedMessageQuery.FAILED_ORDER_BY); + + try { + final Context context = Factory.get().getApplicationContext(); + final Resources resources = context.getResources(); + final NotificationManagerCompat notificationManager = + NotificationManagerCompat.from(context); + if (messageDataCursor != null) { + final MessageData messageData = new MessageData(); + + final HashSet conversationsWithFailedMessages = new HashSet(); + + // track row ids in case we want to display something that requires this + // information + final ArrayList failedMessages = new ArrayList(); + + int cursorPosition = -1; + final long when = 0; + + messageDataCursor.moveToPosition(-1); + while (messageDataCursor.moveToNext()) { + messageData.bind(messageDataCursor); + + final String conversationId = messageData.getConversationId(); + if (DataModel.get().isNewMessageObservable(conversationId)) { + // Don't post a system notification for an observable conversation + // because we already show an angry red annotation in the conversation + // itself or in the conversation preview snippet. + continue; + } + + cursorPosition = messageDataCursor.getPosition(); + failedMessages.add(cursorPosition); + conversationsWithFailedMessages.add(conversationId); + } + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "Found " + failedMessages.size() + " failed messages"); + } + if (failedMessages.size() > 0) { + final NotificationCompat.Builder builder = + new NotificationCompat.Builder(context); + + CharSequence line1; + CharSequence line2; + final boolean isRichContent = false; + ConversationIdSet conversationIds = null; + PendingIntent destinationIntent; + if (failedMessages.size() == 1) { + messageDataCursor.moveToPosition(cursorPosition); + messageData.bind(messageDataCursor); + final String conversationId = messageData.getConversationId(); + + // We have a single conversation, go directly to that conversation. + destinationIntent = UIIntents.get() + .getPendingIntentForConversationActivity(context, + conversationId, + null /*draft*/); + + conversationIds = ConversationIdSet.createSet(conversationId); + + final String failedMessgeSnippet = messageData.getMessageText(); + int failureStringId; + if (messageData.getStatus() == + MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED) { + failureStringId = + R.string.notification_download_failures_line1_singular; + } else { + failureStringId = R.string.notification_send_failures_line1_singular; + } + line1 = resources.getString(failureStringId); + line2 = failedMessgeSnippet; + // Set rich text for non-SMS messages or MMS push notification messages + // which we generate locally with rich text + // TODO- fix this +// if (messageData.isMmsInd()) { +// isRichContent = true; +// } + } else { + // We have notifications for multiple conversation, go to the conversation + // list. + destinationIntent = UIIntents.get() + .getPendingIntentForConversationListActivity(context); + + int line1StringId; + int line2PluralsId; + if (messageData.getStatus() == + MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED) { + line1StringId = + R.string.notification_download_failures_line1_plural; + line2PluralsId = R.plurals.notification_download_failures; + } else { + line1StringId = R.string.notification_send_failures_line1_plural; + line2PluralsId = R.plurals.notification_send_failures; + } + line1 = resources.getString(line1StringId); + line2 = resources.getQuantityString( + line2PluralsId, + conversationsWithFailedMessages.size(), + failedMessages.size(), + conversationsWithFailedMessages.size()); + } + line1 = applyWarningTextColor(context, line1); + line2 = applyWarningTextColor(context, line2); + + final PendingIntent pendingIntentForDelete = + UIIntents.get().getPendingIntentForClearingNotifications( + context, + BugleNotifications.UPDATE_ERRORS, + conversationIds, + 0); + + builder + .setContentTitle(line1) + .setTicker(line1) + .setWhen(when > 0 ? when : System.currentTimeMillis()) + .setSmallIcon(R.drawable.ic_failed_light) + .setDeleteIntent(pendingIntentForDelete) + .setContentIntent(destinationIntent) + .setSound(UriUtil.getUriForResourceId(context, R.raw.message_failure)); + if (isRichContent && !TextUtils.isEmpty(line2)) { + final NotificationCompat.InboxStyle inboxStyle = + new NotificationCompat.InboxStyle(builder); + if (line2 != null) { + inboxStyle.addLine(Html.fromHtml(line2.toString())); + } + builder.setStyle(inboxStyle); + } else { + builder.setContentText(line2); + } + + if (builder != null) { + notificationManager.notify( + BugleNotifications.buildNotificationTag( + PendingIntentConstants.MSG_SEND_ERROR, null), + PendingIntentConstants.MSG_SEND_ERROR, + builder.build()); + } + } else { + notificationManager.cancel( + BugleNotifications.buildNotificationTag( + PendingIntentConstants.MSG_SEND_ERROR, null), + PendingIntentConstants.MSG_SEND_ERROR); + } + } + } finally { + if (messageDataCursor != null) { + messageDataCursor.close(); + } + } + } +} diff --git a/src/com/android/messaging/datamodel/MessageTextStats.java b/src/com/android/messaging/datamodel/MessageTextStats.java new file mode 100644 index 0000000..2bd24ff --- /dev/null +++ b/src/com/android/messaging/datamodel/MessageTextStats.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.telephony.SmsMessage; + +import com.android.messaging.sms.MmsConfig; + +public class MessageTextStats { + private boolean mMessageLengthRequiresMms; + private int mMessageCount; + private int mCodePointsRemainingInCurrentMessage; + + public MessageTextStats() { + mCodePointsRemainingInCurrentMessage = Integer.MAX_VALUE; + } + + public int getNumMessagesToBeSent() { + return mMessageCount; + } + + public int getCodePointsRemainingInCurrentMessage() { + return mCodePointsRemainingInCurrentMessage; + } + + public boolean getMessageLengthRequiresMms() { + return mMessageLengthRequiresMms; + } + + public void updateMessageTextStats(final int selfSubId, final String messageText) { + final int[] params = SmsMessage.calculateLength(messageText, false); + /* SmsMessage.calculateLength returns an int[4] with: + * int[0] being the number of SMS's required, + * int[1] the number of code points used, + * int[2] is the number of code points remaining until the next message. + * int[3] is the encoding type that should be used for the message. + */ + mMessageCount = params[0]; + mCodePointsRemainingInCurrentMessage = params[2]; + + final MmsConfig mmsConfig = MmsConfig.get(selfSubId); + if (!mmsConfig.getMultipartSmsEnabled() && + !mmsConfig.getSendMultipartSmsAsSeparateMessages()) { + // The provider doesn't support multi-part sms's and we should use MMS to + // send multi-part sms, so as soon as the user types + // an sms longer than one segment, we have to turn the message into an mms. + mMessageLengthRequiresMms = mMessageCount > 1; + } else { + final int threshold = mmsConfig.getSmsToMmsTextThreshold(); + mMessageLengthRequiresMms = threshold > 0 && mMessageCount > threshold; + } + // Some carriers require any SMS message longer than 80 to be sent as MMS + // see b/12122333 + int smsToMmsLengthThreshold = mmsConfig.getSmsToMmsTextLengthThreshold(); + if (smsToMmsLengthThreshold > 0) { + final int usedInCurrentMessage = params[1]; + /* + * A little hacky way to find out if we should count characters in double bytes. + * SmsMessage.calculateLength counts message code units based on the characters + * in input. If all of them are ascii, the max length is + * SmsMessage.MAX_USER_DATA_SEPTETS (160). If any of them are double-byte, like + * Korean or Chinese, the max length is SmsMessage.MAX_USER_DATA_BYTES (140) bytes + * (70 code units). + * Here we check if the total code units we can use is smaller than 140. If so, + * we know we should count threshold in double-byte, so divide the threshold by 2. + * In this way, we will count Korean text correctly with regard to the length threshold. + */ + if (usedInCurrentMessage + mCodePointsRemainingInCurrentMessage + < SmsMessage.MAX_USER_DATA_BYTES) { + smsToMmsLengthThreshold /= 2; + } + if (usedInCurrentMessage > smsToMmsLengthThreshold) { + mMessageLengthRequiresMms = true; + } + } + } + +} diff --git a/src/com/android/messaging/datamodel/MessagingContentProvider.java b/src/com/android/messaging/datamodel/MessagingContentProvider.java new file mode 100644 index 0000000..7688abd --- /dev/null +++ b/src/com/android/messaging/datamodel/MessagingContentProvider.java @@ -0,0 +1,476 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel; + +import android.content.ContentProvider; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.UriMatcher; +import android.database.Cursor; +import android.database.sqlite.SQLiteQueryBuilder; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.text.TextUtils; + +import com.android.messaging.BugleApplication; +import com.android.messaging.Factory; +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns; +import com.android.messaging.datamodel.DatabaseHelper.ConversationParticipantsColumns; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.ConversationMessageData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.widget.BugleWidgetProvider; +import com.android.messaging.widget.WidgetConversationProvider; +import com.google.common.annotations.VisibleForTesting; + +import java.io.FileDescriptor; +import java.io.FileNotFoundException; +import java.io.PrintWriter; + +/** + * A centralized provider for Uris exposed by Bugle. + * */ +public class MessagingContentProvider extends ContentProvider { + private static final String TAG = LogUtil.BUGLE_TAG; + + @VisibleForTesting + public static final String AUTHORITY = + "com.android.messaging.datamodel.MessagingContentProvider"; + private static final String CONTENT_AUTHORITY = "content://" + AUTHORITY + '/'; + + // Conversations query + private static final String CONVERSATIONS_QUERY = "conversations"; + + public static final Uri CONVERSATIONS_URI = Uri.parse(CONTENT_AUTHORITY + CONVERSATIONS_QUERY); + static final Uri PARTS_URI = Uri.parse(CONTENT_AUTHORITY + DatabaseHelper.PARTS_TABLE); + + // Messages query + private static final String MESSAGES_QUERY = "messages"; + + static final Uri MESSAGES_URI = Uri.parse(CONTENT_AUTHORITY + MESSAGES_QUERY); + + public static final Uri CONVERSATION_MESSAGES_URI = Uri.parse(CONTENT_AUTHORITY + + MESSAGES_QUERY + "/conversation"); + + // Conversation participants query + private static final String PARTICIPANTS_QUERY = "participants"; + + static class ConversationParticipantsQueryColumns extends ParticipantColumns { + static final String CONVERSATION_ID = ConversationParticipantsColumns.CONVERSATION_ID; + } + + static final Uri CONVERSATION_PARTICIPANTS_URI = Uri.parse(CONTENT_AUTHORITY + + PARTICIPANTS_QUERY + "/conversation"); + + public static final Uri PARTICIPANTS_URI = Uri.parse(CONTENT_AUTHORITY + PARTICIPANTS_QUERY); + + // Conversation images query + private static final String CONVERSATION_IMAGES_QUERY = "conversation_images"; + + public static final Uri CONVERSATION_IMAGES_URI = Uri.parse(CONTENT_AUTHORITY + + CONVERSATION_IMAGES_QUERY); + + private static final String DRAFT_IMAGES_QUERY = "draft_images"; + + public static final Uri DRAFT_IMAGES_URI = Uri.parse(CONTENT_AUTHORITY + + DRAFT_IMAGES_QUERY); + + /** + * Notifies that all data exposed by the provider needs to be refreshed. + *

+ * IMPORTANT! You probably shouldn't be calling this. Prefer to notify more specific + * uri's instead. Currently only sync uses this, because sync can potentially update many + * different tables at once. + */ + public static void notifyEverythingChanged() { + final Uri uri = Uri.parse(CONTENT_AUTHORITY); + final Context context = Factory.get().getApplicationContext(); + final ContentResolver cr = context.getContentResolver(); + cr.notifyChange(uri, null); + + // Notify any conversations widgets the conversation list has changed. + BugleWidgetProvider.notifyConversationListChanged(context); + + // Notify all conversation widgets to update. + WidgetConversationProvider.notifyMessagesChanged(context, null /*conversationId*/); + } + + /** + * Build a participant uri from the conversation id. + */ + public static Uri buildConversationParticipantsUri(final String conversationId) { + final Uri.Builder builder = CONVERSATION_PARTICIPANTS_URI.buildUpon(); + builder.appendPath(conversationId); + return builder.build(); + } + + public static void notifyParticipantsChanged(final String conversationId) { + final Uri uri = buildConversationParticipantsUri(conversationId); + final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver(); + cr.notifyChange(uri, null); + } + + public static void notifyAllMessagesChanged() { + final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver(); + cr.notifyChange(CONVERSATION_MESSAGES_URI, null); + } + + public static void notifyAllParticipantsChanged() { + final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver(); + cr.notifyChange(CONVERSATION_PARTICIPANTS_URI, null); + } + + // Default value for unknown dimension of image + public static final int UNSPECIFIED_SIZE = -1; + + // Internal + private static final int CONVERSATIONS_QUERY_CODE = 10; + + private static final int CONVERSATION_QUERY_CODE = 20; + private static final int CONVERSATION_MESSAGES_QUERY_CODE = 30; + private static final int CONVERSATION_PARTICIPANTS_QUERY_CODE = 40; + private static final int CONVERSATION_IMAGES_QUERY_CODE = 50; + private static final int DRAFT_IMAGES_QUERY_CODE = 60; + private static final int PARTICIPANTS_QUERY_CODE = 70; + + // TODO: Move to a better structured URI namespace. + private static final UriMatcher sURIMatcher = new UriMatcher(UriMatcher.NO_MATCH); + static { + sURIMatcher.addURI(AUTHORITY, CONVERSATIONS_QUERY, CONVERSATIONS_QUERY_CODE); + sURIMatcher.addURI(AUTHORITY, CONVERSATIONS_QUERY + "/*", CONVERSATION_QUERY_CODE); + sURIMatcher.addURI(AUTHORITY, MESSAGES_QUERY + "/conversation/*", + CONVERSATION_MESSAGES_QUERY_CODE); + sURIMatcher.addURI(AUTHORITY, PARTICIPANTS_QUERY + "/conversation/*", + CONVERSATION_PARTICIPANTS_QUERY_CODE); + sURIMatcher.addURI(AUTHORITY, PARTICIPANTS_QUERY, PARTICIPANTS_QUERY_CODE); + sURIMatcher.addURI(AUTHORITY, CONVERSATION_IMAGES_QUERY + "/*", + CONVERSATION_IMAGES_QUERY_CODE); + sURIMatcher.addURI(AUTHORITY, DRAFT_IMAGES_QUERY + "/*", + DRAFT_IMAGES_QUERY_CODE); + } + + /** + * Build a messages uri from the conversation id. + */ + public static Uri buildConversationMessagesUri(final String conversationId) { + final Uri.Builder builder = CONVERSATION_MESSAGES_URI.buildUpon(); + builder.appendPath(conversationId); + return builder.build(); + } + + public static void notifyMessagesChanged(final String conversationId) { + final Uri uri = buildConversationMessagesUri(conversationId); + final Context context = Factory.get().getApplicationContext(); + final ContentResolver cr = context.getContentResolver(); + cr.notifyChange(uri, null); + notifyConversationListChanged(); + + // Notify the widget the messages changed + WidgetConversationProvider.notifyMessagesChanged(context, conversationId); + } + + /** + * Build a conversation metadata uri from a conversation id. + */ + public static Uri buildConversationMetadataUri(final String conversationId) { + final Uri.Builder builder = CONVERSATIONS_URI.buildUpon(); + builder.appendPath(conversationId); + return builder.build(); + } + + public static void notifyConversationMetadataChanged(final String conversationId) { + final Uri uri = buildConversationMetadataUri(conversationId); + final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver(); + cr.notifyChange(uri, null); + notifyConversationListChanged(); + } + + public static void notifyPartsChanged() { + final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver(); + cr.notifyChange(PARTS_URI, null); + } + + public static void notifyConversationListChanged() { + final Context context = Factory.get().getApplicationContext(); + final ContentResolver cr = context.getContentResolver(); + cr.notifyChange(CONVERSATIONS_URI, null); + + // Notify the widget the conversation list changed + BugleWidgetProvider.notifyConversationListChanged(context); + } + + /** + * Build a conversation images uri from a conversation id. + */ + public static Uri buildConversationImagesUri(final String conversationId) { + final Uri.Builder builder = CONVERSATION_IMAGES_URI.buildUpon(); + builder.appendPath(conversationId); + return builder.build(); + } + + /** + * Build a draft images uri from a conversation id. + */ + public static Uri buildDraftImagesUri(final String conversationId) { + final Uri.Builder builder = DRAFT_IMAGES_URI.buildUpon(); + builder.appendPath(conversationId); + return builder.build(); + } + + private DatabaseHelper mDatabaseHelper; + private DatabaseWrapper mDatabaseWrapper; + + public MessagingContentProvider() { + super(); + } + + @VisibleForTesting + public void setDatabaseForTest(final DatabaseWrapper db) { + Assert.isTrue(BugleApplication.isRunningTests()); + mDatabaseWrapper = db; + } + + private DatabaseWrapper getDatabaseWrapper() { + if (mDatabaseWrapper == null) { + mDatabaseWrapper = mDatabaseHelper.getDatabase(); + } + return mDatabaseWrapper; + } + + @Override + public Cursor query(final Uri uri, final String[] projection, String selection, + final String[] selectionArgs, String sortOrder) { + + // Processes other than self are allowed to temporarily access the media + // scratch space; we grant uri read access on a case-by-case basis. Dialer app and + // contacts app would doQuery() on the vCard uri before trying to open the inputStream. + // There's nothing that we need to return for this uri so just No-Op. + //if (isMediaScratchSpaceUri(uri)) { + // return null; + //} + + final SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder(); + + String[] queryArgs = selectionArgs; + final int match = sURIMatcher.match(uri); + String groupBy = null; + String limit = null; + switch (match) { + case CONVERSATIONS_QUERY_CODE: + queryBuilder.setTables(ConversationListItemData.getConversationListView()); + // Hide empty conversations (ones with 0 sort_timestamp) + queryBuilder.appendWhere(ConversationColumns.SORT_TIMESTAMP + " > 0 "); + break; + case CONVERSATION_QUERY_CODE: + queryBuilder.setTables(ConversationListItemData.getConversationListView()); + if (uri.getPathSegments().size() == 2) { + queryBuilder.appendWhere(ConversationColumns._ID + "=?"); + // Get the conversation id from the uri + queryArgs = prependArgs(queryArgs, uri.getPathSegments().get(1)); + } else { + throw new IllegalArgumentException("Malformed URI " + uri); + } + break; + case CONVERSATION_PARTICIPANTS_QUERY_CODE: + queryBuilder.setTables(DatabaseHelper.PARTICIPANTS_TABLE); + if (uri.getPathSegments().size() == 3 && + TextUtils.equals(uri.getPathSegments().get(1), "conversation")) { + queryBuilder.appendWhere(ParticipantColumns._ID + " IN ( " + "SELECT " + + ConversationParticipantsColumns.PARTICIPANT_ID + " AS " + + ParticipantColumns._ID + + " FROM " + DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE + + " WHERE " + ConversationParticipantsColumns.CONVERSATION_ID + + " =? UNION SELECT " + ParticipantColumns._ID + " FROM " + + DatabaseHelper.PARTICIPANTS_TABLE + " WHERE " + + ParticipantColumns.SUB_ID + " != " + + ParticipantData.OTHER_THAN_SELF_SUB_ID + " )"); + // Get the conversation id from the uri + queryArgs = prependArgs(queryArgs, uri.getPathSegments().get(2)); + } else { + throw new IllegalArgumentException("Malformed URI " + uri); + } + break; + case PARTICIPANTS_QUERY_CODE: + queryBuilder.setTables(DatabaseHelper.PARTICIPANTS_TABLE); + if (uri.getPathSegments().size() != 1) { + throw new IllegalArgumentException("Malformed URI " + uri); + } + break; + case CONVERSATION_MESSAGES_QUERY_CODE: + if (uri.getPathSegments().size() == 3 && + TextUtils.equals(uri.getPathSegments().get(1), "conversation")) { + // Get the conversation id from the uri + final String conversationId = uri.getPathSegments().get(2); + + // We need to handle this query differently, instead of falling through to the + // generic query call at the bottom. For performance reasons, the conversation + // messages query is executed as a raw query. It is invalid to specify + // selection/sorting for this query. + + if (selection == null && selectionArgs == null && sortOrder == null) { + return queryConversationMessages(conversationId, uri); + } else { + throw new IllegalArgumentException( + "Cannot set selection or sort order with this query"); + } + } else { + throw new IllegalArgumentException("Malformed URI " + uri); + } + case CONVERSATION_IMAGES_QUERY_CODE: + queryBuilder.setTables(ConversationImagePartsView.getViewName()); + if (uri.getPathSegments().size() == 2) { + // Exclude draft. + queryBuilder.appendWhere( + ConversationImagePartsView.Columns.CONVERSATION_ID + " =? AND " + + ConversationImagePartsView.Columns.STATUS + "<>" + + MessageData.BUGLE_STATUS_OUTGOING_DRAFT); + // Get the conversation id from the uri + queryArgs = prependArgs(queryArgs, uri.getPathSegments().get(1)); + } else { + throw new IllegalArgumentException("Malformed URI " + uri); + } + break; + case DRAFT_IMAGES_QUERY_CODE: + queryBuilder.setTables(ConversationImagePartsView.getViewName()); + if (uri.getPathSegments().size() == 2) { + // Draft only. + queryBuilder.appendWhere( + ConversationImagePartsView.Columns.CONVERSATION_ID + " =? AND " + + ConversationImagePartsView.Columns.STATUS + "=" + + MessageData.BUGLE_STATUS_OUTGOING_DRAFT); + // Get the conversation id from the uri + queryArgs = prependArgs(queryArgs, uri.getPathSegments().get(1)); + } else { + throw new IllegalArgumentException("Malformed URI " + uri); + } + break; + default: { + throw new IllegalArgumentException("Unknown URI " + uri); + } + } + + final Cursor cursor = getDatabaseWrapper().query(queryBuilder, projection, selection, + queryArgs, groupBy, null, sortOrder, limit); + cursor.setNotificationUri(getContext().getContentResolver(), uri); + return cursor; + } + + private Cursor queryConversationMessages(final String conversationId, final Uri notifyUri) { + final String[] queryArgs = { conversationId }; + final Cursor cursor = getDatabaseWrapper().rawQuery( + ConversationMessageData.getConversationMessagesQuerySql(), queryArgs); + cursor.setNotificationUri(getContext().getContentResolver(), notifyUri); + return cursor; + } + + @Override + public String getType(final Uri uri) { + final StringBuilder sb = new + StringBuilder("vnd.android.cursor.dir/vnd.android.messaging."); + + switch (sURIMatcher.match(uri)) { + case CONVERSATIONS_QUERY_CODE: { + sb.append(CONVERSATIONS_QUERY); + break; + } + default: { + throw new IllegalArgumentException("Unknown URI: " + uri); + } + } + return sb.toString(); + } + + protected DatabaseHelper getDatabase() { + return DatabaseHelper.getInstance(getContext()); + } + + @Override + public ParcelFileDescriptor openFile(final Uri uri, final String fileMode) + throws FileNotFoundException { + throw new IllegalArgumentException("openFile not supported: " + uri); + } + + @Override + public Uri insert(final Uri uri, final ContentValues values) { + throw new IllegalStateException("Insert not supported " + uri); + } + + @Override + public int delete(final Uri uri, final String selection, final String[] selectionArgs) { + throw new IllegalArgumentException("Delete not supported: " + uri); + } + + @Override + public int update(final Uri uri, final ContentValues values, final String selection, + final String[] selectionArgs) { + throw new IllegalArgumentException("Update not supported: " + uri); + } + + /** + * Prepends new arguments to the existing argument list. + * + * @param oldArgList The current list of arguments. May be {@code null} + * @param args The new arguments to prepend + * @return A new argument list with the given arguments prepended + */ + private String[] prependArgs(final String[] oldArgList, final String... args) { + if (args == null || args.length == 0) { + return oldArgList; + } + final int oldArgCount = (oldArgList == null ? 0 : oldArgList.length); + final int newArgCount = args.length; + + final String[] newArgs = new String[oldArgCount + newArgCount]; + System.arraycopy(args, 0, newArgs, 0, newArgCount); + if (oldArgCount > 0) { + System.arraycopy(oldArgList, 0, newArgs, newArgCount, oldArgCount); + } + return newArgs; + } + /** + * {@inheritDoc} + */ + @Override + public void dump(final FileDescriptor fd, final PrintWriter writer, final String[] args) { + // First dump out the default SMS app package name + String defaultSmsApp = PhoneUtils.getDefault().getDefaultSmsApp(); + if (TextUtils.isEmpty(defaultSmsApp)) { + if (OsUtil.isAtLeastKLP()) { + defaultSmsApp = "None"; + } else { + defaultSmsApp = "None (pre-Kitkat)"; + } + } + writer.println("Default SMS app: " + defaultSmsApp); + // Now dump logs + LogUtil.dump(writer); + } + + @Override + public boolean onCreate() { + // This is going to wind up calling into createDatabase() below. + mDatabaseHelper = (DatabaseHelper) getDatabase(); + // We cannot initialize mDatabaseWrapper yet as the Factory may not be initialized + return true; + } +} diff --git a/src/com/android/messaging/datamodel/MmsFileProvider.java b/src/com/android/messaging/datamodel/MmsFileProvider.java new file mode 100644 index 0000000..0022630 --- /dev/null +++ b/src/com/android/messaging/datamodel/MmsFileProvider.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.content.Context; +import android.net.Uri; + +import com.android.messaging.Factory; +import com.android.messaging.util.LogUtil; +import com.google.common.annotations.VisibleForTesting; + +import java.io.File; + +/** + * A very simple content provider that can serve mms files from our cache directory. + */ +public class MmsFileProvider extends FileProvider { + private static final String TAG = LogUtil.BUGLE_TAG; + + @VisibleForTesting + static final String AUTHORITY = "com.android.messaging.datamodel.MmsFileProvider"; + private static final String RAW_MMS_DIR = "rawmms"; + + /** + * Returns a uri that can be used to access a raw mms file. + * + * @return the URI for an raw mms file + */ + public static Uri buildRawMmsUri() { + final Uri uri = FileProvider.buildFileUri(AUTHORITY, null); + final File file = getFile(uri.getPath()); + if (!ensureFileExists(file)) { + LogUtil.e(TAG, "Failed to create temp file " + file.getAbsolutePath()); + } + return uri; + } + + @Override + File getFile(final String path, final String extension) { + return getFile(path); + } + + public static File getFile(final Uri uri) { + return getFile(uri.getPath()); + } + + private static File getFile(final String path) { + final Context context = Factory.get().getApplicationContext(); + return new File(getDirectory(context), path + ".dat"); + } + + private static File getDirectory(final Context context) { + return new File(context.getCacheDir(), RAW_MMS_DIR); + } +} diff --git a/src/com/android/messaging/datamodel/NoConfirmationSmsSendService.java b/src/com/android/messaging/datamodel/NoConfirmationSmsSendService.java new file mode 100644 index 0000000..791ff34 --- /dev/null +++ b/src/com/android/messaging/datamodel/NoConfirmationSmsSendService.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.app.IntentService; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.support.v4.app.RemoteInput; +import android.telephony.TelephonyManager; +import android.text.TextUtils; + +import com.android.messaging.datamodel.action.InsertNewMessageAction; +import com.android.messaging.datamodel.action.UpdateMessageNotificationAction; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.conversationlist.ConversationListActivity; +import com.android.messaging.util.LogUtil; + +/** + * Respond to a special intent and send an SMS message without the user's intervention, unless + * the intent extra "showUI" is true. + */ +public class NoConfirmationSmsSendService extends IntentService { + private static final String TAG = LogUtil.BUGLE_TAG; + + private static final String EXTRA_SUBSCRIPTION = "subscription"; + public static final String EXTRA_SELF_ID = "self_id"; + + public NoConfirmationSmsSendService() { + // Class name will be the thread name. + super(NoConfirmationSmsSendService.class.getName()); + + // Intent should be redelivered if the process gets killed before completing the job. + setIntentRedelivery(true); + } + + @Override + protected void onHandleIntent(final Intent intent) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "NoConfirmationSmsSendService onHandleIntent"); + } + + final String action = intent.getAction(); + if (!TelephonyManager.ACTION_RESPOND_VIA_MESSAGE.equals(action)) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "NoConfirmationSmsSendService onHandleIntent wrong action: " + + action); + } + return; + } + final Bundle extras = intent.getExtras(); + if (extras == null) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Called to send SMS but no extras"); + } + return; + } + + // Get all possible extras from intent + final String conversationId = + intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); + final String selfId = intent.getStringExtra(EXTRA_SELF_ID); + final boolean requiresMms = intent.getBooleanExtra(UIIntents.UI_INTENT_EXTRA_REQUIRES_MMS, + false); + final String message = getText(intent, Intent.EXTRA_TEXT); + final String subject = getText(intent, Intent.EXTRA_SUBJECT); + final int subId = extras.getInt(EXTRA_SUBSCRIPTION, ParticipantData.DEFAULT_SELF_SUB_ID); + + final Uri intentUri = intent.getData(); + final String recipients = intentUri != null ? MmsUtils.getSmsRecipients(intentUri) : null; + + if (TextUtils.isEmpty(recipients) && TextUtils.isEmpty(conversationId)) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Both conversationId and recipient(s) cannot be empty"); + } + return; + } + + if (extras.getBoolean("showUI", false)) { + startActivity(new Intent(this, ConversationListActivity.class)); + } else { + if (TextUtils.isEmpty(message)) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Message cannot be empty"); + } + return; + } + + // TODO: it's possible that a long message would require sending it via mms, + // but we're not testing for that here and we're sending the message as an sms. + + if (TextUtils.isEmpty(conversationId)) { + InsertNewMessageAction.insertNewMessage(subId, recipients, message, subject); + } else { + MessageData messageData = null; + if (requiresMms) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Auto-sending MMS message in conversation: " + + conversationId); + } + messageData = MessageData.createDraftMmsMessage(conversationId, selfId, message, + subject); + } else { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Auto-sending SMS message in conversation: " + + conversationId); + } + messageData = MessageData.createDraftSmsMessage(conversationId, selfId, + message); + } + InsertNewMessageAction.insertNewMessage(messageData); + } + UpdateMessageNotificationAction.updateMessageNotification(); + } + } + + private String getText(final Intent intent, final String textType) { + final String message = intent.getStringExtra(textType); + if (message == null) { + final Bundle remoteInput = RemoteInput.getResultsFromIntent(intent); + if (remoteInput != null) { + final CharSequence extra = remoteInput.getCharSequence(textType); + if (extra != null) { + return extra.toString(); + } + } + } + return message; + } + +} diff --git a/src/com/android/messaging/datamodel/NotificationState.java b/src/com/android/messaging/datamodel/NotificationState.java new file mode 100644 index 0000000..d589874 --- /dev/null +++ b/src/com/android/messaging/datamodel/NotificationState.java @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel; + +import android.app.PendingIntent; +import android.net.Uri; +import android.support.v4.app.NotificationCompat; + +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.util.ConversationIdSet; + +import java.util.ArrayList; +import java.util.HashSet; + +/** + * Base class for representing notifications. The main reason for this class is that in order to + * show pictures or avatars they might need to be loaded in the background. This class and + * subclasses can do the main work to get the notification ready and then wait until any images + * that are needed are ready before posting. + * + * The creation of a notification is split into two parts. The NotificationState ctor should + * setup the basic information including the mContentIntent. A Notification Builder is created in + * RealTimeChatNotifications and passed to the build() method of each notification where the + * Notification is fully specified. + * + * TODO: There is still some duplication and inconsistency in the utility functions and + * placement of different building blocks across notification types (e.g. summary text for accounts) + */ +public abstract class NotificationState { + private static final int CONTENT_INTENT_REQUEST_CODE_OFFSET = 0; + private static final int CLEAR_INTENT_REQUEST_CODE_OFFSET = 1; + private static final int NUM_REQUEST_CODES_NEEDED = 2; + + public interface FailedMessageQuery { + static final String FAILED_MESSAGES_WHERE_CLAUSE = + "((" + MessageColumns.STATUS + " = " + + MessageData.BUGLE_STATUS_OUTGOING_FAILED + " OR " + + MessageColumns.STATUS + " = " + + MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED + ") AND " + + DatabaseHelper.MessageColumns.SEEN + " = 0)"; + + static final String FAILED_ORDER_BY = DatabaseHelper.MessageColumns.CONVERSATION_ID + ", " + + DatabaseHelper.MessageColumns.SENT_TIMESTAMP + " asc"; + } + + public final ConversationIdSet mConversationIds; + public final HashSet mPeople; + + public NotificationCompat.Style mNotificationStyle; + public NotificationCompat.Builder mNotificationBuilder; + public boolean mCanceled; + public int mType; + public int mBaseRequestCode; + public ArrayList mParticipantAvatarsUris = null; + public ArrayList mParticipantContactUris = null; + + NotificationState(final ConversationIdSet conversationIds) { + mConversationIds = conversationIds; + mPeople = new HashSet(); + } + + /** + * The intent to be triggered when the notification is dismissed. + */ + public abstract PendingIntent getClearIntent(); + + protected Uri getAttachmentUri() { + return null; + } + + // Returns the mime type of the attachment (See ContentType class for definitions) + protected String getAttachmentType() { + return null; + } + + /** + * Build the notification using the given builder. + * @param builder + * @return The style of the notification. + */ + protected abstract NotificationCompat.Style build(NotificationCompat.Builder builder); + + protected void setAvatarUrlsForConversation(final String conversationId) { + } + + protected void setPeopleForConversation(final String conversationId) { + } + + /** + * Reserves request codes for this notification type. By default 2 codes are reserved, one for + * the main intent and another for the cancel intent. Override this function to reserve more. + */ + public int getNumRequestCodesNeeded() { + return NUM_REQUEST_CODES_NEEDED; + } + + public int getContentIntentRequestCode() { + return mBaseRequestCode + CONTENT_INTENT_REQUEST_CODE_OFFSET; + } + + public int getClearIntentRequestCode() { + return mBaseRequestCode + CLEAR_INTENT_REQUEST_CODE_OFFSET; + } + + /** + * Gets the appropriate icon needed for notifications. + */ + public abstract int getIcon(); + + /** + * @return the type of notification that should be used from {@link RealTimeChatNotifications} + * so that the proper ringtone and vibrate settings can be used. + */ + public int getLatestMessageNotificationType() { + return BugleNotifications.LOCAL_SMS_NOTIFICATION; + } + + /** + * @return the notification priority level for this notification. + */ + public abstract int getPriority(); + + /** @return custom ringtone URI or null if not set */ + public String getRingtoneUri() { + return null; + } + + public boolean getNotificationVibrate() { + return false; + } + + public long getLatestReceivedTimestamp() { + return Long.MIN_VALUE; + } +} diff --git a/src/com/android/messaging/datamodel/ParticipantRefresh.java b/src/com/android/messaging/datamodel/ParticipantRefresh.java new file mode 100644 index 0000000..5324496 --- /dev/null +++ b/src/com/android/messaging/datamodel/ParticipantRefresh.java @@ -0,0 +1,738 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.content.ContentValues; +import android.database.ContentObserver; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.graphics.Color; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.support.v4.util.ArrayMap; +import android.telephony.SubscriptionInfo; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns; +import com.android.messaging.datamodel.DatabaseHelper.ConversationParticipantsColumns; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.ParticipantData.ParticipantsQuery; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.SafeAsyncTask; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Joiner; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Utility class for refreshing participant information based on matching contact. This updates + * 1. name, photo_uri, matching contact_id of participants. + * 2. generated_name of conversations. + * + * There are two kinds of participant refreshes, + * 1. Full refresh, this is triggered at application start or activity resumes after contact + * change is detected. + * 2. Partial refresh, this is triggered when a participant is added to a conversation. This + * normally happens during SMS sync. + */ +@VisibleForTesting +public class ParticipantRefresh { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + /** + * Refresh all participants including ones that were resolved before. + */ + public static final int REFRESH_MODE_FULL = 0; + + /** + * Refresh all unresolved participants. + */ + public static final int REFRESH_MODE_INCREMENTAL = 1; + + /** + * Force refresh all self participants. + */ + public static final int REFRESH_MODE_SELF_ONLY = 2; + + public static class ConversationParticipantsQuery { + public static final String[] PROJECTION = new String[] { + ConversationParticipantsColumns._ID, + ConversationParticipantsColumns.CONVERSATION_ID, + ConversationParticipantsColumns.PARTICIPANT_ID + }; + + public static final int INDEX_ID = 0; + public static final int INDEX_CONVERSATION_ID = 1; + public static final int INDEX_PARTICIPANT_ID = 2; + } + + // Track whether observer is initialized or not. + private static volatile boolean sObserverInitialized = false; + private static final Object sLock = new Object(); + private static final AtomicBoolean sFullRefreshScheduled = new AtomicBoolean(false); + private static final Runnable sFullRefreshRunnable = new Runnable() { + @Override + public void run() { + final boolean oldScheduled = sFullRefreshScheduled.getAndSet(false); + Assert.isTrue(oldScheduled); + refreshParticipants(REFRESH_MODE_FULL); + } + }; + private static final Runnable sSelfOnlyRefreshRunnable = new Runnable() { + @Override + public void run() { + refreshParticipants(REFRESH_MODE_SELF_ONLY); + } + }; + + /** + * A customized content resolver to track contact changes. + */ + public static class ContactContentObserver extends ContentObserver { + private volatile boolean mContactChanged = false; + + public ContactContentObserver() { + super(null); + } + + @Override + public void onChange(final boolean selfChange) { + super.onChange(selfChange); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Contacts changed"); + } + mContactChanged = true; + } + + public boolean getContactChanged() { + return mContactChanged; + } + + public void resetContactChanged() { + mContactChanged = false; + } + + public void initialize() { + // TODO: Handle enterprise contacts post M once contacts provider supports it + Factory.get().getApplicationContext().getContentResolver().registerContentObserver( + Phone.CONTENT_URI, true, this); + mContactChanged = true; // Force a full refresh on initialization. + } + } + + /** + * Refresh participants only if needed, i.e., application start or contact changed. + */ + public static void refreshParticipantsIfNeeded() { + if (ParticipantRefresh.getNeedFullRefresh() && + sFullRefreshScheduled.compareAndSet(false, true)) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Started full participant refresh"); + } + SafeAsyncTask.executeOnThreadPool(sFullRefreshRunnable); + } else if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Skipped full participant refresh"); + } + } + + /** + * Refresh self participants on subscription or settings change. + */ + public static void refreshSelfParticipants() { + SafeAsyncTask.executeOnThreadPool(sSelfOnlyRefreshRunnable); + } + + private static boolean getNeedFullRefresh() { + final ContactContentObserver observer = Factory.get().getContactContentObserver(); + if (observer == null) { + // If there is no observer (for unittest cases), we don't need to refresh participants. + return false; + } + + if (!sObserverInitialized) { + synchronized (sLock) { + if (!sObserverInitialized) { + observer.initialize(); + sObserverInitialized = true; + } + } + } + + return observer.getContactChanged(); + } + + private static void resetNeedFullRefresh() { + final ContactContentObserver observer = Factory.get().getContactContentObserver(); + if (observer != null) { + observer.resetContactChanged(); + } + } + + /** + * This class is totally static. Make constructor to be private so that an instance + * of this class would not be created by by mistake. + */ + private ParticipantRefresh() { + } + + /** + * Refresh participants in Bugle. + * + * @param refreshMode the refresh mode desired. See {@link #REFRESH_MODE_FULL}, + * {@link #REFRESH_MODE_INCREMENTAL}, and {@link #REFRESH_MODE_SELF_ONLY} + */ + @VisibleForTesting + static void refreshParticipants(final int refreshMode) { + Assert.inRange(refreshMode, REFRESH_MODE_FULL, REFRESH_MODE_SELF_ONLY); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + switch (refreshMode) { + case REFRESH_MODE_FULL: + LogUtil.v(TAG, "Start full participant refresh"); + break; + case REFRESH_MODE_INCREMENTAL: + LogUtil.v(TAG, "Start partial participant refresh"); + break; + case REFRESH_MODE_SELF_ONLY: + LogUtil.v(TAG, "Start self participant refresh"); + break; + } + } + + if (!ContactUtil.hasReadContactsPermission() || !OsUtil.hasPhonePermission()) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Skipping participant referesh because of permissions"); + } + return; + } + + if (refreshMode == REFRESH_MODE_FULL) { + // resetNeedFullRefresh right away so that we will skip duplicated full refresh + // requests. + resetNeedFullRefresh(); + } + + if (refreshMode == REFRESH_MODE_FULL || refreshMode == REFRESH_MODE_SELF_ONLY) { + refreshSelfParticipantList(); + } + + final ArrayList changedParticipants = new ArrayList(); + + String selection = null; + String[] selectionArgs = null; + + if (refreshMode == REFRESH_MODE_INCREMENTAL) { + // In case of incremental refresh, filter out participants that are already resolved. + selection = ParticipantColumns.CONTACT_ID + "=?"; + selectionArgs = new String[] { + String.valueOf(ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED) }; + } else if (refreshMode == REFRESH_MODE_SELF_ONLY) { + // In case of self-only refresh, filter out non-self participants. + selection = SELF_PARTICIPANTS_CLAUSE; + selectionArgs = null; + } + + final DatabaseWrapper db = DataModel.get().getDatabase(); + Cursor cursor = null; + boolean selfUpdated = false; + try { + cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE, + ParticipantsQuery.PROJECTION, selection, selectionArgs, null, null, null); + + if (cursor != null) { + while (cursor.moveToNext()) { + try { + final ParticipantData participantData = + ParticipantData.getFromCursor(cursor); + if (refreshParticipant(db, participantData)) { + if (participantData.isSelf()) { + selfUpdated = true; + } + updateParticipant(db, participantData); + final String id = participantData.getId(); + changedParticipants.add(id); + } + } catch (final Exception exception) { + // Failure to update one participant shouldn't cancel the entire refresh. + // Log the failure so we know what's going on and resume the loop. + LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "ParticipantRefresh: Failed to " + + "update participant", exception); + } + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Number of participants refreshed:" + changedParticipants.size()); + } + + // Refresh conversations for participants that are changed. + if (changedParticipants.size() > 0) { + BugleDatabaseOperations.refreshConversationsForParticipants(changedParticipants); + } + if (selfUpdated) { + // Boom + MessagingContentProvider.notifyAllParticipantsChanged(); + MessagingContentProvider.notifyAllMessagesChanged(); + } + } + + private static final String SELF_PARTICIPANTS_CLAUSE = ParticipantColumns.SUB_ID + + " NOT IN ( " + + ParticipantData.OTHER_THAN_SELF_SUB_ID + + " )"; + + private static final Set getExistingSubIds() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final HashSet existingSubIds = new HashSet(); + + Cursor cursor = null; + try { + cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE, + ParticipantsQuery.PROJECTION, + SELF_PARTICIPANTS_CLAUSE, null, null, null, null); + + if (cursor != null) { + while (cursor.moveToNext()) { + final int subId = cursor.getInt(ParticipantsQuery.INDEX_SUB_ID); + existingSubIds.add(subId); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return existingSubIds; + } + + private static final String UPDATE_SELF_PARTICIPANT_SUBSCRIPTION_SQL = + "UPDATE " + DatabaseHelper.PARTICIPANTS_TABLE + " SET " + + ParticipantColumns.SIM_SLOT_ID + " = %d, " + + ParticipantColumns.SUBSCRIPTION_COLOR + " = %d, " + + ParticipantColumns.SUBSCRIPTION_NAME + " = %s " + + " WHERE %s"; + + static String getUpdateSelfParticipantSubscriptionInfoSql(final int slotId, + final int subscriptionColor, final String subscriptionName, final String where) { + return String.format((Locale) null /* construct SQL string without localization */, + UPDATE_SELF_PARTICIPANT_SUBSCRIPTION_SQL, + slotId, subscriptionColor, subscriptionName, where); + } + + /** + * Ensure that there is a self participant corresponding to every active SIM. Also, ensure + * that any other older SIM self participants are marked as inactive. + */ + private static void refreshSelfParticipantList() { + if (!OsUtil.isAtLeastL_MR1()) { + return; + } + + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final List subInfoRecords = + PhoneUtils.getDefault().toLMr1().getActiveSubscriptionInfoList(); + final ArrayMap activeSubscriptionIdToRecordMap = + new ArrayMap(); + db.beginTransaction(); + final Set existingSubIds = getExistingSubIds(); + + try { + if (subInfoRecords != null) { + for (final SubscriptionInfo subInfoRecord : subInfoRecords) { + final int subId = subInfoRecord.getSubscriptionId(); + // If its a new subscription, add it to the database. + if (!existingSubIds.contains(subId)) { + db.execSQL(DatabaseHelper.getCreateSelfParticipantSql(subId)); + // Add it to the local set to guard against duplicated entries returned + // by subscription manager. + existingSubIds.add(subId); + } + activeSubscriptionIdToRecordMap.put(subId, subInfoRecord); + + if (subId == PhoneUtils.getDefault().getDefaultSmsSubscriptionId()) { + // This is the system default subscription, so update the default self. + activeSubscriptionIdToRecordMap.put(ParticipantData.DEFAULT_SELF_SUB_ID, + subInfoRecord); + } + } + } + + // For subscriptions already in the database, refresh ParticipantColumns.SIM_SLOT_ID. + for (final Integer subId : activeSubscriptionIdToRecordMap.keySet()) { + final SubscriptionInfo record = activeSubscriptionIdToRecordMap.get(subId); + final String displayName = + DatabaseUtils.sqlEscapeString(record.getDisplayName().toString()); + db.execSQL(getUpdateSelfParticipantSubscriptionInfoSql(record.getSimSlotIndex(), + record.getIconTint(), displayName, + ParticipantColumns.SUB_ID + " = " + subId)); + } + db.execSQL(getUpdateSelfParticipantSubscriptionInfoSql( + ParticipantData.INVALID_SLOT_ID, Color.TRANSPARENT, "''", + ParticipantColumns.SUB_ID + " NOT IN (" + + Joiner.on(", ").join(activeSubscriptionIdToRecordMap.keySet()) + ")")); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + // Fix up conversation self ids by reverting to default self for conversations whose self + // ids are no longer active. + refreshConversationSelfIds(); + } + + /** + * Refresh one participant. + * @return true if the ParticipantData was changed + */ + public static boolean refreshParticipant(final DatabaseWrapper db, + final ParticipantData participantData) { + boolean updated = false; + + if (participantData.isSelf()) { + final int selfChange = refreshFromSelfProfile(db, participantData); + + if (selfChange == SELF_PROFILE_EXISTS) { + // If a self-profile exists, it takes precedence over Contacts data. So we are done. + return true; + } + + updated = (selfChange == SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED); + + // Fall-through and try to update based on Contacts data + } + + updated |= refreshFromContacts(db, participantData); + return updated; + } + + private static final int SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED = 1; + private static final int SELF_PROFILE_EXISTS = 2; + + private static int refreshFromSelfProfile(final DatabaseWrapper db, + final ParticipantData participantData) { + int changed = 0; + // Refresh the phone number based on information from telephony + if (participantData.updatePhoneNumberForSelfIfChanged()) { + changed = SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED; + } + + if (OsUtil.isAtLeastL_MR1()) { + // Refresh the subscription info based on information from SubscriptionManager. + final SubscriptionInfo subscriptionInfo = + PhoneUtils.get(participantData.getSubId()).toLMr1().getActiveSubscriptionInfo(); + if (participantData.updateSubscriptionInfoForSelfIfChanged(subscriptionInfo)) { + changed = SELF_PHONE_NUMBER_OR_SUBSCRIPTION_CHANGED; + } + } + + // For self participant, try getting name/avatar from self profile in CP2 first. + // TODO: in case of multi-sim, profile would not be able to be used for + // different numbers. Need to figure out that. + Cursor selfCursor = null; + try { + selfCursor = ContactUtil.getSelf(db.getContext()).performSynchronousQuery(); + if (selfCursor != null && selfCursor.getCount() > 0) { + selfCursor.moveToNext(); + final long selfContactId = selfCursor.getLong(ContactUtil.INDEX_CONTACT_ID); + participantData.setContactId(selfContactId); + participantData.setFullName(selfCursor.getString( + ContactUtil.INDEX_DISPLAY_NAME)); + participantData.setFirstName( + ContactUtil.lookupFirstName(db.getContext(), selfContactId)); + participantData.setProfilePhotoUri(selfCursor.getString( + ContactUtil.INDEX_PHOTO_URI)); + participantData.setLookupKey(selfCursor.getString( + ContactUtil.INDEX_SELF_QUERY_LOOKUP_KEY)); + return SELF_PROFILE_EXISTS; + } + } catch (final Exception exception) { + // It's possible for contact query to fail and we don't want that to crash our app. + // However, we need to at least log the exception so we know something was wrong. + LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "Participant refresh: failed to refresh " + + "participant. exception=" + exception); + } finally { + if (selfCursor != null) { + selfCursor.close(); + } + } + return changed; + } + + private static boolean refreshFromContacts(final DatabaseWrapper db, + final ParticipantData participantData) { + final String normalizedDestination = participantData.getNormalizedDestination(); + final long currentContactId = participantData.getContactId(); + final String currentDisplayName = participantData.getFullName(); + final String currentFirstName = participantData.getFirstName(); + final String currentPhotoUri = participantData.getProfilePhotoUri(); + final String currentContactDestination = participantData.getContactDestination(); + + Cursor matchingContactCursor = null; + long matchingContactId = -1; + String matchingDisplayName = null; + String matchingFirstName = null; + String matchingPhotoUri = null; + String matchingLookupKey = null; + String matchingDestination = null; + boolean updated = false; + + if (TextUtils.isEmpty(normalizedDestination)) { + // The normalized destination can be "" for the self id if we can't get it from the + // SIM. Some contact providers throw an IllegalArgumentException if you lookup "", + // so we early out. + return false; + } + + try { + matchingContactCursor = ContactUtil.lookupDestination(db.getContext(), + normalizedDestination).performSynchronousQuery(); + if (matchingContactCursor == null || matchingContactCursor.getCount() == 0) { + // If there is no match, mark the participant as contact not found. + if (currentContactId != ParticipantData.PARTICIPANT_CONTACT_ID_NOT_FOUND) { + participantData.setContactId(ParticipantData.PARTICIPANT_CONTACT_ID_NOT_FOUND); + participantData.setFullName(null); + participantData.setFirstName(null); + participantData.setProfilePhotoUri(null); + participantData.setLookupKey(null); + updated = true; + } + return updated; + } + + while (matchingContactCursor.moveToNext()) { + final long contactId = matchingContactCursor.getLong(ContactUtil.INDEX_CONTACT_ID); + // Pick either the first contact or the contact with same id as previous matched + // contact id. + if (matchingContactId == -1 || currentContactId == contactId) { + matchingContactId = contactId; + matchingDisplayName = matchingContactCursor.getString( + ContactUtil.INDEX_DISPLAY_NAME); + matchingFirstName = ContactUtil.lookupFirstName(db.getContext(), contactId); + matchingPhotoUri = matchingContactCursor.getString( + ContactUtil.INDEX_PHOTO_URI); + matchingLookupKey = matchingContactCursor.getString( + ContactUtil.INDEX_LOOKUP_KEY); + matchingDestination = matchingContactCursor.getString( + ContactUtil.INDEX_PHONE_EMAIL); + } + + // There is no need to try other contacts if the current contactId was not filled... + if (currentContactId < 0 + // or we found the matching contact id + || currentContactId == contactId) { + break; + } + } + } catch (final Exception exception) { + // It's possible for contact query to fail and we don't want that to crash our app. + // However, we need to at least log the exception so we know something was wrong. + LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "Participant refresh: failed to refresh " + + "participant. exception=" + exception); + return false; + } finally { + if (matchingContactCursor != null) { + matchingContactCursor.close(); + } + } + + // Update participant only if something changed. + final boolean isContactIdChanged = (matchingContactId != currentContactId); + final boolean isDisplayNameChanged = + !TextUtils.equals(matchingDisplayName, currentDisplayName); + final boolean isFirstNameChanged = !TextUtils.equals(matchingFirstName, currentFirstName); + final boolean isPhotoUrlChanged = !TextUtils.equals(matchingPhotoUri, currentPhotoUri); + final boolean isDestinationChanged = !TextUtils.equals(matchingDestination, + currentContactDestination); + + if (isContactIdChanged || isDisplayNameChanged || isFirstNameChanged || isPhotoUrlChanged + || isDestinationChanged) { + participantData.setContactId(matchingContactId); + participantData.setFullName(matchingDisplayName); + participantData.setFirstName(matchingFirstName); + participantData.setProfilePhotoUri(matchingPhotoUri); + participantData.setLookupKey(matchingLookupKey); + participantData.setContactDestination(matchingDestination); + if (isDestinationChanged) { + // Update the send destination to the new one entered by user in Contacts. + participantData.setSendDestination(matchingDestination); + } + updated = true; + } + + return updated; + } + + /** + * Update participant with matching contact's contactId, displayName and photoUri. + */ + private static void updateParticipant(final DatabaseWrapper db, + final ParticipantData participantData) { + final ContentValues values = new ContentValues(); + if (participantData.isSelf()) { + // Self participants can refresh their normalized phone numbers + values.put(ParticipantColumns.NORMALIZED_DESTINATION, + participantData.getNormalizedDestination()); + values.put(ParticipantColumns.DISPLAY_DESTINATION, + participantData.getDisplayDestination()); + } + values.put(ParticipantColumns.CONTACT_ID, participantData.getContactId()); + values.put(ParticipantColumns.LOOKUP_KEY, participantData.getLookupKey()); + values.put(ParticipantColumns.FULL_NAME, participantData.getFullName()); + values.put(ParticipantColumns.FIRST_NAME, participantData.getFirstName()); + values.put(ParticipantColumns.PROFILE_PHOTO_URI, participantData.getProfilePhotoUri()); + values.put(ParticipantColumns.CONTACT_DESTINATION, participantData.getContactDestination()); + values.put(ParticipantColumns.SEND_DESTINATION, participantData.getSendDestination()); + + db.beginTransaction(); + try { + db.update(DatabaseHelper.PARTICIPANTS_TABLE, values, ParticipantColumns._ID + "=?", + new String[] { participantData.getId() }); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + /** + * Get a list of inactive self ids in the participants table. + */ + private static List getInactiveSelfParticipantIds() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final List inactiveSelf = new ArrayList(); + + final String selection = ParticipantColumns.SIM_SLOT_ID + "=? AND " + + SELF_PARTICIPANTS_CLAUSE; + Cursor cursor = null; + try { + cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE, + new String[] { ParticipantColumns._ID }, + selection, new String[] { String.valueOf(ParticipantData.INVALID_SLOT_ID) }, + null, null, null); + + if (cursor != null) { + while (cursor.moveToNext()) { + final String participantId = cursor.getString(0); + inactiveSelf.add(participantId); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return inactiveSelf; + } + + /** + * Gets a list of conversations with the given self ids. + */ + private static List getConversationsWithSelfParticipantIds(final List selfIds) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final List conversationIds = new ArrayList(); + + Cursor cursor = null; + try { + final StringBuilder selectionList = new StringBuilder(); + for (int i = 0; i < selfIds.size(); i++) { + selectionList.append('?'); + if (i < selfIds.size() - 1) { + selectionList.append(','); + } + } + final String selection = + ConversationColumns.CURRENT_SELF_ID + " IN (" + selectionList + ")"; + cursor = db.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { ConversationColumns._ID }, + selection, selfIds.toArray(new String[0]), + null, null, null); + + if (cursor != null) { + while (cursor.moveToNext()) { + final String conversationId = cursor.getString(0); + conversationIds.add(conversationId); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return conversationIds; + } + + /** + * Refresh one conversation's self id. + */ + private static void updateConversationSelfId(final String conversationId, + final String selfId) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + + db.beginTransaction(); + try { + BugleDatabaseOperations.updateConversationSelfIdInTransaction(db, conversationId, + selfId); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + MessagingContentProvider.notifyMessagesChanged(conversationId); + MessagingContentProvider.notifyConversationMetadataChanged(conversationId); + UIIntents.get().broadcastConversationSelfIdChange(db.getContext(), conversationId, selfId); + } + + /** + * After refreshing the self participant list, find all conversations with inactive self ids, + * and switch them back to system default. + */ + private static void refreshConversationSelfIds() { + final List inactiveSelfs = getInactiveSelfParticipantIds(); + if (inactiveSelfs.size() == 0) { + return; + } + final List conversationsToRefresh = + getConversationsWithSelfParticipantIds(inactiveSelfs); + if (conversationsToRefresh.size() == 0) { + return; + } + final DatabaseWrapper db = DataModel.get().getDatabase(); + final ParticipantData defaultSelf = + BugleDatabaseOperations.getOrCreateSelf(db, ParticipantData.DEFAULT_SELF_SUB_ID); + + if (defaultSelf != null) { + for (final String conversationId : conversationsToRefresh) { + updateConversationSelfId(conversationId, defaultSelf.getId()); + } + } + } +} diff --git a/src/com/android/messaging/datamodel/SyncManager.java b/src/com/android/messaging/datamodel/SyncManager.java new file mode 100644 index 0000000..b3571bf --- /dev/null +++ b/src/com/android/messaging/datamodel/SyncManager.java @@ -0,0 +1,478 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.content.Context; +import android.database.ContentObserver; +import android.net.Uri; +import android.provider.Telephony; +import android.support.v4.util.LongSparseArray; + +import com.android.messaging.datamodel.action.SyncMessagesAction; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.BuglePrefsKeys; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.google.common.collect.Lists; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +/** + * This class manages message sync with the Telephony SmsProvider/MmsProvider. + */ +public class SyncManager { + private static final String TAG = LogUtil.BUGLE_TAG; + + /** + * Record of any user customization to conversation settings + */ + public static class ConversationCustomization { + private final boolean mArchived; + private final boolean mMuted; + private final boolean mNoVibrate; + private final String mNotificationSoundUri; + + public ConversationCustomization(final boolean archived, final boolean muted, + final boolean noVibrate, final String notificationSoundUri) { + mArchived = archived; + mMuted = muted; + mNoVibrate = noVibrate; + mNotificationSoundUri = notificationSoundUri; + } + + public boolean isArchived() { + return mArchived; + } + + public boolean isMuted() { + return mMuted; + } + + public boolean noVibrate() { + return mNoVibrate; + } + + public String getNotificationSoundUri() { + return mNotificationSoundUri; + } + } + + SyncManager() { + } + + /** + * Timestamp of in progress sync - used to keep track of whether sync is running + */ + private long mSyncInProgressTimestamp = -1; + + /** + * Timestamp of current sync batch upper bound - used to determine if message makes batch dirty + */ + private long mCurrentUpperBoundTimestamp = -1; + + /** + * Timestamp of messages inserted since sync batch started - used to determine if batch dirty + */ + private long mMaxRecentChangeTimestamp = -1L; + + private final ThreadInfoCache mThreadInfoCache = new ThreadInfoCache(); + + /** + * User customization to conversations. If this is set, we need to recover them after + * a full sync. + */ + private LongSparseArray mCustomization = null; + + /** + * Start an incremental sync (backed off a few seconds) + */ + public static void sync() { + SyncMessagesAction.sync(); + } + + /** + * Start an incremental sync (with no backoff) + */ + public static void immediateSync() { + SyncMessagesAction.immediateSync(); + } + + /** + * Start a full sync (for debugging) + */ + public static void forceSync() { + SyncMessagesAction.fullSync(); + } + + /** + * Called from data model thread when starting a sync batch + * @param upperBoundTimestamp upper bound timestamp for sync batch + */ + public synchronized void startSyncBatch(final long upperBoundTimestamp) { + Assert.isTrue(mCurrentUpperBoundTimestamp < 0); + mCurrentUpperBoundTimestamp = upperBoundTimestamp; + mMaxRecentChangeTimestamp = -1L; + } + + /** + * Called from data model thread at end of batch to determine if any messages added in window + * @param lowerBoundTimestamp lower bound timestamp for sync batch + * @return true if message added within window from lower to upper bound timestamp of batch + */ + public synchronized boolean isBatchDirty(final long lowerBoundTimestamp) { + Assert.isTrue(mCurrentUpperBoundTimestamp >= 0); + final long max = mMaxRecentChangeTimestamp; + + final boolean dirty = (max >= 0 && max >= lowerBoundTimestamp); + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncManager: Sync batch of messages from " + lowerBoundTimestamp + + " to " + mCurrentUpperBoundTimestamp + " is " + + (dirty ? "DIRTY" : "clean") + "; max change timestamp = " + + mMaxRecentChangeTimestamp); + } + + mCurrentUpperBoundTimestamp = -1L; + mMaxRecentChangeTimestamp = -1L; + + return dirty; + } + + /** + * Called from data model or background worker thread to indicate start of message add process + * (add must complete on that thread before action transitions to new thread/stage) + * @param timestamp timestamp of message being added + */ + public synchronized void onNewMessageInserted(final long timestamp) { + if (mCurrentUpperBoundTimestamp >= 0 && timestamp <= mCurrentUpperBoundTimestamp) { + // Message insert in current sync window + mMaxRecentChangeTimestamp = Math.max(mCurrentUpperBoundTimestamp, timestamp); + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncManager: New message @ " + timestamp + " before upper bound of " + + "current sync batch " + mCurrentUpperBoundTimestamp); + } + } else if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncManager: New message @ " + timestamp + " after upper bound of " + + "current sync batch " + mCurrentUpperBoundTimestamp); + } + } + + /** + * Synchronously checks whether sync is allowed and starts sync if allowed + * @param full - true indicates a full (not incremental) sync operation + * @param startTimestamp - starttimestamp for this sync (if allowed) + * @return - true if sync should start + */ + public synchronized boolean shouldSync(final boolean full, final long startTimestamp) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SyncManager: Checking shouldSync " + (full ? "full " : "") + + "at " + startTimestamp); + } + + if (full) { + final long delayUntilFullSync = delayUntilFullSync(startTimestamp); + if (delayUntilFullSync > 0) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncManager: Full sync requested for " + startTimestamp + + " delayed for " + delayUntilFullSync + " ms"); + } + return false; + } + } + + if (isSyncing()) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncManager: Not allowed to " + (full ? "full " : "") + + "sync yet; still running sync started at " + mSyncInProgressTimestamp); + } + return false; + } + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncManager: Starting " + (full ? "full " : "") + "sync at " + + startTimestamp); + } + + mSyncInProgressTimestamp = startTimestamp; + + return true; + } + + /** + * Return delay (in ms) until allowed to run a full sync (0 meaning can run immediately) + * @param startTimestamp Timestamp used to start the sync + * @return 0 if allowed to run now, else delay in ms + */ + public long delayUntilFullSync(final long startTimestamp) { + final BugleGservices bugleGservices = BugleGservices.get(); + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + + final long lastFullSyncTime = prefs.getLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, -1L); + final long smsFullSyncBackoffTimeMillis = bugleGservices.getLong( + BugleGservicesKeys.SMS_FULL_SYNC_BACKOFF_TIME_MILLIS, + BugleGservicesKeys.SMS_FULL_SYNC_BACKOFF_TIME_MILLIS_DEFAULT); + final long noFullSyncBefore = (lastFullSyncTime < 0 ? startTimestamp : + lastFullSyncTime + smsFullSyncBackoffTimeMillis); + + final long delayUntilFullSync = noFullSyncBefore - startTimestamp; + if (delayUntilFullSync > 0) { + return delayUntilFullSync; + } + return 0; + } + + /** + * Check if sync currently in progress (public for asserts/logging). + */ + public synchronized boolean isSyncing() { + return (mSyncInProgressTimestamp >= 0); + } + + /** + * Check if sync batch should be in progress - compares upperBound with in memory value + * @param upperBoundTimestamp - upperbound timestamp for sync batch + * @return - true if timestamps match (otherwise batch is orphan from older process) + */ + public synchronized boolean isSyncing(final long upperBoundTimestamp) { + Assert.isTrue(upperBoundTimestamp >= 0); + return (upperBoundTimestamp == mCurrentUpperBoundTimestamp); + } + + /** + * Check if sync has completed for the first time. + */ + public boolean getHasFirstSyncCompleted() { + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + return prefs.getLong(BuglePrefsKeys.LAST_SYNC_TIME, + BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT) != + BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT; + } + + /** + * Called once sync is complete + */ + public synchronized void complete() { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncManager: Sync started at " + mSyncInProgressTimestamp + + " marked as complete"); + } + mSyncInProgressTimestamp = -1L; + // Conversation customization only used once + mCustomization = null; + } + + private final ContentObserver mMmsSmsObserver = new TelephonyMessagesObserver(); + private boolean mSyncOnChanges = false; + private boolean mNotifyOnChanges = false; + + /** + * Register content observer when necessary and kick off a catch up sync + */ + public void updateSyncObserver(final Context context) { + registerObserver(context); + // Trigger an sms sync in case we missed and messages before registering this observer or + // becoming the SMS provider. + immediateSync(); + } + + private void registerObserver(final Context context) { + if (!PhoneUtils.getDefault().isDefaultSmsApp()) { + // Not default SMS app - need to actively monitor telephony but not notify + mNotifyOnChanges = false; + mSyncOnChanges = true; + } else if (OsUtil.isSecondaryUser()){ + // Secondary users default SMS app - need to actively monitor telephony and notify + mNotifyOnChanges = true; + mSyncOnChanges = true; + } else { + // Primary users default SMS app - don't monitor telephony (most changes from this app) + mNotifyOnChanges = false; + mSyncOnChanges = false; + } + if (mNotifyOnChanges || mSyncOnChanges) { + context.getContentResolver().registerContentObserver(Telephony.MmsSms.CONTENT_URI, + true, mMmsSmsObserver); + } else { + context.getContentResolver().unregisterContentObserver(mMmsSmsObserver); + } + } + + public synchronized void setCustomization( + final LongSparseArray customization) { + this.mCustomization = customization; + } + + public synchronized ConversationCustomization getCustomizationForThread(final long threadId) { + if (mCustomization != null) { + return mCustomization.get(threadId); + } + return null; + } + + public static void resetLastSyncTimestamps() { + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + prefs.putLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, + BuglePrefsKeys.LAST_FULL_SYNC_TIME_DEFAULT); + prefs.putLong(BuglePrefsKeys.LAST_SYNC_TIME, BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT); + } + + private class TelephonyMessagesObserver extends ContentObserver { + public TelephonyMessagesObserver() { + // Just run on default thread + super(null); + } + + // Implement the onChange(boolean) method to delegate the change notification to + // the onChange(boolean, Uri) method to ensure correct operation on older versions + // of the framework that did not have the onChange(boolean, Uri) method. + @Override + public void onChange(final boolean selfChange) { + onChange(selfChange, null); + } + + // Implement the onChange(boolean, Uri) method to take advantage of the new Uri argument. + @Override + public void onChange(final boolean selfChange, final Uri uri) { + // Handle change. + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SyncManager: Sms/Mms DB changed @" + System.currentTimeMillis() + + " for " + (uri == null ? "" : uri.toString()) + " " + + mSyncOnChanges + "/" + mNotifyOnChanges); + } + + if (mSyncOnChanges) { + // If sync is already running this will do nothing - but at end of each sync + // action there is a check for recent messages that should catch new changes. + SyncManager.immediateSync(); + } + if (mNotifyOnChanges) { + // TODO: Secondary users are not going to get notifications + } + } + } + + public ThreadInfoCache getThreadInfoCache() { + return mThreadInfoCache; + } + + public static class ThreadInfoCache { + // Cache of thread->conversationId map + private final LongSparseArray mThreadToConversationId = + new LongSparseArray(); + + // Cache of thread->recipients map + private final LongSparseArray> mThreadToRecipients = + new LongSparseArray>(); + + // Remember the conversation ids that need to be archived + private final HashSet mArchivedConversations = new HashSet<>(); + + public synchronized void clear() { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncManager: Cleared ThreadInfoCache"); + } + mThreadToConversationId.clear(); + mThreadToRecipients.clear(); + mArchivedConversations.clear(); + } + + public synchronized boolean isArchived(final String conversationId) { + return mArchivedConversations.contains(conversationId); + } + + /** + * Get or create a conversation based on the message's thread id + * + * @param threadId The message's thread + * @param refSubId The subId used for normalizing phone numbers in the thread + * @param customization The user setting customization to the conversation if any + * @return The existing conversation id or new conversation id + */ + public synchronized String getOrCreateConversation(final DatabaseWrapper db, + final long threadId, int refSubId, final ConversationCustomization customization) { + // This function has several components which need to be atomic. + Assert.isTrue(db.getDatabase().inTransaction()); + + // If we already have this conversation ID in our local map, just return it + String conversationId = mThreadToConversationId.get(threadId); + if (conversationId != null) { + return conversationId; + } + + final List recipients = getThreadRecipients(threadId); + final ArrayList participants = + BugleDatabaseOperations.getConversationParticipantsFromRecipients(recipients, + refSubId); + + if (customization != null) { + // There is user customization we need to recover + conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId, + customization.isArchived(), participants, customization.isMuted(), + customization.noVibrate(), customization.getNotificationSoundUri()); + if (customization.isArchived()) { + mArchivedConversations.add(conversationId); + } + } else { + conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId, + false/*archived*/, participants, false/*noNotification*/, + false/*noVibrate*/, null/*soundUri*/); + } + + if (conversationId != null) { + mThreadToConversationId.put(threadId, conversationId); + return conversationId; + } + + return null; + } + + + /** + * Load the recipients of a thread from telephony provider. If we fail, use + * a predefined unknown recipient. This should not return null. + * + * @param threadId + */ + public synchronized List getThreadRecipients(final long threadId) { + List recipients = mThreadToRecipients.get(threadId); + if (recipients == null) { + recipients = MmsUtils.getRecipientsByThread(threadId); + if (recipients != null && recipients.size() > 0) { + mThreadToRecipients.put(threadId, recipients); + } + } + + if (recipients == null || recipients.isEmpty()) { + LogUtil.w(TAG, "SyncManager : using unknown sender since thread " + threadId + + " couldn't find any recipients."); + + // We want to try our best to load the messages, + // so if recipient info is broken, try to fix it with unknown recipient + recipients = Lists.newArrayList(); + recipients.add(ParticipantData.getUnknownSenderDestination()); + } + + return recipients; + } + } +} diff --git a/src/com/android/messaging/datamodel/action/Action.java b/src/com/android/messaging/datamodel/action/Action.java new file mode 100644 index 0000000..e4c332e --- /dev/null +++ b/src/com/android/messaging/datamodel/action/Action.java @@ -0,0 +1,295 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DataModelException; +import com.android.messaging.datamodel.action.ActionMonitor.ActionCompletedListener; +import com.android.messaging.datamodel.action.ActionMonitor.ActionExecutedListener; +import com.android.messaging.util.LogUtil; + +import java.util.LinkedList; +import java.util.List; + +/** + * Base class for operations that perform application business logic off the main UI thread while + * holding a wake lock. + * . + * Note all derived classes need to provide real implementation of Parcelable (this is abstract) + */ +public abstract class Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + // Members holding the parameters common to all actions - no action state + public final String actionKey; + + // If derived classes keep their data in actionParameters then parcelable is trivial + protected Bundle actionParameters; + + // This does not get written to the parcel + private final List mBackgroundActions = new LinkedList(); + + /** + * Process the action locally - runs on action service thread. + * TODO: Currently, there is no way for this method to indicate failure + * @return result to be passed in to {@link ActionExecutedListener#onActionExecuted}. It is + * also the result passed in to {@link ActionCompletedListener#onActionSucceeded} if + * there is no background work. + */ + protected Object executeAction() { + return null; + } + + /** + * Queues up background work ie. {@link #doBackgroundWork} will be called on the + * background worker thread. + */ + protected void requestBackgroundWork() { + mBackgroundActions.add(this); + } + + /** + * Queues up background actions for background processing after the current action has + * completed its processing ({@link #executeAction}, {@link processBackgroundCompletion} + * or {@link #processBackgroundFailure}) on the Action thread. + * @param backgroundAction + */ + protected void requestBackgroundWork(final Action backgroundAction) { + mBackgroundActions.add(backgroundAction); + } + + /** + * Return flag indicating if any actions have been queued + */ + public boolean hasBackgroundActions() { + return !mBackgroundActions.isEmpty(); + } + + /** + * Send queued actions to the background worker provided + */ + public void sendBackgroundActions(final BackgroundWorker worker) { + worker.queueBackgroundWork(mBackgroundActions); + mBackgroundActions.clear(); + } + + /** + * Do work in a long running background worker thread. + * {@link #requestBackgroundWork} needs to be called for this method to + * be called. {@link #processBackgroundFailure} will be called on the Action service thread + * if this method throws {@link DataModelException}. + * @return response that is to be passed to {@link #processBackgroundResponse} + */ + protected Bundle doBackgroundWork() throws DataModelException { + return null; + } + + /** + * Process the success response from the background worker. Runs on action service thread. + * @param response the response returned by {@link #doBackgroundWork} + * @return result to be passed in to {@link ActionCompletedListener#onActionSucceeded} + */ + protected Object processBackgroundResponse(final Bundle response) { + return null; + } + + /** + * Called in case of failures when sending background actions. Runs on action service thread + * @return result to be passed in to {@link ActionCompletedListener#onActionFailed} + */ + protected Object processBackgroundFailure() { + return null; + } + + /** + * Constructor + */ + protected Action(final String key) { + this.actionKey = key; + this.actionParameters = new Bundle(); + } + + /** + * Constructor + */ + protected Action() { + this.actionKey = generateUniqueActionKey(getClass().getSimpleName()); + this.actionParameters = new Bundle(); + } + + /** + * Queue an action and monitor for processing by the ActionService via the factory helper + */ + protected void start(final ActionMonitor monitor) { + ActionMonitor.registerActionMonitor(this.actionKey, monitor); + DataModel.startActionService(this); + } + + /** + * Queue an action for processing by the ActionService via the factory helper + */ + public void start() { + DataModel.startActionService(this); + } + + /** + * Queue an action for delayed processing by the ActionService via the factory helper + */ + public void schedule(final int requestCode, final long delayMs) { + DataModel.scheduleAction(this, requestCode, delayMs); + } + + /** + * Called when action queues ActionService intent + */ + protected final void markStart() { + ActionMonitor.setState(this, ActionMonitor.STATE_CREATED, + ActionMonitor.STATE_QUEUED); + } + + /** + * Mark the beginning of local action execution + */ + protected final void markBeginExecute() { + ActionMonitor.setState(this, ActionMonitor.STATE_QUEUED, + ActionMonitor.STATE_EXECUTING); + } + + /** + * Mark the end of local action execution - either completes the action or queues + * background actions + */ + protected final void markEndExecute(final Object result) { + final boolean hasBackgroundActions = hasBackgroundActions(); + ActionMonitor.setExecutedState(this, ActionMonitor.STATE_EXECUTING, + hasBackgroundActions, result); + if (!hasBackgroundActions) { + ActionMonitor.setCompleteState(this, ActionMonitor.STATE_EXECUTING, + result, true); + } + } + + /** + * Update action state to indicate that the background worker is starting + */ + protected final void markBackgroundWorkStarting() { + ActionMonitor.setState(this, + ActionMonitor.STATE_BACKGROUND_ACTIONS_QUEUED, + ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION); + } + + /** + * Update action state to indicate that the background worker has posted its response + * (or failure) to the Action service + */ + protected final void markBackgroundCompletionQueued() { + ActionMonitor.setState(this, + ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION, + ActionMonitor.STATE_BACKGROUND_COMPLETION_QUEUED); + } + + /** + * Update action state to indicate the background action failed but is being re-queued for retry + */ + protected final void markBackgroundWorkQueued() { + ActionMonitor.setState(this, + ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION, + ActionMonitor.STATE_BACKGROUND_ACTIONS_QUEUED); + } + + /** + * Called by ActionService to process a response from the background worker + * @param response the response returned by {@link #doBackgroundWork} + */ + protected final void processBackgroundWorkResponse(final Bundle response) { + ActionMonitor.setState(this, + ActionMonitor.STATE_BACKGROUND_COMPLETION_QUEUED, + ActionMonitor.STATE_PROCESSING_BACKGROUND_RESPONSE); + final Object result = processBackgroundResponse(response); + ActionMonitor.setCompleteState(this, + ActionMonitor.STATE_PROCESSING_BACKGROUND_RESPONSE, result, true); + } + + /** + * Called by ActionService when a background action fails + */ + protected final void processBackgroundWorkFailure() { + final Object result = processBackgroundFailure(); + ActionMonitor.setCompleteState(this, ActionMonitor.STATE_UNDEFINED, + result, false); + } + + private static final Object sLock = new Object(); + private static long sActionIdx = System.currentTimeMillis() * 1000; + + /** + * Helper method to generate a unique operation index + */ + protected static long getActionIdx() { + long idx = 0; + synchronized (sLock) { + idx = ++sActionIdx; + } + return idx; + } + + /** + * This helper can be used to generate a unique key used to identify an action. + * @param baseKey - key generated to identify the action parameters + * @return - composite key generated by appending unique index + */ + protected static String generateUniqueActionKey(final String baseKey) { + final StringBuilder key = new StringBuilder(); + if (!TextUtils.isEmpty(baseKey)) { + key.append(baseKey); + } + key.append(":"); + key.append(getActionIdx()); + return key.toString(); + } + + /** + * Most derived classes use this base implementation (unless they include files handles) + */ + @Override + public int describeContents() { + return 0; + } + + /** + * Derived classes need to implement writeToParcel (but typically should call this method + * to parcel Action member variables before they parcel their member variables). + */ + public void writeActionToParcel(final Parcel parcel, final int flags) { + parcel.writeString(this.actionKey); + parcel.writeBundle(this.actionParameters); + } + + /** + * Helper for derived classes to implement parcelable + */ + public Action(final Parcel in) { + this.actionKey = in.readString(); + // Note: Need to set classloader to ensure we can un-parcel classes from this package + this.actionParameters = in.readBundle(Action.class.getClassLoader()); + } +} diff --git a/src/com/android/messaging/datamodel/action/ActionMonitor.java b/src/com/android/messaging/datamodel/action/ActionMonitor.java new file mode 100644 index 0000000..cb080aa --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ActionMonitor.java @@ -0,0 +1,477 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.os.Handler; +import android.support.v4.util.SimpleArrayMap; +import android.text.TextUtils; + +import com.android.messaging.util.Assert.RunsOnAnyThread; +import com.android.messaging.util.Assert.RunsOnMainThread; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.ThreadUtil; +import com.google.common.annotations.VisibleForTesting; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.TimeZone; + +/** + * Base class for action monitors + * Actions come in various flavors but + * o) Fire and forget - no monitor + * o) Immediate local processing only - will trigger ActionCompletedListener when done + * o) Background worker processing only - will trigger ActionCompletedListener when done + * o) Immediate local processing followed by background work followed by more local processing + * - will trigger ActionExecutedListener once local processing complete and + * ActionCompletedListener when second set of local process (dealing with background + * worker response) is complete + */ +public class ActionMonitor { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + /** + * Interface used to notify on completion of local execution for an action + */ + public interface ActionExecutedListener { + /** + * @param result value returned by {@link Action#executeAction} + */ + @RunsOnMainThread + abstract void onActionExecuted(ActionMonitor monitor, final Action action, + final Object data, final Object result); + } + + /** + * Interface used to notify action completion + */ + public interface ActionCompletedListener { + /** + * @param result object returned from processing the action. This is the value returned by + * {@link Action#executeAction} if there is no background work, or + * else the value returned by + * {@link Action#processBackgroundResponse} + */ + @RunsOnMainThread + abstract void onActionSucceeded(ActionMonitor monitor, + final Action action, final Object data, final Object result); + /** + * @param result value returned by {@link Action#processBackgroundFailure} + */ + @RunsOnMainThread + abstract void onActionFailed(ActionMonitor monitor, final Action action, + final Object data, final Object result); + } + + /** + * Interface for being notified of action state changes - used for profiling, testing only + */ + protected interface ActionStateChangedListener { + /** + * @param action the action that is changing state + * @param state the new state of the action + */ + @RunsOnAnyThread + void onActionStateChanged(Action action, int state); + } + + /** + * Operations always start out as STATE_CREATED and finish as STATE_COMPLETE. + * Some common state transition sequences in between include: + *

    + *
  • Local data change only : STATE_QUEUED - STATE_EXECUTING + *
  • Background worker request only : STATE_BACKGROUND_ACTIONS_QUEUED + * - STATE_EXECUTING_BACKGROUND_ACTION + * - STATE_BACKGROUND_COMPLETION_QUEUED + * - STATE_PROCESSING_BACKGROUND_RESPONSE + *
  • Local plus background worker request : STATE_QUEUED - STATE_EXECUTING + * - STATE_BACKGROUND_ACTIONS_QUEUED + * - STATE_EXECUTING_BACKGROUND_ACTION + * - STATE_BACKGROUND_COMPLETION_QUEUED + * - STATE_PROCESSING_BACKGROUND_RESPONSE + *
+ */ + protected static final int STATE_UNDEFINED = 0; + protected static final int STATE_CREATED = 1; // Just created + protected static final int STATE_QUEUED = 2; // Action queued for processing + protected static final int STATE_EXECUTING = 3; // Action processing on datamodel thread + protected static final int STATE_BACKGROUND_ACTIONS_QUEUED = 4; + protected static final int STATE_EXECUTING_BACKGROUND_ACTION = 5; + // The background work has completed, either returning a success response or resulting in a + // failure + protected static final int STATE_BACKGROUND_COMPLETION_QUEUED = 6; + protected static final int STATE_PROCESSING_BACKGROUND_RESPONSE = 7; + protected static final int STATE_COMPLETE = 8; // Action complete + + /** + * Lock used to protect access to state and listeners + */ + private final Object mLock = new Object(); + + /** + * Current state of action + */ + @VisibleForTesting + protected int mState; + + /** + * Listener which is notified on action completion + */ + private ActionCompletedListener mCompletedListener; + + /** + * Listener which is notified on action executed + */ + private ActionExecutedListener mExecutedListener; + + /** + * Listener which is notified of state changes + */ + private ActionStateChangedListener mStateChangedListener; + + /** + * Handler used to post results back to caller + */ + private final Handler mHandler; + + /** + * Data passed back to listeners (associated with the action when it is created) + */ + private final Object mData; + + /** + * The action key is used to determine equivalence of operations and their requests + */ + private final String mActionKey; + + /** + * Get action key identifying associated action + */ + public String getActionKey() { + return mActionKey; + } + + /** + * Unregister listeners so that they will not be called back - override this method if needed + */ + public void unregister() { + clearListeners(); + } + + /** + * Unregister listeners so that they will not be called + */ + protected final void clearListeners() { + synchronized (mLock) { + mCompletedListener = null; + mExecutedListener = null; + } + } + + /** + * Create a monitor associated with a particular action instance + */ + protected ActionMonitor(final int initialState, final String actionKey, + final Object data) { + mHandler = ThreadUtil.getMainThreadHandler(); + mActionKey = actionKey; + mState = initialState; + mData = data; + } + + /** + * Return flag to indicate if action is complete + */ + public boolean isComplete() { + boolean complete = false; + synchronized (mLock) { + complete = (mState == STATE_COMPLETE); + } + return complete; + } + + /** + * Set listener that will be called with action completed result + */ + protected final void setCompletedListener(final ActionCompletedListener listener) { + synchronized (mLock) { + mCompletedListener = listener; + } + } + + /** + * Set listener that will be called with local execution result + */ + protected final void setExecutedListener(final ActionExecutedListener listener) { + synchronized (mLock) { + mExecutedListener = listener; + } + } + + /** + * Set listener that will be called with local execution result + */ + protected final void setStateChangedListener(final ActionStateChangedListener listener) { + synchronized (mLock) { + mStateChangedListener = listener; + } + } + + /** + * Perform a state update transition + * @param action - action whose state is updating + * @param expectedOldState - expected existing state of action (can be UNKNOWN) + * @param newState - new state which will be set + */ + @VisibleForTesting + protected void updateState(final Action action, final int expectedOldState, + final int newState) { + ActionStateChangedListener listener = null; + synchronized (mLock) { + if (expectedOldState != STATE_UNDEFINED && + mState != expectedOldState) { + throw new IllegalStateException("On updateState to " + newState + " was " + mState + + " expecting " + expectedOldState); + } + if (newState != mState) { + mState = newState; + listener = mStateChangedListener; + } + } + if (listener != null) { + listener.onActionStateChanged(action, newState); + } + } + + /** + * Perform a state update transition + * @param action - action whose state is updating + * @param expectedOldState - expected existing state of action (can be UNKNOWN) + * @param newState - new state which will be set + */ + static void setState(final Action action, final int expectedOldState, + final int newState) { + int oldMonitorState = expectedOldState; + int newMonitorState = newState; + final ActionMonitor monitor + = ActionMonitor.lookupActionMonitor(action.actionKey); + if (monitor != null) { + oldMonitorState = monitor.mState; + monitor.updateState(action, expectedOldState, newState); + newMonitorState = monitor.mState; + } + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"); + df.setTimeZone(TimeZone.getTimeZone("UTC")); + LogUtil.v(TAG, "Operation-" + action.actionKey + ": @" + df.format(new Date()) + + "UTC State = " + oldMonitorState + " - " + newMonitorState); + } + } + + /** + * Mark action complete + * @param action - action whose state is updating + * @param expectedOldState - expected existing state of action (can be UNKNOWN) + * @param result - object returned from processing the action. This is the value returned by + * {@link Action#executeAction} if there is no background work, or + * else the value returned by {@link Action#processBackgroundResponse} + * or {@link Action#processBackgroundFailure} + */ + private final void complete(final Action action, + final int expectedOldState, final Object result, + final boolean succeeded) { + ActionCompletedListener completedListener = null; + synchronized (mLock) { + setState(action, expectedOldState, STATE_COMPLETE); + completedListener = mCompletedListener; + mExecutedListener = null; + mStateChangedListener = null; + } + if (completedListener != null) { + // Marshal to UI thread + mHandler.post(new Runnable() { + @Override + public void run() { + ActionCompletedListener listener = null; + synchronized (mLock) { + if (mCompletedListener != null) { + listener = mCompletedListener; + } + mCompletedListener = null; + } + if (listener != null) { + if (succeeded) { + listener.onActionSucceeded(ActionMonitor.this, + action, mData, result); + } else { + listener.onActionFailed(ActionMonitor.this, + action, mData, result); + } + } + } + }); + } + } + + /** + * Mark action complete + * @param action - action whose state is updating + * @param expectedOldState - expected existing state of action (can be UNKNOWN) + * @param result - object returned from processing the action. This is the value returned by + * {@link Action#executeAction} if there is no background work, or + * else the value returned by {@link Action#processBackgroundResponse} + * or {@link Action#processBackgroundFailure} + */ + static void setCompleteState(final Action action, final int expectedOldState, + final Object result, final boolean succeeded) { + int oldMonitorState = expectedOldState; + final ActionMonitor monitor + = ActionMonitor.lookupActionMonitor(action.actionKey); + if (monitor != null) { + oldMonitorState = monitor.mState; + monitor.complete(action, expectedOldState, result, succeeded); + unregisterActionMonitorIfComplete(action.actionKey, monitor); + } + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"); + df.setTimeZone(TimeZone.getTimeZone("UTC")); + LogUtil.v(TAG, "Operation-" + action.actionKey + ": @" + df.format(new Date()) + + "UTC State = " + oldMonitorState + " - " + STATE_COMPLETE); + } + } + + /** + * Mark action complete + * @param action - action whose state is updating + * @param expectedOldState - expected existing state of action (can be UNKNOWN) + * @param hasBackgroundActions - has the completing action requested background work + * @param result - the return value of {@link Action#executeAction} + */ + final void executed(final Action action, + final int expectedOldState, final boolean hasBackgroundActions, final Object result) { + ActionExecutedListener executedListener = null; + synchronized (mLock) { + if (hasBackgroundActions) { + setState(action, expectedOldState, STATE_BACKGROUND_ACTIONS_QUEUED); + } + executedListener = mExecutedListener; + } + if (executedListener != null) { + // Marshal to UI thread + mHandler.post(new Runnable() { + @Override + public void run() { + ActionExecutedListener listener = null; + synchronized (mLock) { + if (mExecutedListener != null) { + listener = mExecutedListener; + mExecutedListener = null; + } + } + if (listener != null) { + listener.onActionExecuted(ActionMonitor.this, + action, mData, result); + } + } + }); + } + } + + /** + * Mark action complete + * @param action - action whose state is updating + * @param expectedOldState - expected existing state of action (can be UNKNOWN) + * @param hasBackgroundActions - has the completing action requested background work + * @param result - the return value of {@link Action#executeAction} + */ + static void setExecutedState(final Action action, + final int expectedOldState, final boolean hasBackgroundActions, final Object result) { + int oldMonitorState = expectedOldState; + final ActionMonitor monitor + = ActionMonitor.lookupActionMonitor(action.actionKey); + if (monitor != null) { + oldMonitorState = monitor.mState; + monitor.executed(action, expectedOldState, hasBackgroundActions, result); + } + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"); + df.setTimeZone(TimeZone.getTimeZone("UTC")); + LogUtil.v(TAG, "Operation-" + action.actionKey + ": @" + df.format(new Date()) + + "UTC State = " + oldMonitorState + " - EXECUTED"); + } + } + + /** + * Map of action monitors indexed by actionKey + */ + @VisibleForTesting + static SimpleArrayMap sActionMonitors = + new SimpleArrayMap(); + + /** + * Insert new monitor into map + */ + static void registerActionMonitor(final String actionKey, + final ActionMonitor monitor) { + if (monitor != null + && (TextUtils.isEmpty(monitor.getActionKey()) + || TextUtils.isEmpty(actionKey) + || !actionKey.equals(monitor.getActionKey()))) { + throw new IllegalArgumentException("Monitor key " + monitor.getActionKey() + + " not compatible with action key " + actionKey); + } + synchronized (sActionMonitors) { + sActionMonitors.put(actionKey, monitor); + } + } + + /** + * Find monitor associated with particular action + */ + private static ActionMonitor lookupActionMonitor(final String actionKey) { + ActionMonitor monitor = null; + synchronized (sActionMonitors) { + monitor = sActionMonitors.get(actionKey); + } + return monitor; + } + + /** + * Remove monitor from map + */ + @VisibleForTesting + static void unregisterActionMonitor(final String actionKey, + final ActionMonitor monitor) { + if (monitor != null) { + synchronized (sActionMonitors) { + sActionMonitors.remove(actionKey); + } + } + } + + /** + * Remove monitor from map if the action is complete + */ + static void unregisterActionMonitorIfComplete(final String actionKey, + final ActionMonitor monitor) { + if (monitor != null && monitor.isComplete()) { + synchronized (sActionMonitors) { + sActionMonitors.remove(actionKey); + } + } + } +} diff --git a/src/com/android/messaging/datamodel/action/ActionService.java b/src/com/android/messaging/datamodel/action/ActionService.java new file mode 100644 index 0000000..29225fa --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ActionService.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.app.PendingIntent; +import android.content.Context; +import android.os.Bundle; + +/** + * Class providing interface for the ActionService - can be stubbed for testing + */ +public class ActionService { + protected static PendingIntent makeStartActionPendingIntent(final Context context, + final Action action, final int requestCode, final boolean launchesAnActivity) { + return ActionServiceImpl.makeStartActionPendingIntent(context, action, requestCode, + launchesAnActivity); + } + + /** + * Start an action by posting it over the the ActionService + */ + public void startAction(final Action action) { + ActionServiceImpl.startAction(action); + } + + /** + * Schedule a delayed action by posting it over the the ActionService + */ + public void scheduleAction(final Action action, final int code, + final long delayMs) { + ActionServiceImpl.scheduleAction(action, code, delayMs); + } + + /** + * Process a response from the BackgroundWorker in the ActionService + */ + protected void handleResponseFromBackgroundWorker( + final Action action, final Bundle response) { + ActionServiceImpl.handleResponseFromBackgroundWorker(action, response); + } + + /** + * Process a failure from the BackgroundWorker in the ActionService + */ + protected void handleFailureFromBackgroundWorker(final Action action, + final Exception exception) { + ActionServiceImpl.handleFailureFromBackgroundWorker(action, exception); + } +} diff --git a/src/com/android/messaging/datamodel/action/ActionServiceImpl.java b/src/com/android/messaging/datamodel/action/ActionServiceImpl.java new file mode 100644 index 0000000..a408dac --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ActionServiceImpl.java @@ -0,0 +1,341 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.app.AlarmManager; +import android.app.IntentService; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.SystemClock; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.LoggingTimer; +import com.android.messaging.util.WakeLockHelper; +import com.google.common.annotations.VisibleForTesting; + +/** + * ActionService used to perform background processing for data model + */ +public class ActionServiceImpl extends IntentService { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + private static final boolean VERBOSE = false; + + public ActionServiceImpl() { + super("ActionService"); + } + + /** + * Start action by sending intent to the service + * @param action - action to start + */ + protected static void startAction(final Action action) { + final Intent intent = makeIntent(OP_START_ACTION); + final Bundle actionBundle = new Bundle(); + actionBundle.putParcelable(BUNDLE_ACTION, action); + intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle); + action.markStart(); + startServiceWithIntent(intent); + } + + /** + * Schedule an action to run after specified delay using alarm manager to send pendingintent + * @param action - action to start + * @param requestCode - request code used to collapse requests + * @param delayMs - delay in ms (from now) before action will start + */ + protected static void scheduleAction(final Action action, final int requestCode, + final long delayMs) { + final Intent intent = PendingActionReceiver.makeIntent(OP_START_ACTION); + final Bundle actionBundle = new Bundle(); + actionBundle.putParcelable(BUNDLE_ACTION, action); + intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle); + + PendingActionReceiver.scheduleAlarm(intent, requestCode, delayMs); + } + + /** + * Handle response returned by BackgroundWorker + * @param request - request generating response + * @param response - response from service + */ + protected static void handleResponseFromBackgroundWorker(final Action action, + final Bundle response) { + final Intent intent = makeIntent(OP_RECEIVE_BACKGROUND_RESPONSE); + + final Bundle actionBundle = new Bundle(); + actionBundle.putParcelable(BUNDLE_ACTION, action); + intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle); + intent.putExtra(EXTRA_WORKER_RESPONSE, response); + + startServiceWithIntent(intent); + } + + /** + * Handle response returned by BackgroundWorker + * @param request - request generating failure + */ + protected static void handleFailureFromBackgroundWorker(final Action action, + final Exception exception) { + final Intent intent = makeIntent(OP_RECEIVE_BACKGROUND_FAILURE); + + final Bundle actionBundle = new Bundle(); + actionBundle.putParcelable(BUNDLE_ACTION, action); + intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle); + intent.putExtra(EXTRA_WORKER_EXCEPTION, exception); + + startServiceWithIntent(intent); + } + + // ops + @VisibleForTesting + protected static final int OP_START_ACTION = 200; + @VisibleForTesting + protected static final int OP_RECEIVE_BACKGROUND_RESPONSE = 201; + @VisibleForTesting + protected static final int OP_RECEIVE_BACKGROUND_FAILURE = 202; + + // extras + @VisibleForTesting + protected static final String EXTRA_OP_CODE = "op"; + @VisibleForTesting + protected static final String EXTRA_ACTION_BUNDLE = "datamodel_action_bundle"; + @VisibleForTesting + protected static final String EXTRA_WORKER_EXCEPTION = "worker_exception"; + @VisibleForTesting + protected static final String EXTRA_WORKER_RESPONSE = "worker_response"; + @VisibleForTesting + protected static final String EXTRA_WORKER_UPDATE = "worker_update"; + @VisibleForTesting + protected static final String BUNDLE_ACTION = "bundle_action"; + + private BackgroundWorker mBackgroundWorker; + + /** + * Allocate an intent with a specific opcode. + */ + private static Intent makeIntent(final int opcode) { + final Intent intent = new Intent(Factory.get().getApplicationContext(), + ActionServiceImpl.class); + intent.putExtra(EXTRA_OP_CODE, opcode); + return intent; + } + + /** + * Broadcast receiver for alarms scheduled through ActionService. + */ + public static class PendingActionReceiver extends BroadcastReceiver { + static final String ACTION = "com.android.messaging.datamodel.PENDING_ACTION"; + + /** + * Allocate an intent with a specific opcode and alarm action. + */ + public static Intent makeIntent(final int opcode) { + final Intent intent = new Intent(Factory.get().getApplicationContext(), + PendingActionReceiver.class); + intent.setAction(ACTION); + intent.putExtra(EXTRA_OP_CODE, opcode); + return intent; + } + + public static void scheduleAlarm(final Intent intent, final int requestCode, + final long delayMs) { + final Context context = Factory.get().getApplicationContext(); + final PendingIntent pendingIntent = PendingIntent.getBroadcast( + context, requestCode, intent, PendingIntent.FLAG_CANCEL_CURRENT); + + final AlarmManager mgr = + (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + + if (delayMs < Long.MAX_VALUE) { + mgr.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, + SystemClock.elapsedRealtime() + delayMs, pendingIntent); + } else { + mgr.cancel(pendingIntent); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void onReceive(final Context context, final Intent intent) { + ActionServiceImpl.startServiceWithIntent(intent); + } + } + + /** + * Creates a pending intent that will trigger a data model action when the intent is + * triggered + */ + public static PendingIntent makeStartActionPendingIntent(final Context context, + final Action action, final int requestCode, final boolean launchesAnActivity) { + final Intent intent = PendingActionReceiver.makeIntent(OP_START_ACTION); + final Bundle actionBundle = new Bundle(); + actionBundle.putParcelable(BUNDLE_ACTION, action); + intent.putExtra(EXTRA_ACTION_BUNDLE, actionBundle); + if (launchesAnActivity) { + intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND); + } + return PendingIntent.getBroadcast(context, requestCode, intent, + PendingIntent.FLAG_UPDATE_CURRENT); + } + + /** + * {@inheritDoc} + */ + @Override + public void onCreate() { + super.onCreate(); + mBackgroundWorker = DataModel.get().getBackgroundWorkerForActionService(); + DataModel.get().getConnectivityUtil().registerForSignalStrength(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + DataModel.get().getConnectivityUtil().unregisterForSignalStrength(); + } + + private static final String WAKELOCK_ID = "bugle_datamodel_service_wakelock"; + @VisibleForTesting + static WakeLockHelper sWakeLock = new WakeLockHelper(WAKELOCK_ID); + + /** + * Queue intent to the ActionService after acquiring wake lock + */ + private static void startServiceWithIntent(final Intent intent) { + final Context context = Factory.get().getApplicationContext(); + final int opcode = intent.getIntExtra(EXTRA_OP_CODE, 0); + // Increase refCount on wake lock - acquiring if necessary + if (VERBOSE) { + LogUtil.v(TAG, "acquiring wakelock for opcode " + opcode); + } + sWakeLock.acquire(context, intent, opcode); + intent.setClass(context, ActionServiceImpl.class); + + // TODO: Note that intent will be quietly discarded if it exceeds available rpc + // memory (in total around 1MB). See this article for background + // http://developer.android.com/reference/android/os/TransactionTooLargeException.html + // Perhaps we should keep large structures in the action monitor? + if (context.startService(intent) == null) { + LogUtil.e(TAG, + "ActionService.startServiceWithIntent: failed to start service for intent " + + intent); + sWakeLock.release(intent, opcode); + } + } + + /** + * {@inheritDoc} + */ + @Override + protected void onHandleIntent(final Intent intent) { + if (intent == null) { + // Shouldn't happen but sometimes does following another crash. + LogUtil.w(TAG, "ActionService.onHandleIntent: Called with null intent"); + return; + } + final int opcode = intent.getIntExtra(EXTRA_OP_CODE, 0); + sWakeLock.ensure(intent, opcode); + + try { + Action action; + final Bundle actionBundle = intent.getBundleExtra(EXTRA_ACTION_BUNDLE); + actionBundle.setClassLoader(getClassLoader()); + switch(opcode) { + case OP_START_ACTION: { + action = (Action) actionBundle.getParcelable(BUNDLE_ACTION); + executeAction(action); + break; + } + + case OP_RECEIVE_BACKGROUND_RESPONSE: { + action = (Action) actionBundle.getParcelable(BUNDLE_ACTION); + final Bundle response = intent.getBundleExtra(EXTRA_WORKER_RESPONSE); + processBackgroundResponse(action, response); + break; + } + + case OP_RECEIVE_BACKGROUND_FAILURE: { + action = (Action) actionBundle.getParcelable(BUNDLE_ACTION); + processBackgroundFailure(action); + break; + } + + default: + throw new RuntimeException("Unrecognized opcode in ActionServiceImpl"); + } + + action.sendBackgroundActions(mBackgroundWorker); + } finally { + // Decrease refCount on wake lock - releasing if necessary + sWakeLock.release(intent, opcode); + } + } + + private static final long EXECUTION_TIME_WARN_LIMIT_MS = 1000; // 1 second + /** + * Local execution of action on ActionService thread + */ + private void executeAction(final Action action) { + action.markBeginExecute(); + + final LoggingTimer timer = createLoggingTimer(action, "#executeAction"); + timer.start(); + + final Object result = action.executeAction(); + + timer.stopAndLog(); + + action.markEndExecute(result); + } + + /** + * Process response on ActionService thread + */ + private void processBackgroundResponse(final Action action, final Bundle response) { + final LoggingTimer timer = createLoggingTimer(action, "#processBackgroundResponse"); + timer.start(); + + action.processBackgroundWorkResponse(response); + + timer.stopAndLog(); + } + + /** + * Process failure on ActionService thread + */ + private void processBackgroundFailure(final Action action) { + final LoggingTimer timer = createLoggingTimer(action, "#processBackgroundFailure"); + timer.start(); + + action.processBackgroundWorkFailure(); + + timer.stopAndLog(); + } + + private static LoggingTimer createLoggingTimer( + final Action action, final String methodName) { + return new LoggingTimer(TAG, action.getClass().getSimpleName() + methodName, + EXECUTION_TIME_WARN_LIMIT_MS); + } +} diff --git a/src/com/android/messaging/datamodel/action/BackgroundWorker.java b/src/com/android/messaging/datamodel/action/BackgroundWorker.java new file mode 100644 index 0000000..aad3c07 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/BackgroundWorker.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import java.util.List; + +/** + * Interface between action service and its workers + */ +public class BackgroundWorker { + + /** + * Send list of requests from action service to a worker + */ + public void queueBackgroundWork(final List backgroundActions) { + BackgroundWorkerService.queueBackgroundWork(backgroundActions); + } +} diff --git a/src/com/android/messaging/datamodel/action/BackgroundWorkerService.java b/src/com/android/messaging/datamodel/action/BackgroundWorkerService.java new file mode 100644 index 0000000..4d4b150 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/BackgroundWorkerService.java @@ -0,0 +1,168 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.app.IntentService; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DataModelException; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.LoggingTimer; +import com.android.messaging.util.WakeLockHelper; +import com.google.common.annotations.VisibleForTesting; + +import java.util.List; + +/** + * Background worker service is an initial example of a background work queue handler + * Used to actually "send" messages which may take some time and should not block ActionService + * or UI + */ +public class BackgroundWorkerService extends IntentService { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + private static final boolean VERBOSE = false; + + private static final String WAKELOCK_ID = "bugle_background_worker_wakelock"; + @VisibleForTesting + static WakeLockHelper sWakeLock = new WakeLockHelper(WAKELOCK_ID); + + private final ActionService mHost; + + public BackgroundWorkerService() { + super("BackgroundWorker"); + mHost = DataModel.get().getActionService(); + } + + /** + * Queue a list of requests from action service to this worker + */ + public static void queueBackgroundWork(final List actions) { + for (final Action action : actions) { + startServiceWithAction(action, 0); + } + } + + // ops + @VisibleForTesting + protected static final int OP_PROCESS_REQUEST = 400; + + // extras + @VisibleForTesting + protected static final String EXTRA_OP_CODE = "op"; + @VisibleForTesting + protected static final String EXTRA_ACTION = "action"; + @VisibleForTesting + protected static final String EXTRA_ATTEMPT = "retry_attempt"; + + /** + * Queue action intent to the BackgroundWorkerService after acquiring wake lock + */ + private static void startServiceWithAction(final Action action, + final int retryCount) { + final Intent intent = new Intent(); + intent.putExtra(EXTRA_ACTION, action); + intent.putExtra(EXTRA_ATTEMPT, retryCount); + startServiceWithIntent(OP_PROCESS_REQUEST, intent); + } + + /** + * Queue intent to the BackgroundWorkerService after acquiring wake lock + */ + private static void startServiceWithIntent(final int opcode, final Intent intent) { + final Context context = Factory.get().getApplicationContext(); + + intent.setClass(context, BackgroundWorkerService.class); + intent.putExtra(EXTRA_OP_CODE, opcode); + sWakeLock.acquire(context, intent, opcode); + if (VERBOSE) { + LogUtil.v(TAG, "acquiring wakelock for opcode " + opcode); + } + + if (context.startService(intent) == null) { + LogUtil.e(TAG, + "BackgroundWorkerService.startServiceWithAction: failed to start service for " + + opcode); + sWakeLock.release(intent, opcode); + } + } + + @Override + protected void onHandleIntent(final Intent intent) { + if (intent == null) { + // Shouldn't happen but sometimes does following another crash. + LogUtil.w(TAG, "BackgroundWorkerService.onHandleIntent: Called with null intent"); + return; + } + final int opcode = intent.getIntExtra(EXTRA_OP_CODE, 0); + sWakeLock.ensure(intent, opcode); + + try { + switch(opcode) { + case OP_PROCESS_REQUEST: { + final Action action = intent.getParcelableExtra(EXTRA_ACTION); + final int attempt = intent.getIntExtra(EXTRA_ATTEMPT, -1); + doBackgroundWork(action, attempt); + break; + } + + default: + throw new RuntimeException("Unrecognized opcode in BackgroundWorkerService"); + } + } finally { + sWakeLock.release(intent, opcode); + } + } + + /** + * Local execution of background work for action on ActionService thread + */ + private void doBackgroundWork(final Action action, final int attempt) { + action.markBackgroundWorkStarting(); + Bundle response = null; + try { + final LoggingTimer timer = new LoggingTimer( + TAG, action.getClass().getSimpleName() + "#doBackgroundWork"); + timer.start(); + + response = action.doBackgroundWork(); + + timer.stopAndLog(); + action.markBackgroundCompletionQueued(); + mHost.handleResponseFromBackgroundWorker(action, response); + } catch (final Exception exception) { + final boolean retry = false; + LogUtil.e(TAG, "Error in background worker", exception); + if (!(exception instanceof DataModelException)) { + // DataModelException is expected (sort-of) and handled in handleFailureFromWorker + // below, but other exceptions should crash ENG builds + Assert.fail("Unexpected error in background worker - abort"); + } + if (retry) { + action.markBackgroundWorkQueued(); + startServiceWithAction(action, attempt + 1); + } else { + action.markBackgroundCompletionQueued(); + mHost.handleFailureFromBackgroundWorker(action, exception); + } + } + } +} diff --git a/src/com/android/messaging/datamodel/action/BugleActionToasts.java b/src/com/android/messaging/datamodel/action/BugleActionToasts.java new file mode 100644 index 0000000..f60facd --- /dev/null +++ b/src/com/android/messaging/datamodel/action/BugleActionToasts.java @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.action; + +import android.content.Context; +import android.content.res.Resources; +import android.widget.Toast; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.ThreadUtil; + +import javax.annotation.Nullable; + +/** + * Shows one-time, transient notifications in response to action failures (i.e. permanent failures + * when sending a message) by showing toasts. + */ +public class BugleActionToasts { + /** + * Called when SendMessageAction or DownloadMmsAction finishes + * @param conversationId the conversation of the sent or downloaded message + * @param success did the action succeed + * @param status the message sending status + * @param isSms whether the message is sent using SMS + * @param subId the subId of the SIM related to this send + * @param isSend whether it is a send (false for download) + */ + static void onSendMessageOrManualDownloadActionCompleted( + final String conversationId, + final boolean success, + final int status, + final boolean isSms, + final int subId, + final boolean isSend) { + // We only show notifications for two cases, i.e. when mobile data is off or when we are + // in airplane mode, both of which fail fast with permanent failures. + if (!success && status == MmsUtils.MMS_REQUEST_MANUAL_RETRY) { + final PhoneUtils phoneUtils = PhoneUtils.get(subId); + if (phoneUtils.isAirplaneModeOn()) { + if (isSend) { + showToast(R.string.send_message_failure_airplane_mode); + } else { + showToast(R.string.download_message_failure_airplane_mode); + } + return; + } else if (!isSms && !phoneUtils.isMobileDataEnabled()) { + if (isSend) { + showToast(R.string.send_message_failure_no_data); + } else { + showToast(R.string.download_message_failure_no_data); + } + return; + } + } + + if (AccessibilityUtil.isTouchExplorationEnabled(Factory.get().getApplicationContext())) { + final boolean isFocusedConversation = DataModel.get().isFocusedConversation(conversationId); + if (isFocusedConversation && success) { + // Using View.announceForAccessibility may be preferable, but we do not have a + // View, and so we use a toast instead. + showToast(isSend ? R.string.send_message_success + : R.string.download_message_success); + return; + } + + // {@link MessageNotificationState#checkFailedMessages} does not post a notification for + // failures in observable conversations. For accessibility, we provide an indication + // here. + final boolean isObservableConversation = DataModel.get().isNewMessageObservable( + conversationId); + if (isObservableConversation && !success) { + showToast(isSend ? R.string.send_message_failure + : R.string.download_message_failure); + } + } + } + + public static void onMessageReceived(final String conversationId, + @Nullable final ParticipantData sender, @Nullable final MessageData message) { + final Context context = Factory.get().getApplicationContext(); + if (AccessibilityUtil.isTouchExplorationEnabled(context)) { + final boolean isFocusedConversation = DataModel.get().isFocusedConversation( + conversationId); + if (isFocusedConversation) { + final Resources res = context.getResources(); + final String senderDisplayName = (sender == null) + ? res.getString(R.string.unknown_sender) : sender.getDisplayName(false); + final String announcement = res.getString( + R.string.incoming_message_announcement, senderDisplayName, + (message == null) ? "" : message.getMessageText()); + showToast(announcement); + } + } + } + + public static void onConversationDeleted() { + showToast(R.string.conversation_deleted); + } + + private static void showToast(final int messageResId) { + ThreadUtil.getMainThreadHandler().post(new Runnable() { + @Override + public void run() { + Toast.makeText(getApplicationContext(), + getApplicationContext().getString(messageResId), Toast.LENGTH_LONG).show(); + } + }); + } + + private static void showToast(final String message) { + ThreadUtil.getMainThreadHandler().post(new Runnable() { + @Override + public void run() { + Toast.makeText(getApplicationContext(), message, Toast.LENGTH_LONG).show(); + } + }); + } + + private static Context getApplicationContext() { + return Factory.get().getApplicationContext(); + } + + private static class UpdateDestinationBlockedActionToast + implements UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener { + private final Context mContext; + + UpdateDestinationBlockedActionToast(final Context context) { + mContext = context; + } + + @Override + public void onUpdateDestinationBlockedAction( + final UpdateDestinationBlockedAction action, + final boolean success, + final boolean block, + final String destination) { + if (success) { + Toast.makeText(mContext, + block + ? R.string.update_destination_blocked + : R.string.update_destination_unblocked, + Toast.LENGTH_LONG + ).show(); + } + } + } + + public static UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener + makeUpdateDestinationBlockedActionListener(final Context context) { + return new UpdateDestinationBlockedActionToast(context); + } +} diff --git a/src/com/android/messaging/datamodel/action/DeleteConversationAction.java b/src/com/android/messaging/datamodel/action/DeleteConversationAction.java new file mode 100644 index 0000000..a00f6d6 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/DeleteConversationAction.java @@ -0,0 +1,205 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DataModelException; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.widget.WidgetConversationProvider; + +import java.util.ArrayList; +import java.util.List; + +/** + * Action used to delete a conversation. + */ +public class DeleteConversationAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + public static void deleteConversation(final String conversationId, final long cutoffTimestamp) { + final DeleteConversationAction action = new DeleteConversationAction(conversationId, + cutoffTimestamp); + action.start(); + } + + private static final String KEY_CONVERSATION_ID = "conversation_id"; + private static final String KEY_CUTOFF_TIMESTAMP = "cutoff_timestamp"; + + private DeleteConversationAction(final String conversationId, final long cutoffTimestamp) { + super(); + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + // TODO: Should we set cuttoff timestamp to prevent us deleting new messages? + actionParameters.putLong(KEY_CUTOFF_TIMESTAMP, cutoffTimestamp); + } + + // Delete conversation from both the local DB and telephony in the background so sync cannot + // run concurrently and incorrectly try to recreate the conversation's messages locally. The + // telephony database can sometimes be quite slow to delete conversations, so we delete from + // the local DB first, notify the UI, and then delete from telephony. + @Override + protected Bundle doBackgroundWork() throws DataModelException { + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + final long cutoffTimestamp = actionParameters.getLong(KEY_CUTOFF_TIMESTAMP); + + if (!TextUtils.isEmpty(conversationId)) { + // First find the thread id for this conversation. + final long threadId = BugleDatabaseOperations.getThreadId(db, conversationId); + + if (BugleDatabaseOperations.deleteConversation(db, conversationId, cutoffTimestamp)) { + LogUtil.i(TAG, "DeleteConversationAction: Deleted local conversation " + + conversationId); + + BugleActionToasts.onConversationDeleted(); + + // Remove notifications if necessary + BugleNotifications.update(true /* silent */, null /* conversationId */, + BugleNotifications.UPDATE_MESSAGES); + + // We have changed the conversation list + MessagingContentProvider.notifyConversationListChanged(); + + // Notify the widget the conversation is deleted so it can go into its configure state. + WidgetConversationProvider.notifyConversationDeleted( + Factory.get().getApplicationContext(), + conversationId); + } else { + LogUtil.w(TAG, "DeleteConversationAction: Could not delete local conversation " + + conversationId); + return null; + } + + // Now delete from telephony DB. MmsSmsProvider throws an exception if the thread id is + // less than 0. If it's greater than zero, it will delete all messages with that thread + // id, even if there's no corresponding row in the threads table. + if (threadId >= 0) { + final int count = MmsUtils.deleteThread(threadId, cutoffTimestamp); + if (count > 0) { + LogUtil.i(TAG, "DeleteConversationAction: Deleted telephony thread " + + threadId + " (cutoffTimestamp = " + cutoffTimestamp + ")"); + } else { + LogUtil.w(TAG, "DeleteConversationAction: Could not delete thread from " + + "telephony: conversationId = " + conversationId + ", thread id = " + + threadId); + } + } else { + LogUtil.w(TAG, "DeleteConversationAction: Local conversation " + conversationId + + " has an invalid telephony thread id; will delete messages individually"); + deleteConversationMessagesFromTelephony(); + } + } else { + LogUtil.e(TAG, "DeleteConversationAction: conversationId is empty"); + } + + return null; + } + + /** + * Deletes all the telephony messages for the local conversation being deleted. + *

+ * This is a fallback used when the conversation is not associated with any telephony thread, + * or its thread id is invalid (e.g. negative). This is not common, but can happen sometimes + * (e.g. the Unknown Sender conversation). In the usual case of deleting a conversation, we + * don't need this because the telephony provider automatically deletes messages when a thread + * is deleted. + */ + private void deleteConversationMessagesFromTelephony() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + Assert.notNull(conversationId); + + final List messageUris = new ArrayList<>(); + Cursor cursor = null; + try { + cursor = db.query(DatabaseHelper.MESSAGES_TABLE, + new String[] { MessageColumns.SMS_MESSAGE_URI }, + MessageColumns.CONVERSATION_ID + "=?", + new String[] { conversationId }, + null, null, null); + while (cursor.moveToNext()) { + String messageUri = cursor.getString(0); + try { + messageUris.add(Uri.parse(messageUri)); + } catch (Exception e) { + LogUtil.e(TAG, "DeleteConversationAction: Could not parse message uri " + + messageUri); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + for (Uri messageUri : messageUris) { + int count = MmsUtils.deleteMessage(messageUri); + if (count > 0) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "DeleteConversationAction: Deleted telephony message " + + messageUri); + } + } else { + LogUtil.w(TAG, "DeleteConversationAction: Could not delete telephony message " + + messageUri); + } + } + } + + @Override + protected Object executeAction() { + requestBackgroundWork(); + return null; + } + + private DeleteConversationAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public DeleteConversationAction createFromParcel(final Parcel in) { + return new DeleteConversationAction(in); + } + + @Override + public DeleteConversationAction[] newArray(final int size) { + return new DeleteConversationAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/DeleteMessageAction.java b/src/com/android/messaging/datamodel/action/DeleteMessageAction.java new file mode 100644 index 0000000..9ddb2a6 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/DeleteMessageAction.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.LogUtil; + +/** + * Action used to delete a single message. + */ +public class DeleteMessageAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + public static void deleteMessage(final String messageId) { + final DeleteMessageAction action = new DeleteMessageAction(messageId); + action.start(); + } + + private static final String KEY_MESSAGE_ID = "message_id"; + + private DeleteMessageAction(final String messageId) { + super(); + actionParameters.putString(KEY_MESSAGE_ID, messageId); + } + + // Doing this work in the background so that we're not competing with sync + // which could bring the deleted message back to life between the time we deleted + // it locally and deleted it in telephony (sync is also done on doBackgroundWork). + // + // Previously this block of code deleted from telephony first but that can be very + // slow (on the order of seconds) so this was modified to first delete locally, trigger + // the UI update, then delete from telephony. + @Override + protected Bundle doBackgroundWork() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + + // First find the thread id for this conversation. + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + + if (!TextUtils.isEmpty(messageId)) { + // Check message still exists + final MessageData message = BugleDatabaseOperations.readMessage(db, messageId); + if (message != null) { + // Delete from local DB + int count = BugleDatabaseOperations.deleteMessage(db, messageId); + if (count > 0) { + LogUtil.i(TAG, "DeleteMessageAction: Deleted local message " + + messageId); + } else { + LogUtil.w(TAG, "DeleteMessageAction: Could not delete local message " + + messageId); + } + MessagingContentProvider.notifyMessagesChanged(message.getConversationId()); + // We may have changed the conversation list + MessagingContentProvider.notifyConversationListChanged(); + + final Uri messageUri = message.getSmsMessageUri(); + if (messageUri != null) { + // Delete from telephony DB + count = MmsUtils.deleteMessage(messageUri); + if (count > 0) { + LogUtil.i(TAG, "DeleteMessageAction: Deleted telephony message " + + messageUri); + } else { + LogUtil.w(TAG, "DeleteMessageAction: Could not delete message from " + + "telephony: messageId = " + messageId + ", telephony uri = " + + messageUri); + } + } else { + LogUtil.i(TAG, "DeleteMessageAction: Local message " + messageId + + " has no telephony uri."); + } + } else { + LogUtil.w(TAG, "DeleteMessageAction: Message " + messageId + " no longer exists"); + } + } + return null; + } + + /** + * Delete the message. + */ + @Override + protected Object executeAction() { + requestBackgroundWork(); + return null; + } + + private DeleteMessageAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public DeleteMessageAction createFromParcel(final Parcel in) { + return new DeleteMessageAction(in); + } + + @Override + public DeleteMessageAction[] newArray(final int size) { + return new DeleteMessageAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/DownloadMmsAction.java b/src/com/android/messaging/datamodel/action/DownloadMmsAction.java new file mode 100644 index 0000000..7a8c907 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/DownloadMmsAction.java @@ -0,0 +1,340 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.ContentValues; +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.SyncManager; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.RunsOnMainThread; +import com.android.messaging.util.LogUtil; + +/** + * Downloads an MMS message. + *

+ * This class is public (not package-private) because the SMS/MMS (e.g. MmsUtils) classes need to + * access the EXTRA_* fields for setting up the 'downloaded' pending intent. + */ +public class DownloadMmsAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + /** + * Interface for DownloadMmsAction listeners + */ + public interface DownloadMmsActionListener { + @RunsOnMainThread + abstract void onDownloadMessageStarting(final ActionMonitor monitor, + final Object data, final MessageData message); + @RunsOnMainThread + abstract void onDownloadMessageSucceeded(final ActionMonitor monitor, + final Object data, final MessageData message); + @RunsOnMainThread + abstract void onDownloadMessageFailed(final ActionMonitor monitor, + final Object data, final MessageData message); + } + + /** + * Queue download of an mms notification message (can only be called during execute of action) + */ + static boolean queueMmsForDownloadInBackground(final String messageId, + final Action processingAction) { + // When this method is being called, it is always from auto download + final DownloadMmsAction action = new DownloadMmsAction(); + // This could queue nothing + return action.queueAction(messageId, processingAction); + } + + private static final String KEY_MESSAGE_ID = "message_id"; + private static final String KEY_CONVERSATION_ID = "conversation_id"; + private static final String KEY_PARTICIPANT_ID = "participant_id"; + private static final String KEY_CONTENT_LOCATION = "content_location"; + private static final String KEY_TRANSACTION_ID = "transaction_id"; + private static final String KEY_NOTIFICATION_URI = "notification_uri"; + private static final String KEY_SUB_ID = "sub_id"; + private static final String KEY_SUB_PHONE_NUMBER = "sub_phone_number"; + private static final String KEY_AUTO_DOWNLOAD = "auto_download"; + private static final String KEY_FAILURE_STATUS = "failure_status"; + + // Values we attach to the pending intent that's fired when the message is downloaded. + // Only applicable when downloading via the platform APIs on L+. + public static final String EXTRA_MESSAGE_ID = "message_id"; + public static final String EXTRA_CONTENT_URI = "content_uri"; + public static final String EXTRA_NOTIFICATION_URI = "notification_uri"; + public static final String EXTRA_SUB_ID = "sub_id"; + public static final String EXTRA_SUB_PHONE_NUMBER = "sub_phone_number"; + public static final String EXTRA_TRANSACTION_ID = "transaction_id"; + public static final String EXTRA_CONTENT_LOCATION = "content_location"; + public static final String EXTRA_AUTO_DOWNLOAD = "auto_download"; + public static final String EXTRA_RECEIVED_TIMESTAMP = "received_timestamp"; + public static final String EXTRA_CONVERSATION_ID = "conversation_id"; + public static final String EXTRA_PARTICIPANT_ID = "participant_id"; + public static final String EXTRA_STATUS_IF_FAILED = "status_if_failed"; + + private DownloadMmsAction() { + super(); + } + + @Override + protected Object executeAction() { + Assert.fail("DownloadMmsAction must be queued rather than started"); + return null; + } + + protected boolean queueAction(final String messageId, final Action processingAction) { + actionParameters.putString(KEY_MESSAGE_ID, messageId); + + final DatabaseWrapper db = DataModel.get().getDatabase(); + // Read the message from local db + final MessageData message = BugleDatabaseOperations.readMessage(db, messageId); + if (message != null && message.canDownloadMessage()) { + final Uri notificationUri = message.getSmsMessageUri(); + final String conversationId = message.getConversationId(); + final int status = message.getStatus(); + + final String selfId = message.getSelfId(); + final ParticipantData self = BugleDatabaseOperations + .getExistingParticipant(db, selfId); + final int subId = self.getSubId(); + actionParameters.putInt(KEY_SUB_ID, subId); + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + actionParameters.putString(KEY_PARTICIPANT_ID, message.getParticipantId()); + actionParameters.putString(KEY_CONTENT_LOCATION, message.getMmsContentLocation()); + actionParameters.putString(KEY_TRANSACTION_ID, message.getMmsTransactionId()); + actionParameters.putParcelable(KEY_NOTIFICATION_URI, notificationUri); + actionParameters.putBoolean(KEY_AUTO_DOWNLOAD, isAutoDownload(status)); + + final long now = System.currentTimeMillis(); + if (message.getInDownloadWindow(now)) { + // We can still retry + actionParameters.putString(KEY_SUB_PHONE_NUMBER, self.getNormalizedDestination()); + + final int downloadingStatus = getDownloadingStatus(status); + // Update message status to indicate downloading. + updateMessageStatus(notificationUri, messageId, conversationId, + downloadingStatus, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED); + // Pre-compute the next status when failed so we don't have to load from db again + actionParameters.putInt(KEY_FAILURE_STATUS, getFailureStatus(downloadingStatus)); + + // Actual download happens in background + processingAction.requestBackgroundWork(this); + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, + "DownloadMmsAction: Queued download of MMS message " + messageId); + } + return true; + } else { + LogUtil.w(TAG, "DownloadMmsAction: Download of MMS message " + messageId + + " failed (outside download window)"); + + // Retries depleted and we failed. Update the message status so we won't retry again + updateMessageStatus(notificationUri, messageId, conversationId, + MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED, + MessageData.RAW_TELEPHONY_STATUS_UNDEFINED); + if (status == MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD) { + // For auto download failure, we should send a DEFERRED NotifyRespInd + // to carrier to indicate we will manual download later + ProcessDownloadedMmsAction.sendDeferredRespStatus( + messageId, message.getMmsTransactionId(), + message.getMmsContentLocation(), subId); + return true; + } + } + } + return false; + } + + /** + * Find out the auto download state of this message based on its starting status + * + * @param status The starting status of the message. + * @return True if this is a message doing auto downloading, false otherwise + */ + private static boolean isAutoDownload(final int status) { + switch (status) { + case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD: + return false; + case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD: + return true; + default: + Assert.fail("isAutoDownload: invalid input status " + status); + return false; + } + } + + /** + * Get the corresponding downloading status based on the starting status of the message + * + * @param status The starting status of the message. + * @return The downloading status + */ + private static int getDownloadingStatus(final int status) { + switch (status) { + case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD: + return MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING; + case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD: + return MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING; + default: + Assert.fail("isAutoDownload: invalid input status " + status); + return MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING; + } + } + + /** + * Get the corresponding failed status based on the current downloading status + * + * @param status The downloading status + * @return The status the message should have if downloading failed + */ + private static int getFailureStatus(final int status) { + switch (status) { + case MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING: + return MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD; + case MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING: + return MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD; + default: + Assert.fail("isAutoDownload: invalid input status " + status); + return MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD; + } + } + + @Override + protected Bundle doBackgroundWork() { + final Context context = Factory.get().getApplicationContext(); + final int subId = actionParameters.getInt(KEY_SUB_ID); + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + final Uri notificationUri = actionParameters.getParcelable(KEY_NOTIFICATION_URI); + final String subPhoneNumber = actionParameters.getString(KEY_SUB_PHONE_NUMBER); + final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID); + final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION); + final boolean autoDownload = actionParameters.getBoolean(KEY_AUTO_DOWNLOAD); + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + final String participantId = actionParameters.getString(KEY_PARTICIPANT_ID); + final int statusIfFailed = actionParameters.getInt(KEY_FAILURE_STATUS); + + final long receivedTimestampRoundedToSecond = + 1000 * ((System.currentTimeMillis() + 500) / 1000); + + LogUtil.i(TAG, "DownloadMmsAction: Downloading MMS message " + messageId + + " (" + (autoDownload ? "auto" : "manual") + ")"); + + // Bundle some values we'll need after the message is downloaded (via platform APIs) + final Bundle extras = new Bundle(); + extras.putString(EXTRA_MESSAGE_ID, messageId); + extras.putString(EXTRA_CONVERSATION_ID, conversationId); + extras.putString(EXTRA_PARTICIPANT_ID, participantId); + extras.putInt(EXTRA_STATUS_IF_FAILED, statusIfFailed); + + // Start the download + final MmsUtils.StatusPlusUri status = MmsUtils.downloadMmsMessage(context, + notificationUri, subId, subPhoneNumber, transactionId, contentLocation, + autoDownload, receivedTimestampRoundedToSecond / 1000L, extras); + if (status == MmsUtils.STATUS_PENDING) { + // Async download; no status yet + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "DownloadMmsAction: Downloading MMS message " + messageId + + " asynchronously; waiting for pending intent to signal completion"); + } + } else { + // Inform sync that message has been added at local received timestamp + final SyncManager syncManager = DataModel.get().getSyncManager(); + syncManager.onNewMessageInserted(receivedTimestampRoundedToSecond); + // Handle downloaded message + ProcessDownloadedMmsAction.processMessageDownloadFastFailed(messageId, + notificationUri, conversationId, participantId, contentLocation, subId, + subPhoneNumber, statusIfFailed, autoDownload, transactionId, + status.resultCode); + } + return null; + } + + @Override + protected Object processBackgroundResponse(final Bundle response) { + // Nothing to do here; post-download actions handled by ProcessDownloadedMmsAction + return null; + } + + @Override + protected Object processBackgroundFailure() { + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID); + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + final String participantId = actionParameters.getString(KEY_PARTICIPANT_ID); + final int statusIfFailed = actionParameters.getInt(KEY_FAILURE_STATUS); + final int subId = actionParameters.getInt(KEY_SUB_ID); + + ProcessDownloadedMmsAction.processDownloadActionFailure(messageId, + MmsUtils.MMS_REQUEST_MANUAL_RETRY, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED, + conversationId, participantId, statusIfFailed, subId, transactionId); + + return null; + } + + static void updateMessageStatus(final Uri messageUri, final String messageId, + final String conversationId, final int status, final int rawStatus) { + final Context context = Factory.get().getApplicationContext(); + // Downloading status just kept in local DB but need to fix up telephony DB first + if (status == MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING || + status == MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING) { + MmsUtils.clearMmsStatus(context, messageUri); + } + // Then mark downloading status in our local DB + final ContentValues values = new ContentValues(); + values.put(MessageColumns.STATUS, status); + values.put(MessageColumns.RAW_TELEPHONY_STATUS, rawStatus); + final DatabaseWrapper db = DataModel.get().getDatabase(); + BugleDatabaseOperations.updateMessageRowIfExists(db, messageId, values); + + MessagingContentProvider.notifyMessagesChanged(conversationId); + } + + private DownloadMmsAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public DownloadMmsAction createFromParcel(final Parcel in) { + return new DownloadMmsAction(in); + } + + @Override + public DownloadMmsAction[] newArray(final int size) { + return new DownloadMmsAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/DumpDatabaseAction.java b/src/com/android/messaging/datamodel/action/DumpDatabaseAction.java new file mode 100644 index 0000000..ab320bf --- /dev/null +++ b/src/com/android/messaging/datamodel/action/DumpDatabaseAction.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.Context; +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.LogUtil; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; + +public class DumpDatabaseAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + public static final String DUMP_NAME = "db_copy.db"; + private static final int BUFFER_SIZE = 16384; + + /** + * Copy the database to external storage + */ + public static void dumpDatabase() { + final DumpDatabaseAction action = new DumpDatabaseAction(); + action.start(); + } + + private DumpDatabaseAction() { + } + + @Override + protected Object executeAction() { + final Context context = Factory.get().getApplicationContext(); + final String dbName = DatabaseHelper.DATABASE_NAME; + BufferedOutputStream bos = null; + BufferedInputStream bis = null; + + long originalSize = 0; + final File inFile = context.getDatabasePath(dbName); + if (inFile.exists() && inFile.isFile()) { + originalSize = inFile.length(); + } + final File outFile = DebugUtils.getDebugFile(DUMP_NAME, true); + if (outFile != null) { + int totalBytes = 0; + try { + bos = new BufferedOutputStream(new FileOutputStream(outFile)); + bis = new BufferedInputStream(new FileInputStream(inFile)); + + final byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + while ((bytesRead = bis.read(buffer)) > 0) { + bos.write(buffer, 0, bytesRead); + totalBytes += bytesRead; + } + } catch (final IOException e) { + LogUtil.w(TAG, "Exception copying the database;" + + " destination may not be complete.", e); + } finally { + if (bos != null) { + try { + bos.close(); + } catch (final IOException e) { + // Nothing to do + } + } + + if (bis != null) { + try { + bis.close(); + } catch (final IOException e) { + // Nothing to do + } + } + DebugUtils.ensureReadable(outFile); + LogUtil.i(TAG, "Dump complete; orig size: " + originalSize + + ", copy size: " + totalBytes); + } + } + return null; + } + + private DumpDatabaseAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public DumpDatabaseAction createFromParcel(final Parcel in) { + return new DumpDatabaseAction(in); + } + + @Override + public DumpDatabaseAction[] newArray(final int size) { + return new DumpDatabaseAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/FixupMessageStatusOnStartupAction.java b/src/com/android/messaging/datamodel/action/FixupMessageStatusOnStartupAction.java new file mode 100644 index 0000000..e3d131d --- /dev/null +++ b/src/com/android/messaging/datamodel/action/FixupMessageStatusOnStartupAction.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.ContentValues; +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.util.LogUtil; + +/** + * Action used to fixup actively downloading or sending status at startup - just in case we + * crash - never run this when a message might actually be sending or downloading. + */ +public class FixupMessageStatusOnStartupAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + public static void fixupMessageStatus() { + final FixupMessageStatusOnStartupAction action = new FixupMessageStatusOnStartupAction(); + action.start(); + } + + private FixupMessageStatusOnStartupAction() { + } + + @Override + protected Object executeAction() { + // Now mark any messages in active sending or downloading state as inactive + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + int downloadFailedCnt = 0; + int sendFailedCnt = 0; + try { + // For both sending and downloading messages, let's assume they failed. + // For MMS sent/downloaded via platform, the sent/downloaded pending intent + // may come back. That will update the message. User may see the message + // in wrong status within a short window if that happens. But this should + // rarely happen. This is a simple solution to situations like app gets killed + // while the pending intent is still in the fly. Alternatively, we could + // keep the status for platform sent/downloaded MMS and timeout these messages. + // But that is much more complex. + final ContentValues values = new ContentValues(); + values.put(DatabaseHelper.MessageColumns.STATUS, + MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED); + downloadFailedCnt += db.update(DatabaseHelper.MESSAGES_TABLE, values, + DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)", + new String[]{ + Integer.toString(MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING), + Integer.toString(MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING) + }); + values.clear(); + + values.clear(); + values.put(DatabaseHelper.MessageColumns.STATUS, + MessageData.BUGLE_STATUS_OUTGOING_FAILED); + sendFailedCnt = db.update(DatabaseHelper.MESSAGES_TABLE, values, + DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)", + new String[]{ + Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_SENDING), + Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_RESENDING) + }); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + LogUtil.i(TAG, "Fixup: Send failed - " + sendFailedCnt + + " Download failed - " + downloadFailedCnt); + + // Don't send contentObserver notifications as displayed text should not change + return null; + } + + private FixupMessageStatusOnStartupAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public FixupMessageStatusOnStartupAction createFromParcel(final Parcel in) { + return new FixupMessageStatusOnStartupAction(in); + } + + @Override + public FixupMessageStatusOnStartupAction[] newArray(final int size) { + return new FixupMessageStatusOnStartupAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/GetOrCreateConversationAction.java b/src/com/android/messaging/datamodel/action/GetOrCreateConversationAction.java new file mode 100644 index 0000000..b262141 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/GetOrCreateConversationAction.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.action.ActionMonitor.ActionCompletedListener; +import com.android.messaging.datamodel.data.LaunchConversationData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.RunsOnMainThread; +import com.android.messaging.util.LogUtil; + +import java.util.ArrayList; + +/** + * Action used to get or create a conversation for a list of conversation participants. + */ +public class GetOrCreateConversationAction extends Action implements Parcelable { + /** + * Interface for GetOrCreateConversationAction listeners + */ + public interface GetOrCreateConversationActionListener { + @RunsOnMainThread + abstract void onGetOrCreateConversationSucceeded(final ActionMonitor monitor, + final Object data, final String conversationId); + + @RunsOnMainThread + abstract void onGetOrCreateConversationFailed(final ActionMonitor monitor, + final Object data); + } + + public static GetOrCreateConversationActionMonitor getOrCreateConversation( + final ArrayList participants, final Object data, + final GetOrCreateConversationActionListener listener) { + final GetOrCreateConversationActionMonitor monitor = new + GetOrCreateConversationActionMonitor(data, listener); + final GetOrCreateConversationAction action = new GetOrCreateConversationAction(participants, + monitor.getActionKey()); + action.start(monitor); + return monitor; + } + + + public static GetOrCreateConversationActionMonitor getOrCreateConversation( + final String[] recipients, final Object data, final LaunchConversationData listener) { + final ArrayList participants = new ArrayList<>(); + for (String recipient : recipients) { + recipient = recipient.trim(); + if (!TextUtils.isEmpty(recipient)) { + participants.add(ParticipantData.getFromRawPhoneBySystemLocale(recipient)); + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "getOrCreateConversation hit empty recipient"); + } + } + return getOrCreateConversation(participants, data, listener); + } + + private static final String KEY_PARTICIPANTS_LIST = "participants_list"; + + private GetOrCreateConversationAction(final ArrayList participants, + final String actionKey) { + super(actionKey); + actionParameters.putParcelableArrayList(KEY_PARTICIPANTS_LIST, participants); + } + + /** + * Lookup the conversation or create a new one. + */ + @Override + protected Object executeAction() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + + // First find the thread id for this list of participants. + final ArrayList participants = + actionParameters.getParcelableArrayList(KEY_PARTICIPANTS_LIST); + BugleDatabaseOperations.sanitizeConversationParticipants(participants); + final ArrayList recipients = + BugleDatabaseOperations.getRecipientsFromConversationParticipants(participants); + + final long threadId = MmsUtils.getOrCreateThreadId(Factory.get().getApplicationContext(), + recipients); + + if (threadId < 0) { + LogUtil.w(LogUtil.BUGLE_TAG, "Couldn't create a threadId in SMS db for numbers : " + + LogUtil.sanitizePII(recipients.toString())); + // TODO: Add a better way to indicate an error from executeAction. + return null; + } + + final String conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId, + false, participants, false, false, null); + + return conversationId; + } + + /** + * A monitor that notifies a listener upon completion + */ + public static class GetOrCreateConversationActionMonitor extends ActionMonitor + implements ActionCompletedListener { + private final GetOrCreateConversationActionListener mListener; + + GetOrCreateConversationActionMonitor(final Object data, + final GetOrCreateConversationActionListener listener) { + super(STATE_CREATED, generateUniqueActionKey("GetOrCreateConversationAction"), data); + setCompletedListener(this); + mListener = listener; + } + + @Override + public void onActionSucceeded(final ActionMonitor monitor, + final Action action, final Object data, final Object result) { + if (result == null) { + mListener.onGetOrCreateConversationFailed(monitor, data); + } else { + mListener.onGetOrCreateConversationSucceeded(monitor, data, (String) result); + } + } + + @Override + public void onActionFailed(final ActionMonitor monitor, + final Action action, final Object data, final Object result) { + // TODO: Currently onActionFailed is only called if there is an error in + // processing requests, not for errors in the local processing. + Assert.fail("Unreachable"); + mListener.onGetOrCreateConversationFailed(monitor, data); + } + } + + private GetOrCreateConversationAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public GetOrCreateConversationAction createFromParcel(final Parcel in) { + return new GetOrCreateConversationAction(in); + } + + @Override + public GetOrCreateConversationAction[] newArray(final int size) { + return new GetOrCreateConversationAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/HandleLowStorageAction.java b/src/com/android/messaging/datamodel/action/HandleLowStorageAction.java new file mode 100644 index 0000000..7bfcfe0 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/HandleLowStorageAction.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.sms.SmsReleaseStorage; +import com.android.messaging.util.Assert; + +/** + * Action used to handle low storage related issues on the device. + */ +public class HandleLowStorageAction extends Action implements Parcelable { + private static final int SUB_OP_CODE_CLEAR_MEDIA_MESSAGES = 100; + private static final int SUB_OP_CODE_CLEAR_OLD_MESSAGES = 101; + + public static void handleDeleteMediaMessages(final long durationInMillis) { + final HandleLowStorageAction action = new HandleLowStorageAction( + SUB_OP_CODE_CLEAR_MEDIA_MESSAGES, durationInMillis); + action.start(); + } + + public static void handleDeleteOldMessages(final long durationInMillis) { + final HandleLowStorageAction action = new HandleLowStorageAction( + SUB_OP_CODE_CLEAR_OLD_MESSAGES, durationInMillis); + action.start(); + } + + private static final String KEY_SUB_OP_CODE = "sub_op_code"; + private static final String KEY_CUTOFF_DURATION_MILLIS = "cutoff_duration_millis"; + + private HandleLowStorageAction(final int subOpcode, final long durationInMillis) { + super(); + actionParameters.putInt(KEY_SUB_OP_CODE, subOpcode); + actionParameters.putLong(KEY_CUTOFF_DURATION_MILLIS, durationInMillis); + } + + @Override + protected Object executeAction() { + final int subOpCode = actionParameters.getInt(KEY_SUB_OP_CODE); + final long durationInMillis = actionParameters.getLong(KEY_CUTOFF_DURATION_MILLIS); + switch (subOpCode) { + case SUB_OP_CODE_CLEAR_MEDIA_MESSAGES: + SmsReleaseStorage.deleteMessages(0, durationInMillis); + break; + + case SUB_OP_CODE_CLEAR_OLD_MESSAGES: + SmsReleaseStorage.deleteMessages(1, durationInMillis); + break; + + default: + Assert.fail("Unsupported action type!"); + break; + } + return true; + } + + private HandleLowStorageAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public HandleLowStorageAction createFromParcel(final Parcel in) { + return new HandleLowStorageAction(in); + } + + @Override + public HandleLowStorageAction[] newArray(final int size) { + return new HandleLowStorageAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/InsertNewMessageAction.java b/src/com/android/messaging/datamodel/action/InsertNewMessageAction.java new file mode 100644 index 0000000..2567ca9 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/InsertNewMessageAction.java @@ -0,0 +1,480 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.Context; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.Telephony; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.SyncManager; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * Action used to convert a draft message to an outgoing message. Its writes SMS messages to + * the telephony db, but {@link SendMessageAction} is responsible for inserting MMS message into + * the telephony DB. The latter also does the actual sending of the message in the background. + * The latter is also responsible for re-sending a failed message. + */ +public class InsertNewMessageAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + private static long sLastSentMessageTimestamp = -1; + + /** + * Insert message (no listener) + */ + public static void insertNewMessage(final MessageData message) { + final InsertNewMessageAction action = new InsertNewMessageAction(message); + action.start(); + } + + /** + * Insert message (no listener) with a given non-default subId. + */ + public static void insertNewMessage(final MessageData message, final int subId) { + Assert.isFalse(subId == ParticipantData.DEFAULT_SELF_SUB_ID); + final InsertNewMessageAction action = new InsertNewMessageAction(message, subId); + action.start(); + } + + /** + * Insert message (no listener) + */ + public static void insertNewMessage(final int subId, final String recipients, + final String messageText, final String subject) { + final InsertNewMessageAction action = new InsertNewMessageAction( + subId, recipients, messageText, subject); + action.start(); + } + + public static long getLastSentMessageTimestamp() { + return sLastSentMessageTimestamp; + } + + private static final String KEY_SUB_ID = "sub_id"; + private static final String KEY_MESSAGE = "message"; + private static final String KEY_RECIPIENTS = "recipients"; + private static final String KEY_MESSAGE_TEXT = "message_text"; + private static final String KEY_SUBJECT_TEXT = "subject_text"; + + private InsertNewMessageAction(final MessageData message) { + this(message, ParticipantData.DEFAULT_SELF_SUB_ID); + actionParameters.putParcelable(KEY_MESSAGE, message); + } + + private InsertNewMessageAction(final MessageData message, final int subId) { + super(); + actionParameters.putParcelable(KEY_MESSAGE, message); + actionParameters.putInt(KEY_SUB_ID, subId); + } + + private InsertNewMessageAction(final int subId, final String recipients, + final String messageText, final String subject) { + super(); + if (TextUtils.isEmpty(recipients) || TextUtils.isEmpty(messageText)) { + Assert.fail("InsertNewMessageAction: Can't have empty recipients or message"); + } + actionParameters.putInt(KEY_SUB_ID, subId); + actionParameters.putString(KEY_RECIPIENTS, recipients); + actionParameters.putString(KEY_MESSAGE_TEXT, messageText); + actionParameters.putString(KEY_SUBJECT_TEXT, subject); + } + + /** + * Add message to database in pending state and queue actual sending + */ + @Override + protected Object executeAction() { + LogUtil.i(TAG, "InsertNewMessageAction: inserting new message"); + MessageData message = actionParameters.getParcelable(KEY_MESSAGE); + if (message == null) { + LogUtil.i(TAG, "InsertNewMessageAction: Creating MessageData with provided data"); + message = createMessage(); + if (message == null) { + LogUtil.w(TAG, "InsertNewMessageAction: Could not create MessageData"); + return null; + } + } + final DatabaseWrapper db = DataModel.get().getDatabase(); + final String conversationId = message.getConversationId(); + + final ParticipantData self = getSelf(db, conversationId, message); + if (self == null) { + return null; + } + message.bindSelfId(self.getId()); + // If the user taps the Send button before the conversation draft is created/loaded by + // ReadDraftDataAction (maybe the action service thread was busy), the MessageData may not + // have the participant id set. It should be equal to the self id, so we'll use that. + if (message.getParticipantId() == null) { + message.bindParticipantId(self.getId()); + } + + final long timestamp = System.currentTimeMillis(); + final ArrayList recipients = + BugleDatabaseOperations.getRecipientsForConversation(db, conversationId); + if (recipients.size() < 1) { + LogUtil.w(TAG, "InsertNewMessageAction: message recipients is empty"); + return null; + } + final int subId = self.getSubId(); + + // TODO: Work out whether to send with SMS or MMS (taking into account recipients)? + final boolean isSms = (message.getProtocol() == MessageData.PROTOCOL_SMS); + if (isSms) { + String sendingConversationId = conversationId; + if (recipients.size() > 1) { + // Broadcast SMS - put message in "fake conversation" before farming out to real 1:1 + final long laterTimestamp = timestamp + 1; + // Send a single message + insertBroadcastSmsMessage(conversationId, message, subId, + laterTimestamp, recipients); + + sendingConversationId = null; + } + + for (final String recipient : recipients) { + // Start actual sending + insertSendingSmsMessage(message, subId, recipient, + timestamp, sendingConversationId); + } + + // Can now clear draft from conversation (deleting attachments if necessary) + BugleDatabaseOperations.updateDraftMessageData(db, conversationId, + null /* message */, BugleDatabaseOperations.UPDATE_MODE_CLEAR_DRAFT); + } else { + final long timestampRoundedToSecond = 1000 * ((timestamp + 500) / 1000); + // Write place holder message directly referencing parts from the draft + final MessageData messageToSend = insertSendingMmsMessage(conversationId, + message, timestampRoundedToSecond); + + // Can now clear draft from conversation (preserving attachments which are now + // referenced by messageToSend) + BugleDatabaseOperations.updateDraftMessageData(db, conversationId, + messageToSend, BugleDatabaseOperations.UPDATE_MODE_CLEAR_DRAFT); + } + MessagingContentProvider.notifyConversationListChanged(); + ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this); + + return message; + } + + private ParticipantData getSelf( + final DatabaseWrapper db, final String conversationId, final MessageData message) { + ParticipantData self; + // Check if we are asked to bind to a non-default subId. This is directly passed in from + // the UI thread so that the sub id may be locked as soon as the user clicks on the Send + // button. + final int requestedSubId = actionParameters.getInt( + KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + if (requestedSubId != ParticipantData.DEFAULT_SELF_SUB_ID) { + self = BugleDatabaseOperations.getOrCreateSelf(db, requestedSubId); + } else { + String selfId = message.getSelfId(); + if (selfId == null) { + // The conversation draft provides no self id hint, meaning that 1) conversation + // self id was not loaded AND 2) the user didn't pick a SIM from the SIM selector. + // In this case, use the conversation's self id. + final ConversationListItemData conversation = + ConversationListItemData.getExistingConversation(db, conversationId); + if (conversation != null) { + selfId = conversation.getSelfId(); + } else { + LogUtil.w(LogUtil.BUGLE_DATAMODEL_TAG, "Conversation " + conversationId + + "already deleted before sending draft message " + + message.getMessageId() + ". Aborting InsertNewMessageAction."); + return null; + } + } + + // We do not use SubscriptionManager.DEFAULT_SUB_ID for sending a message, so we need + // to bind the message to the system default subscription if it's unbound. + final ParticipantData unboundSelf = BugleDatabaseOperations.getExistingParticipant( + db, selfId); + if (unboundSelf.getSubId() == ParticipantData.DEFAULT_SELF_SUB_ID + && OsUtil.isAtLeastL_MR1()) { + final int defaultSubId = PhoneUtils.getDefault().getDefaultSmsSubscriptionId(); + self = BugleDatabaseOperations.getOrCreateSelf(db, defaultSubId); + } else { + self = unboundSelf; + } + } + return self; + } + + /** Create MessageData using KEY_RECIPIENTS, KEY_MESSAGE_TEXT and KEY_SUBJECT */ + private MessageData createMessage() { + // First find the thread id for this list of participants. + final String recipientsList = actionParameters.getString(KEY_RECIPIENTS); + final String messageText = actionParameters.getString(KEY_MESSAGE_TEXT); + final String subjectText = actionParameters.getString(KEY_SUBJECT_TEXT); + final int subId = actionParameters.getInt( + KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + + final ArrayList participants = new ArrayList<>(); + for (final String recipient : recipientsList.split(",")) { + participants.add(ParticipantData.getFromRawPhoneBySimLocale(recipient, subId)); + } + if (participants.size() == 0) { + Assert.fail("InsertNewMessage: Empty participants"); + return null; + } + + final DatabaseWrapper db = DataModel.get().getDatabase(); + BugleDatabaseOperations.sanitizeConversationParticipants(participants); + final ArrayList recipients = + BugleDatabaseOperations.getRecipientsFromConversationParticipants(participants); + if (recipients.size() == 0) { + Assert.fail("InsertNewMessage: Empty recipients"); + return null; + } + + final long threadId = MmsUtils.getOrCreateThreadId(Factory.get().getApplicationContext(), + recipients); + + if (threadId < 0) { + Assert.fail("InsertNewMessage: Couldn't get threadId in SMS db for these recipients: " + + recipients.toString()); + // TODO: How do we fail the action? + return null; + } + + final String conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId, + false, participants, false, false, null); + + final ParticipantData self = BugleDatabaseOperations.getOrCreateSelf(db, subId); + + if (TextUtils.isEmpty(subjectText)) { + return MessageData.createDraftSmsMessage(conversationId, self.getId(), messageText); + } else { + return MessageData.createDraftMmsMessage(conversationId, self.getId(), messageText, + subjectText); + } + } + + private void insertBroadcastSmsMessage(final String conversationId, + final MessageData message, final int subId, final long laterTimestamp, + final ArrayList recipients) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "InsertNewMessageAction: Inserting broadcast SMS message " + + message.getMessageId()); + } + final Context context = Factory.get().getApplicationContext(); + final DatabaseWrapper db = DataModel.get().getDatabase(); + + // Inform sync that message is being added at timestamp + final SyncManager syncManager = DataModel.get().getSyncManager(); + syncManager.onNewMessageInserted(laterTimestamp); + + final long threadId = BugleDatabaseOperations.getThreadId(db, conversationId); + final String address = TextUtils.join(" ", recipients); + + final String messageText = message.getMessageText(); + // Insert message into telephony database sms message table + final Uri messageUri = MmsUtils.insertSmsMessage(context, + Telephony.Sms.CONTENT_URI, + subId, + address, + messageText, + laterTimestamp, + Telephony.Sms.STATUS_COMPLETE, + Telephony.Sms.MESSAGE_TYPE_SENT, threadId); + if (messageUri != null && !TextUtils.isEmpty(messageUri.toString())) { + db.beginTransaction(); + try { + message.updateSendingMessage(conversationId, messageUri, laterTimestamp); + message.markMessageSent(laterTimestamp); + + BugleDatabaseOperations.insertNewMessageInTransaction(db, message); + + BugleDatabaseOperations.updateConversationMetadataInTransaction(db, + conversationId, message.getMessageId(), laterTimestamp, + false /* senderBlocked */, false /* shouldAutoSwitchSelfId */); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "InsertNewMessageAction: Inserted broadcast SMS message " + + message.getMessageId() + ", uri = " + message.getSmsMessageUri()); + } + MessagingContentProvider.notifyMessagesChanged(conversationId); + MessagingContentProvider.notifyPartsChanged(); + } else { + // Ignore error as we only really care about the individual messages? + LogUtil.e(TAG, + "InsertNewMessageAction: No uri for broadcast SMS " + message.getMessageId() + + " inserted into telephony DB"); + } + } + + /** + * Insert SMS messaging into our database and telephony db. + */ + private MessageData insertSendingSmsMessage(final MessageData content, final int subId, + final String recipient, final long timestamp, final String sendingConversationId) { + sLastSentMessageTimestamp = timestamp; + + final Context context = Factory.get().getApplicationContext(); + + // Inform sync that message is being added at timestamp + final SyncManager syncManager = DataModel.get().getSyncManager(); + syncManager.onNewMessageInserted(timestamp); + + final DatabaseWrapper db = DataModel.get().getDatabase(); + + // Send a single message + long threadId; + String conversationId; + if (sendingConversationId == null) { + // For 1:1 message generated sending broadcast need to look up threadId+conversationId + threadId = MmsUtils.getOrCreateSmsThreadId(context, recipient); + conversationId = BugleDatabaseOperations.getOrCreateConversationFromRecipient( + db, threadId, false /* sender blocked */, + ParticipantData.getFromRawPhoneBySimLocale(recipient, subId)); + } else { + // Otherwise just look up threadId + threadId = BugleDatabaseOperations.getThreadId(db, sendingConversationId); + conversationId = sendingConversationId; + } + + final String messageText = content.getMessageText(); + + // Insert message into telephony database sms message table + final Uri messageUri = MmsUtils.insertSmsMessage(context, + Telephony.Sms.CONTENT_URI, + subId, + recipient, + messageText, + timestamp, + Telephony.Sms.STATUS_NONE, + Telephony.Sms.MESSAGE_TYPE_SENT, threadId); + + MessageData message = null; + if (messageUri != null && !TextUtils.isEmpty(messageUri.toString())) { + db.beginTransaction(); + try { + message = MessageData.createDraftSmsMessage(conversationId, + content.getSelfId(), messageText); + message.updateSendingMessage(conversationId, messageUri, timestamp); + + BugleDatabaseOperations.insertNewMessageInTransaction(db, message); + + // Do not update the conversation summary to reflect autogenerated 1:1 messages + if (sendingConversationId != null) { + BugleDatabaseOperations.updateConversationMetadataInTransaction(db, + conversationId, message.getMessageId(), timestamp, + false /* senderBlocked */, false /* shouldAutoSwitchSelfId */); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "InsertNewMessageAction: Inserted SMS message " + + message.getMessageId() + " (uri = " + message.getSmsMessageUri() + + ", timestamp = " + message.getReceivedTimeStamp() + ")"); + } + MessagingContentProvider.notifyMessagesChanged(conversationId); + MessagingContentProvider.notifyPartsChanged(); + } else { + LogUtil.e(TAG, "InsertNewMessageAction: No uri for SMS inserted into telephony DB"); + } + + return message; + } + + /** + * Insert MMS messaging into our database. + */ + private MessageData insertSendingMmsMessage(final String conversationId, + final MessageData message, final long timestamp) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + final List attachmentsUpdated = new ArrayList<>(); + try { + sLastSentMessageTimestamp = timestamp; + + // Insert "draft" message as placeholder until the final message is written to + // the telephony db + message.updateSendingMessage(conversationId, null/*messageUri*/, timestamp); + + // No need to inform SyncManager as message currently has no Uri... + BugleDatabaseOperations.insertNewMessageInTransaction(db, message); + + BugleDatabaseOperations.updateConversationMetadataInTransaction(db, + conversationId, message.getMessageId(), timestamp, + false /* senderBlocked */, false /* shouldAutoSwitchSelfId */); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "InsertNewMessageAction: Inserted MMS message " + + message.getMessageId() + " (timestamp = " + timestamp + ")"); + } + MessagingContentProvider.notifyMessagesChanged(conversationId); + MessagingContentProvider.notifyPartsChanged(); + + return message; + } + + private InsertNewMessageAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public InsertNewMessageAction createFromParcel(final Parcel in) { + return new InsertNewMessageAction(in); + } + + @Override + public InsertNewMessageAction[] newArray(final int size) { + return new InsertNewMessageAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/LogTelephonyDatabaseAction.java b/src/com/android/messaging/datamodel/action/LogTelephonyDatabaseAction.java new file mode 100644 index 0000000..441a5a2 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/LogTelephonyDatabaseAction.java @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.Telephony.Threads; +import android.provider.Telephony.ThreadsColumns; + +import com.android.messaging.Factory; +import com.android.messaging.mmslib.SqliteWrapper; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.LogUtil; + +public class LogTelephonyDatabaseAction extends Action implements Parcelable { + // Because we use sanitizePII, we should also use BUGLE_TAG + private static final String TAG = LogUtil.BUGLE_TAG; + + private static final String[] ALL_THREADS_PROJECTION = { + Threads._ID, + Threads.DATE, + Threads.MESSAGE_COUNT, + Threads.RECIPIENT_IDS, + Threads.SNIPPET, + Threads.SNIPPET_CHARSET, + Threads.READ, + Threads.ERROR, + Threads.HAS_ATTACHMENT }; + + // Constants from the Telephony Database + private static final int ID = 0; + private static final int DATE = 1; + private static final int MESSAGE_COUNT = 2; + private static final int RECIPIENT_IDS = 3; + private static final int SNIPPET = 4; + private static final int SNIPPET_CHAR_SET = 5; + private static final int READ = 6; + private static final int ERROR = 7; + private static final int HAS_ATTACHMENT = 8; + + /** + * Log telephony data to logcat + */ + public static void dumpDatabase() { + final LogTelephonyDatabaseAction action = new LogTelephonyDatabaseAction(); + action.start(); + } + + private LogTelephonyDatabaseAction() { + } + + @Override + protected Object executeAction() { + final Context context = Factory.get().getApplicationContext(); + + if (!DebugUtils.isDebugEnabled()) { + LogUtil.e(TAG, "Can't log telephony database unless debugging is enabled"); + return null; + } + + if (!LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.w(TAG, "Can't log telephony database unless DEBUG is turned on for TAG: " + + TAG); + return null; + } + + LogUtil.d(TAG, "\n"); + LogUtil.d(TAG, "Dump of canoncial_addresses table"); + LogUtil.d(TAG, "*********************************"); + + Cursor cursor = SqliteWrapper.query(context, context.getContentResolver(), + Uri.parse("content://mms-sms/canonical-addresses"), null, null, null, null); + + if (cursor == null) { + LogUtil.w(TAG, "null Cursor in content://mms-sms/canonical-addresses"); + } else { + try { + while (cursor.moveToNext()) { + long id = cursor.getLong(0); + String number = cursor.getString(1); + LogUtil.d(TAG, LogUtil.sanitizePII("id: " + id + " number: " + number)); + } + } finally { + cursor.close(); + } + } + + LogUtil.d(TAG, "\n"); + LogUtil.d(TAG, "Dump of threads table"); + LogUtil.d(TAG, "*********************"); + + cursor = SqliteWrapper.query(context, context.getContentResolver(), + Threads.CONTENT_URI.buildUpon().appendQueryParameter("simple", "true").build(), + ALL_THREADS_PROJECTION, null, null, "date ASC"); + try { + while (cursor.moveToNext()) { + LogUtil.d(TAG, LogUtil.sanitizePII("threadId: " + cursor.getLong(ID) + + " " + ThreadsColumns.DATE + " : " + cursor.getLong(DATE) + + " " + ThreadsColumns.MESSAGE_COUNT + " : " + cursor.getInt(MESSAGE_COUNT) + + " " + ThreadsColumns.SNIPPET + " : " + cursor.getString(SNIPPET) + + " " + ThreadsColumns.READ + " : " + cursor.getInt(READ) + + " " + ThreadsColumns.ERROR + " : " + cursor.getInt(ERROR) + + " " + ThreadsColumns.HAS_ATTACHMENT + " : " + + cursor.getInt(HAS_ATTACHMENT) + + " " + ThreadsColumns.RECIPIENT_IDS + " : " + + cursor.getString(RECIPIENT_IDS))); + } + } finally { + cursor.close(); + } + + return null; + } + + private LogTelephonyDatabaseAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public LogTelephonyDatabaseAction createFromParcel(final Parcel in) { + return new LogTelephonyDatabaseAction(in); + } + + @Override + public LogTelephonyDatabaseAction[] newArray(final int size) { + return new LogTelephonyDatabaseAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/MarkAsReadAction.java b/src/com/android/messaging/datamodel/action/MarkAsReadAction.java new file mode 100644 index 0000000..31bc59d --- /dev/null +++ b/src/com/android/messaging/datamodel/action/MarkAsReadAction.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.ContentValues; +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.LogUtil; + +/** + * Action used to mark all the messages in a conversation as read + */ +public class MarkAsReadAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + private static final String KEY_CONVERSATION_ID = "conversation_id"; + + /** + * Mark all the messages as read for a particular conversation. + */ + public static void markAsRead(final String conversationId) { + final MarkAsReadAction action = new MarkAsReadAction(conversationId); + action.start(); + } + + private MarkAsReadAction(final String conversationId) { + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + } + + @Override + protected Object executeAction() { + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + + // TODO: Consider doing this in background service to avoid delaying other actions + final DatabaseWrapper db = DataModel.get().getDatabase(); + + // Mark all messages in thread as read in telephony + final long threadId = BugleDatabaseOperations.getThreadId(db, conversationId); + if (threadId != -1) { + MmsUtils.updateSmsReadStatus(threadId, Long.MAX_VALUE); + } + + // Update local db + db.beginTransaction(); + try { + final ContentValues values = new ContentValues(); + values.put(MessageColumns.CONVERSATION_ID, conversationId); + values.put(MessageColumns.READ, 1); + values.put(MessageColumns.SEEN, 1); // if they read it, they saw it + + final int count = db.update(DatabaseHelper.MESSAGES_TABLE, values, + "(" + MessageColumns.READ + " !=1 OR " + + MessageColumns.SEEN + " !=1 ) AND " + + MessageColumns.CONVERSATION_ID + "=?", + new String[] { conversationId }); + if (count > 0) { + MessagingContentProvider.notifyMessagesChanged(conversationId); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + // After marking messages as read, update the notifications. This will + // clear the now stale notifications. + BugleNotifications.update(false/*silent*/, BugleNotifications.UPDATE_ALL); + return null; + } + + private MarkAsReadAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public MarkAsReadAction createFromParcel(final Parcel in) { + return new MarkAsReadAction(in); + } + + @Override + public MarkAsReadAction[] newArray(final int size) { + return new MarkAsReadAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/MarkAsSeenAction.java b/src/com/android/messaging/datamodel/action/MarkAsSeenAction.java new file mode 100644 index 0000000..28f55fd --- /dev/null +++ b/src/com/android/messaging/datamodel/action/MarkAsSeenAction.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.ContentValues; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.util.LogUtil; + +/** + * Action used to mark all messages as seen + */ +public class MarkAsSeenAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + private static final String KEY_CONVERSATION_ID = "conversation_id"; + + /** + * Mark all messages as seen. + */ + public static void markAllAsSeen() { + final MarkAsSeenAction action = new MarkAsSeenAction((String) null/*conversationId*/); + action.start(); + } + + /** + * Mark all messages of a given conversation as seen. + */ + public static void markAsSeen(final String conversationId) { + final MarkAsSeenAction action = new MarkAsSeenAction(conversationId); + action.start(); + } + + /** + * ctor for MarkAsSeenAction. + * @param conversationId the conversation id for which to mark as seen, or null to mark all + * messages as seen + */ + public MarkAsSeenAction(final String conversationId) { + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + } + + @Override + protected Object executeAction() { + final String conversationId = + actionParameters.getString(KEY_CONVERSATION_ID); + final boolean hasSpecificConversation = !TextUtils.isEmpty(conversationId); + + // Everything in telephony should already have the seen bit set. + // Possible exception are messages which did not have seen set and + // were sync'ed into bugle. + + // Now mark the messages as seen in the bugle db + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + + try { + final ContentValues values = new ContentValues(); + values.put(MessageColumns.SEEN, 1); + + if (hasSpecificConversation) { + final int count = db.update(DatabaseHelper.MESSAGES_TABLE, values, + MessageColumns.SEEN + " != 1 AND " + + MessageColumns.CONVERSATION_ID + "=?", + new String[] { conversationId }); + if (count > 0) { + MessagingContentProvider.notifyMessagesChanged(conversationId); + } + } else { + db.update(DatabaseHelper.MESSAGES_TABLE, values, + MessageColumns.SEEN + " != 1", null/*selectionArgs*/); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + // After marking messages as seen, update the notifications. This will + // clear the now stale notifications. + BugleNotifications.update(false/*silent*/, BugleNotifications.UPDATE_ALL); + return null; + } + + private MarkAsSeenAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public MarkAsSeenAction createFromParcel(final Parcel in) { + return new MarkAsSeenAction(in); + } + + @Override + public MarkAsSeenAction[] newArray(final int size) { + return new MarkAsSeenAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/ProcessDeliveryReportAction.java b/src/com/android/messaging/datamodel/action/ProcessDeliveryReportAction.java new file mode 100644 index 0000000..fbd4e82 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ProcessDeliveryReportAction.java @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.ContentUris; +import android.content.ContentValues; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.Telephony; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +import java.util.concurrent.TimeUnit; + +public class ProcessDeliveryReportAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + private static final String KEY_URI = "uri"; + private static final String KEY_STATUS = "status"; + + private ProcessDeliveryReportAction(final Uri uri, final int status) { + actionParameters.putParcelable(KEY_URI, uri); + actionParameters.putInt(KEY_STATUS, status); + } + + public static void deliveryReportReceived(final Uri uri, final int status) { + final ProcessDeliveryReportAction action = new ProcessDeliveryReportAction(uri, status); + action.start(); + } + + @Override + protected Object executeAction() { + final Uri smsMessageUri = actionParameters.getParcelable(KEY_URI); + final int status = actionParameters.getInt(KEY_STATUS); + + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final long messageRowId = ContentUris.parseId(smsMessageUri); + if (messageRowId < 0) { + LogUtil.e(TAG, "ProcessDeliveryReportAction: can't find message"); + return null; + } + final long timeSentInMillis = System.currentTimeMillis(); + // Update telephony provider + if (smsMessageUri != null) { + MmsUtils.updateSmsStatusAndDateSent(smsMessageUri, status, timeSentInMillis); + } + + // Update local message + db.beginTransaction(); + try { + final ContentValues values = new ContentValues(); + final int bugleStatus = SyncMessageBatch.bugleStatusForSms(true /*outgoing*/, + Telephony.Sms.MESSAGE_TYPE_SENT /* type */, status); + values.put(DatabaseHelper.MessageColumns.STATUS, bugleStatus); + values.put(DatabaseHelper.MessageColumns.SENT_TIMESTAMP, + TimeUnit.MILLISECONDS.toMicros(timeSentInMillis)); + + final MessageData messageData = + BugleDatabaseOperations.readMessageData(db, smsMessageUri); + + // Check the message was not removed before the delivery report comes in + if (messageData != null) { + Assert.isTrue(smsMessageUri.equals(messageData.getSmsMessageUri())); + + // Row must exist as was just loaded above (on ActionService thread) + BugleDatabaseOperations.updateMessageRow(db, messageData.getMessageId(), values); + + MessagingContentProvider.notifyMessagesChanged(messageData.getConversationId()); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + return null; + } + + private ProcessDeliveryReportAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public ProcessDeliveryReportAction createFromParcel(final Parcel in) { + return new ProcessDeliveryReportAction(in); + } + + @Override + public ProcessDeliveryReportAction[] newArray(final int size) { + return new ProcessDeliveryReportAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/ProcessDownloadedMmsAction.java b/src/com/android/messaging/datamodel/action/ProcessDownloadedMmsAction.java new file mode 100644 index 0000000..757ea05 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ProcessDownloadedMmsAction.java @@ -0,0 +1,573 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.app.Activity; +import android.content.ContentValues; +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.Telephony.Mms; +import android.telephony.SmsManager; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DataModelException; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.MmsFileProvider; +import com.android.messaging.datamodel.SyncManager; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.mmslib.SqliteWrapper; +import com.android.messaging.mmslib.pdu.PduHeaders; +import com.android.messaging.mmslib.pdu.RetrieveConf; +import com.android.messaging.sms.DatabaseMessages; +import com.android.messaging.sms.MmsSender; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.google.common.io.Files; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.List; + +/** + * Processes an MMS message after it has been downloaded. + * NOTE: This action must queue a ProcessPendingMessagesAction when it is done (success or failure). + */ +public class ProcessDownloadedMmsAction extends Action { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + // Always set when message downloaded + private static final String KEY_DOWNLOADED_BY_PLATFORM = "downloaded_by_platform"; + private static final String KEY_MESSAGE_ID = "message_id"; + private static final String KEY_NOTIFICATION_URI = "notification_uri"; + private static final String KEY_CONVERSATION_ID = "conversation_id"; + private static final String KEY_PARTICIPANT_ID = "participant_id"; + private static final String KEY_STATUS_IF_FAILED = "status_if_failed"; + + // Set when message downloaded by platform (L+) + private static final String KEY_RESULT_CODE = "result_code"; + private static final String KEY_HTTP_STATUS_CODE = "http_status_code"; + private static final String KEY_CONTENT_URI = "content_uri"; + private static final String KEY_SUB_ID = "sub_id"; + private static final String KEY_SUB_PHONE_NUMBER = "sub_phone_number"; + private static final String KEY_TRANSACTION_ID = "transaction_id"; + private static final String KEY_CONTENT_LOCATION = "content_location"; + private static final String KEY_AUTO_DOWNLOAD = "auto_download"; + private static final String KEY_RECEIVED_TIMESTAMP = "received_timestamp"; + + // Set when message downloaded by us (legacy) + private static final String KEY_STATUS = "status"; + private static final String KEY_RAW_STATUS = "raw_status"; + private static final String KEY_MMS_URI = "mms_uri"; + + // Used to send a deferred response in response to auto-download failure + private static final String KEY_SEND_DEFERRED_RESP_STATUS = "send_deferred_resp_status"; + + // Results passed from background worker to processCompletion + private static final String BUNDLE_REQUEST_STATUS = "request_status"; + private static final String BUNDLE_RAW_TELEPHONY_STATUS = "raw_status"; + private static final String BUNDLE_MMS_URI = "mms_uri"; + + // This is called when MMS lib API returns via PendingIntent + public static void processMessageDownloaded(final int resultCode, final Bundle extras) { + final String messageId = extras.getString(DownloadMmsAction.EXTRA_MESSAGE_ID); + final Uri contentUri = extras.getParcelable(DownloadMmsAction.EXTRA_CONTENT_URI); + final Uri notificationUri = extras.getParcelable(DownloadMmsAction.EXTRA_NOTIFICATION_URI); + final String conversationId = extras.getString(DownloadMmsAction.EXTRA_CONVERSATION_ID); + final String participantId = extras.getString(DownloadMmsAction.EXTRA_PARTICIPANT_ID); + Assert.notNull(messageId); + Assert.notNull(contentUri); + Assert.notNull(notificationUri); + Assert.notNull(conversationId); + Assert.notNull(participantId); + + final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction(); + final Bundle params = action.actionParameters; + params.putBoolean(KEY_DOWNLOADED_BY_PLATFORM, true); + params.putString(KEY_MESSAGE_ID, messageId); + params.putInt(KEY_RESULT_CODE, resultCode); + params.putInt(KEY_HTTP_STATUS_CODE, + extras.getInt(SmsManager.EXTRA_MMS_HTTP_STATUS, 0)); + params.putParcelable(KEY_CONTENT_URI, contentUri); + params.putParcelable(KEY_NOTIFICATION_URI, notificationUri); + params.putInt(KEY_SUB_ID, + extras.getInt(DownloadMmsAction.EXTRA_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID)); + params.putString(KEY_SUB_PHONE_NUMBER, + extras.getString(DownloadMmsAction.EXTRA_SUB_PHONE_NUMBER)); + params.putString(KEY_TRANSACTION_ID, + extras.getString(DownloadMmsAction.EXTRA_TRANSACTION_ID)); + params.putString(KEY_CONTENT_LOCATION, + extras.getString(DownloadMmsAction.EXTRA_CONTENT_LOCATION)); + params.putBoolean(KEY_AUTO_DOWNLOAD, + extras.getBoolean(DownloadMmsAction.EXTRA_AUTO_DOWNLOAD)); + params.putLong(KEY_RECEIVED_TIMESTAMP, + extras.getLong(DownloadMmsAction.EXTRA_RECEIVED_TIMESTAMP)); + params.putString(KEY_CONVERSATION_ID, conversationId); + params.putString(KEY_PARTICIPANT_ID, participantId); + params.putInt(KEY_STATUS_IF_FAILED, + extras.getInt(DownloadMmsAction.EXTRA_STATUS_IF_FAILED)); + action.start(); + } + + // This is called for fast failing downloading (due to airplane mode or mobile data ) + public static void processMessageDownloadFastFailed(final String messageId, + final Uri notificationUri, final String conversationId, final String participantId, + final String contentLocation, final int subId, final String subPhoneNumber, + final int statusIfFailed, final boolean autoDownload, final String transactionId, + final int resultCode) { + Assert.notNull(messageId); + Assert.notNull(notificationUri); + Assert.notNull(conversationId); + Assert.notNull(participantId); + + final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction(); + final Bundle params = action.actionParameters; + params.putBoolean(KEY_DOWNLOADED_BY_PLATFORM, true); + params.putString(KEY_MESSAGE_ID, messageId); + params.putInt(KEY_RESULT_CODE, resultCode); + params.putParcelable(KEY_NOTIFICATION_URI, notificationUri); + params.putInt(KEY_SUB_ID, subId); + params.putString(KEY_SUB_PHONE_NUMBER, subPhoneNumber); + params.putString(KEY_CONTENT_LOCATION, contentLocation); + params.putBoolean(KEY_AUTO_DOWNLOAD, autoDownload); + params.putString(KEY_CONVERSATION_ID, conversationId); + params.putString(KEY_PARTICIPANT_ID, participantId); + params.putInt(KEY_STATUS_IF_FAILED, statusIfFailed); + params.putString(KEY_TRANSACTION_ID, transactionId); + action.start(); + } + + public static void processDownloadActionFailure(final String messageId, final int status, + final int rawStatus, final String conversationId, final String participantId, + final int statusIfFailed, final int subId, final String transactionId) { + Assert.notNull(messageId); + Assert.notNull(conversationId); + Assert.notNull(participantId); + + final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction(); + final Bundle params = action.actionParameters; + params.putBoolean(KEY_DOWNLOADED_BY_PLATFORM, false); + params.putString(KEY_MESSAGE_ID, messageId); + params.putInt(KEY_STATUS, status); + params.putInt(KEY_RAW_STATUS, rawStatus); + params.putString(KEY_CONVERSATION_ID, conversationId); + params.putString(KEY_PARTICIPANT_ID, participantId); + params.putInt(KEY_STATUS_IF_FAILED, statusIfFailed); + params.putInt(KEY_SUB_ID, subId); + params.putString(KEY_TRANSACTION_ID, transactionId); + action.start(); + } + + public static void sendDeferredRespStatus(final String messageId, final String transactionId, + final String contentLocation, final int subId) { + final ProcessDownloadedMmsAction action = new ProcessDownloadedMmsAction(); + final Bundle params = action.actionParameters; + params.putString(KEY_MESSAGE_ID, messageId); + params.putString(KEY_TRANSACTION_ID, transactionId); + params.putString(KEY_CONTENT_LOCATION, contentLocation); + params.putBoolean(KEY_SEND_DEFERRED_RESP_STATUS, true); + params.putInt(KEY_SUB_ID, subId); + action.start(); + } + + private ProcessDownloadedMmsAction() { + // Callers must use one of the static methods above + } + + @Override + protected Object executeAction() { + // Fire up the background worker + requestBackgroundWork(); + return null; + } + + @Override + protected Bundle doBackgroundWork() throws DataModelException { + final Context context = Factory.get().getApplicationContext(); + final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID); + final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION); + final boolean sendDeferredRespStatus = + actionParameters.getBoolean(KEY_SEND_DEFERRED_RESP_STATUS, false); + + // Send a response indicating that auto-download failed + if (sendDeferredRespStatus) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "DownloadMmsAction: Auto-download of message " + messageId + + " failed; sending DEFERRED NotifyRespInd"); + } + MmsUtils.sendNotifyResponseForMmsDownload( + context, + subId, + MmsUtils.stringToBytes(transactionId, "UTF-8"), + contentLocation, + PduHeaders.STATUS_DEFERRED); + return null; + } + + // Processing a real MMS download + final boolean downloadedByPlatform = actionParameters.getBoolean( + KEY_DOWNLOADED_BY_PLATFORM); + + final int status; + int rawStatus = MmsUtils.PDU_HEADER_VALUE_UNDEFINED; + Uri mmsUri = null; + + if (downloadedByPlatform) { + final int resultCode = actionParameters.getInt(KEY_RESULT_CODE); + if (resultCode == Activity.RESULT_OK) { + final Uri contentUri = actionParameters.getParcelable(KEY_CONTENT_URI); + final File downloadedFile = MmsFileProvider.getFile(contentUri); + byte[] downloadedData = null; + try { + downloadedData = Files.toByteArray(downloadedFile); + } catch (final FileNotFoundException e) { + LogUtil.e(TAG, "ProcessDownloadedMmsAction: MMS download file not found: " + + downloadedFile.getAbsolutePath()); + } catch (final IOException e) { + LogUtil.e(TAG, "ProcessDownloadedMmsAction: Error reading MMS download file: " + + downloadedFile.getAbsolutePath(), e); + } + + // Can delete the temp file now + if (downloadedFile.exists()) { + downloadedFile.delete(); + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "ProcessDownloadedMmsAction: Deleted temp file with " + + "downloaded MMS pdu: " + downloadedFile.getAbsolutePath()); + } + } + + if (downloadedData != null) { + final RetrieveConf retrieveConf = + MmsSender.parseRetrieveConf(downloadedData, subId); + if (MmsUtils.isDumpMmsEnabled()) { + MmsUtils.dumpPdu(downloadedData, retrieveConf); + } + if (retrieveConf != null) { + // Insert the downloaded MMS into telephony + final Uri notificationUri = actionParameters.getParcelable( + KEY_NOTIFICATION_URI); + final String subPhoneNumber = actionParameters.getString( + KEY_SUB_PHONE_NUMBER); + final boolean autoDownload = actionParameters.getBoolean( + KEY_AUTO_DOWNLOAD); + final long receivedTimestampInSeconds = + actionParameters.getLong(KEY_RECEIVED_TIMESTAMP); + + // Inform sync we're adding a message to telephony + final SyncManager syncManager = DataModel.get().getSyncManager(); + syncManager.onNewMessageInserted(receivedTimestampInSeconds * 1000L); + + final MmsUtils.StatusPlusUri result = + MmsUtils.insertDownloadedMessageAndSendResponse(context, + notificationUri, subId, subPhoneNumber, transactionId, + contentLocation, autoDownload, receivedTimestampInSeconds, + retrieveConf); + status = result.status; + rawStatus = result.rawStatus; + mmsUri = result.uri; + } else { + // Invalid response PDU + status = MmsUtils.MMS_REQUEST_MANUAL_RETRY; + } + } else { + // Failed to read download file + status = MmsUtils.MMS_REQUEST_MANUAL_RETRY; + } + } else { + LogUtil.w(TAG, "ProcessDownloadedMmsAction: Platform returned error resultCode: " + + resultCode); + final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE); + status = MmsSender.getErrorResultStatus(resultCode, httpStatusCode); + } + } else { + // Message was already processed by the internal API, or the download action failed. + // In either case, we just need to copy the status to the response bundle. + status = actionParameters.getInt(KEY_STATUS); + rawStatus = actionParameters.getInt(KEY_RAW_STATUS); + mmsUri = actionParameters.getParcelable(KEY_MMS_URI); + } + + final Bundle response = new Bundle(); + response.putInt(BUNDLE_REQUEST_STATUS, status); + response.putInt(BUNDLE_RAW_TELEPHONY_STATUS, rawStatus); + response.putParcelable(BUNDLE_MMS_URI, mmsUri); + return response; + } + + @Override + protected Object processBackgroundResponse(final Bundle response) { + if (response == null) { + // No message download to process; doBackgroundWork sent a notify deferred response + Assert.isTrue(actionParameters.getBoolean(KEY_SEND_DEFERRED_RESP_STATUS)); + return null; + } + + final int status = response.getInt(BUNDLE_REQUEST_STATUS); + final int rawStatus = response.getInt(BUNDLE_RAW_TELEPHONY_STATUS); + final Uri messageUri = response.getParcelable(BUNDLE_MMS_URI); + final boolean autoDownload = actionParameters.getBoolean(KEY_AUTO_DOWNLOAD); + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + + // Do post-processing on downloaded message + final MessageData message = processResult(status, rawStatus, messageUri); + + final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + // If we were trying to auto-download but have failed need to send the deferred response + if (autoDownload && message == null && status == MmsUtils.MMS_REQUEST_MANUAL_RETRY) { + final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID); + final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION); + sendDeferredRespStatus(messageId, transactionId, contentLocation, subId); + } + + if (autoDownload) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + MessageData toastMessage = message; + if (toastMessage == null) { + // If the downloaded failed (message is null), then we should announce the + // receiving of the wap push message. Load the wap push message here instead. + toastMessage = BugleDatabaseOperations.readMessageData(db, messageId); + } + if (toastMessage != null) { + final ParticipantData sender = ParticipantData.getFromId( + db, toastMessage.getParticipantId()); + BugleActionToasts.onMessageReceived( + toastMessage.getConversationId(), sender, toastMessage); + } + } else { + final boolean success = message != null && status == MmsUtils.MMS_REQUEST_SUCCEEDED; + BugleActionToasts.onSendMessageOrManualDownloadActionCompleted( + // If download failed, use the wap push message's conversation instead + success ? message.getConversationId() + : actionParameters.getString(KEY_CONVERSATION_ID), + success, status, false/*isSms*/, subId, false /*isSend*/); + } + + final boolean failed = (messageUri == null); + ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(failed, this); + if (failed) { + BugleNotifications.update(false, BugleNotifications.UPDATE_ERRORS); + } + + return message; + } + + @Override + protected Object processBackgroundFailure() { + if (actionParameters.getBoolean(KEY_SEND_DEFERRED_RESP_STATUS)) { + // We can early-out for these failures. processResult is only designed to handle + // post-processing of MMS downloads (whether successful or not). + LogUtil.w(TAG, + "ProcessDownloadedMmsAction: Exception while sending deferred NotifyRespInd"); + return null; + } + + // Background worker threw an exception; require manual retry + processResult(MmsUtils.MMS_REQUEST_MANUAL_RETRY, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED, + null /* mmsUri */); + + ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(true /* failed */, + this); + + return null; + } + + private MessageData processResult(final int status, final int rawStatus, final Uri mmsUri) { + final Context context = Factory.get().getApplicationContext(); + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + final Uri mmsNotificationUri = actionParameters.getParcelable(KEY_NOTIFICATION_URI); + final String notificationConversationId = actionParameters.getString(KEY_CONVERSATION_ID); + final String notificationParticipantId = actionParameters.getString(KEY_PARTICIPANT_ID); + final int statusIfFailed = actionParameters.getInt(KEY_STATUS_IF_FAILED); + final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + + Assert.notNull(messageId); + + LogUtil.i(TAG, "ProcessDownloadedMmsAction: Processed MMS download of message " + messageId + + "; status is " + MmsUtils.getRequestStatusDescription(status)); + + DatabaseMessages.MmsMessage mms = null; + if (status == MmsUtils.MMS_REQUEST_SUCCEEDED && mmsUri != null) { + // Delete the initial M-Notification.ind from telephony + SqliteWrapper.delete(context, context.getContentResolver(), + mmsNotificationUri, null, null); + + // Read the sent MMS from the telephony provider + mms = MmsUtils.loadMms(mmsUri); + } + + boolean messageInFocusedConversation = false; + boolean messageInObservableConversation = false; + String conversationId = null; + MessageData message = null; + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + try { + if (mms != null) { + final ParticipantData self = ParticipantData.getSelfParticipant(mms.getSubId()); + final String selfId = + BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self); + + final List recipients = MmsUtils.getRecipientsByThread(mms.mThreadId); + String from = MmsUtils.getMmsSender(recipients, mms.getUri()); + if (from == null) { + LogUtil.w(TAG, + "Downloaded an MMS without sender address; using unknown sender."); + from = ParticipantData.getUnknownSenderDestination(); + } + final ParticipantData sender = ParticipantData.getFromRawPhoneBySimLocale(from, + subId); + final String senderParticipantId = + BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, sender); + if (!senderParticipantId.equals(notificationParticipantId)) { + LogUtil.e(TAG, "ProcessDownloadedMmsAction: Downloaded MMS message " + + messageId + " has different sender (participantId = " + + senderParticipantId + ") than notification (" + + notificationParticipantId + ")"); + } + final boolean blockedSender = BugleDatabaseOperations.isBlockedDestination( + db, sender.getNormalizedDestination()); + conversationId = BugleDatabaseOperations.getOrCreateConversationFromThreadId(db, + mms.mThreadId, blockedSender, subId); + + messageInFocusedConversation = + DataModel.get().isFocusedConversation(conversationId); + messageInObservableConversation = + DataModel.get().isNewMessageObservable(conversationId); + + // TODO: Also write these values to the telephony provider + mms.mRead = messageInFocusedConversation; + mms.mSeen = messageInObservableConversation; + + // Translate to our format + message = MmsUtils.createMmsMessage(mms, conversationId, senderParticipantId, + selfId, MessageData.BUGLE_STATUS_INCOMING_COMPLETE); + // Update image sizes. + message.updateSizesForImageParts(); + // Inform sync that message has been added at local received timestamp + final SyncManager syncManager = DataModel.get().getSyncManager(); + syncManager.onNewMessageInserted(message.getReceivedTimeStamp()); + final MessageData current = BugleDatabaseOperations.readMessageData(db, messageId); + if (current == null) { + LogUtil.w(TAG, "Message deleted prior to update"); + BugleDatabaseOperations.insertNewMessageInTransaction(db, message); + } else { + // Overwrite existing notification message + message.updateMessageId(messageId); + // Write message + BugleDatabaseOperations.updateMessageInTransaction(db, message); + } + + if (!TextUtils.equals(notificationConversationId, conversationId)) { + // If this is a group conversation, the message is moved. So the original + // 1v1 conversation (as referenced by notificationConversationId) could + // be left with no non-draft message. Delete the conversation if that + // happens. See the comment for the method below for why we need to do this. + if (!BugleDatabaseOperations.deleteConversationIfEmptyInTransaction( + db, notificationConversationId)) { + BugleDatabaseOperations.maybeRefreshConversationMetadataInTransaction( + db, notificationConversationId, messageId, + true /*shouldAutoSwitchSelfId*/, blockedSender /*keepArchived*/); + } + } + + BugleDatabaseOperations.refreshConversationMetadataInTransaction(db, conversationId, + true /*shouldAutoSwitchSelfId*/, blockedSender /*keepArchived*/); + } else { + messageInFocusedConversation = + DataModel.get().isFocusedConversation(notificationConversationId); + + // Default to retry status unless status indicates otherwise + int bugleStatus = statusIfFailed; + if (status == MmsUtils.MMS_REQUEST_MANUAL_RETRY) { + bugleStatus = MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED; + } else if (status == MmsUtils.MMS_REQUEST_NO_RETRY) { + bugleStatus = MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE; + } + DownloadMmsAction.updateMessageStatus(mmsNotificationUri, messageId, + notificationConversationId, bugleStatus, rawStatus); + + // Log MMS download failed + final int resultCode = actionParameters.getInt(KEY_RESULT_CODE); + final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE); + + // Just in case this was the latest message update the summary data + BugleDatabaseOperations.refreshConversationMetadataInTransaction(db, + notificationConversationId, true /*shouldAutoSwitchSelfId*/, + false /*keepArchived*/); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + if (mmsUri != null) { + // Update mms table with read status now we know the conversation id + final ContentValues values = new ContentValues(1); + values.put(Mms.READ, messageInFocusedConversation); + SqliteWrapper.update(context, context.getContentResolver(), mmsUri, values, + null, null); + } + + // Show a notification to let the user know a new message has arrived + BugleNotifications.update(false /*silent*/, conversationId, BugleNotifications.UPDATE_ALL); + + // Messages may have changed in two conversations + if (conversationId != null) { + MessagingContentProvider.notifyMessagesChanged(conversationId); + } + MessagingContentProvider.notifyMessagesChanged(notificationConversationId); + MessagingContentProvider.notifyPartsChanged(); + + return message; + } + + private ProcessDownloadedMmsAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public ProcessDownloadedMmsAction createFromParcel(final Parcel in) { + return new ProcessDownloadedMmsAction(in); + } + + @Override + public ProcessDownloadedMmsAction[] newArray(final int size) { + return new ProcessDownloadedMmsAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/ProcessPendingMessagesAction.java b/src/com/android/messaging/datamodel/action/ProcessPendingMessagesAction.java new file mode 100644 index 0000000..8a41f4a --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ProcessPendingMessagesAction.java @@ -0,0 +1,470 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.ConnectivityManager; +import android.os.Parcel; +import android.os.Parcelable; +import android.telephony.ServiceState; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.BuglePrefsKeys; +import com.android.messaging.util.ConnectivityUtil.ConnectivityListener; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +import java.util.HashSet; +import java.util.Set; + +/** + * Action used to lookup any messages in the pending send/download state and either fail them or + * retry their action. This action only initiates one retry at a time - further retries should be + * triggered by successful sending of a message, network status change or exponential backoff timer. + */ +public class ProcessPendingMessagesAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + private static final int PENDING_INTENT_REQUEST_CODE = 101; + + public static void processFirstPendingMessage() { + // Clear any pending alarms or connectivity events + unregister(); + // Clear retry count + setRetry(0); + + // Start action + final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction(); + action.start(); + } + + public static void scheduleProcessPendingMessagesAction(final boolean failed, + final Action processingAction) { + LogUtil.i(TAG, "ProcessPendingMessagesAction: Scheduling pending messages" + + (failed ? "(message failed)" : "")); + // Can safely clear any pending alarms or connectivity events as either an action + // is currently running or we will run now or register if pending actions possible. + unregister(); + + final boolean isDefaultSmsApp = PhoneUtils.getDefault().isDefaultSmsApp(); + boolean scheduleAlarm = false; + // If message succeeded and if Bugle is default SMS app just carry on with next message + if (!failed && isDefaultSmsApp) { + // Clear retry attempt count as something just succeeded + setRetry(0); + + // Lookup and queue next message for immediate processing by background worker + // iff there are no pending messages this will do nothing and return true. + final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction(); + if (action.queueActions(processingAction)) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + if (processingAction.hasBackgroundActions()) { + LogUtil.v(TAG, "ProcessPendingMessagesAction: Action queued"); + } else { + LogUtil.v(TAG, "ProcessPendingMessagesAction: No actions to queue"); + } + } + // Have queued next action if needed, nothing more to do + return; + } + // In case of error queuing schedule a retry + scheduleAlarm = true; + LogUtil.w(TAG, "ProcessPendingMessagesAction: Action failed to queue; retrying"); + } + if (getHavePendingMessages() || scheduleAlarm) { + // Still have a pending message that needs to be queued for processing + final ConnectivityListener listener = new ConnectivityListener() { + @Override + public void onConnectivityStateChanged(final Context context, final Intent intent) { + final int networkType = + MmsUtils.getConnectivityEventNetworkType(context, intent); + if (networkType != ConnectivityManager.TYPE_MOBILE) { + return; + } + final boolean isConnected = !intent.getBooleanExtra( + ConnectivityManager.EXTRA_NO_CONNECTIVITY, false); + // TODO: Should we check in more detail? + if (isConnected) { + onConnected(); + } + } + + @Override + public void onPhoneStateChanged(final Context context, final int serviceState) { + if (serviceState == ServiceState.STATE_IN_SERVICE) { + onConnected(); + } + } + + private void onConnected() { + LogUtil.i(TAG, "ProcessPendingMessagesAction: Now connected; starting action"); + + // Clear any pending alarms or connectivity events but leave attempt count alone + unregister(); + + // Start action + final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction(); + action.start(); + } + }; + // Read and increment attempt number from shared prefs + final int retryAttempt = getNextRetry(); + register(listener, retryAttempt); + } else { + // No more pending messages (presumably the message that failed has expired) or it + // may be possible that a send and a download are already in process. + // Clear retry attempt count. + // TODO Might be premature if send and download in process... + // but worst case means we try to send a bit more often. + setRetry(0); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "ProcessPendingMessagesAction: No more pending messages"); + } + } + } + + private static void register(final ConnectivityListener listener, final int retryAttempt) { + int retryNumber = retryAttempt; + + // Register to be notified about connectivity changes + DataModel.get().getConnectivityUtil().register(listener); + + final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction(); + final long initialBackoffMs = BugleGservices.get().getLong( + BugleGservicesKeys.INITIAL_MESSAGE_RESEND_DELAY_MS, + BugleGservicesKeys.INITIAL_MESSAGE_RESEND_DELAY_MS_DEFAULT); + final long maxDelayMs = BugleGservices.get().getLong( + BugleGservicesKeys.MAX_MESSAGE_RESEND_DELAY_MS, + BugleGservicesKeys.MAX_MESSAGE_RESEND_DELAY_MS_DEFAULT); + long delayMs; + long nextDelayMs = initialBackoffMs; + do { + delayMs = nextDelayMs; + retryNumber--; + nextDelayMs = delayMs * 2; + } + while (retryNumber > 0 && nextDelayMs < maxDelayMs); + + LogUtil.i(TAG, "ProcessPendingMessagesAction: Registering for retry #" + retryAttempt + + " in " + delayMs + " ms"); + + action.schedule(PENDING_INTENT_REQUEST_CODE, delayMs); + } + + private static void unregister() { + // Clear any pending alarms or connectivity events + DataModel.get().getConnectivityUtil().unregister(); + + final ProcessPendingMessagesAction action = new ProcessPendingMessagesAction(); + action.schedule(PENDING_INTENT_REQUEST_CODE, Long.MAX_VALUE); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "ProcessPendingMessagesAction: Unregistering for connectivity changed " + + "events and clearing scheduled alarm"); + } + } + + private static void setRetry(final int retryAttempt) { + final BuglePrefs prefs = Factory.get().getApplicationPrefs(); + prefs.putInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, retryAttempt); + } + + private static int getNextRetry() { + final BuglePrefs prefs = Factory.get().getApplicationPrefs(); + final int retryAttempt = + prefs.getInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, 0) + 1; + prefs.putInt(BuglePrefsKeys.PROCESS_PENDING_MESSAGES_RETRY_COUNT, retryAttempt); + return retryAttempt; + } + + private ProcessPendingMessagesAction() { + } + + /** + * Read from the DB and determine if there are any messages we should process + * @return true if we have pending messages + */ + private static boolean getHavePendingMessages() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final long now = System.currentTimeMillis(); + + final String toSendMessageId = findNextMessageToSend(db, now); + if (toSendMessageId != null) { + return true; + } else { + final String toDownloadMessageId = findNextMessageToDownload(db, now); + if (toDownloadMessageId != null) { + return true; + } + } + // Messages may be in the process of sending/downloading even when there are no pending + // messages... + return false; + } + + /** + * Queue any pending actions + * @param actionState + * @return true if action queued (or no actions to queue) else false + */ + private boolean queueActions(final Action processingAction) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final long now = System.currentTimeMillis(); + boolean succeeded = true; + + // Will queue no more than one message to send plus one message to download + // This keeps outgoing messages "in order" but allow downloads to happen even if sending + // gets blocked until messages time out. Manual resend bumps messages to head of queue. + final String toSendMessageId = findNextMessageToSend(db, now); + final String toDownloadMessageId = findNextMessageToDownload(db, now); + if (toSendMessageId != null) { + LogUtil.i(TAG, "ProcessPendingMessagesAction: Queueing message " + toSendMessageId + + " for sending"); + // This could queue nothing + if (!SendMessageAction.queueForSendInBackground(toSendMessageId, processingAction)) { + LogUtil.w(TAG, "ProcessPendingMessagesAction: Failed to queue message " + + toSendMessageId + " for sending"); + succeeded = false; + } + } + if (toDownloadMessageId != null) { + LogUtil.i(TAG, "ProcessPendingMessagesAction: Queueing message " + toDownloadMessageId + + " for download"); + // This could queue nothing + if (!DownloadMmsAction.queueMmsForDownloadInBackground(toDownloadMessageId, + processingAction)) { + LogUtil.w(TAG, "ProcessPendingMessagesAction: Failed to queue message " + + toDownloadMessageId + " for download"); + succeeded = false; + } + } + if (toSendMessageId == null && toDownloadMessageId == null) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "ProcessPendingMessagesAction: No messages to send or download"); + } + } + return succeeded; + } + + @Override + protected Object executeAction() { + // If triggered by alarm will not have unregistered yet + unregister(); + + if (PhoneUtils.getDefault().isDefaultSmsApp()) { + queueActions(this); + } else { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "ProcessPendingMessagesAction: Not default SMS app; rescheduling"); + } + scheduleProcessPendingMessagesAction(true, this); + } + + return null; + } + + private static String findNextMessageToSend(final DatabaseWrapper db, final long now) { + String toSendMessageId = null; + db.beginTransaction(); + Cursor sending = null; + Cursor cursor = null; + int sendingCnt = 0; + int pendingCnt = 0; + int failedCnt = 0; + try { + // First check to see if we have any messages already sending + sending = db.query(DatabaseHelper.MESSAGES_TABLE, + MessageData.getProjection(), + DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)", + new String[]{Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_SENDING), + Integer.toString(MessageData.BUGLE_STATUS_OUTGOING_RESENDING)}, + null, + null, + DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC"); + final boolean messageCurrentlySending = sending.moveToNext(); + sendingCnt = sending.getCount(); + // Look for messages we could send + final ContentValues values = new ContentValues(); + values.put(DatabaseHelper.MessageColumns.STATUS, + MessageData.BUGLE_STATUS_OUTGOING_FAILED); + cursor = db.query(DatabaseHelper.MESSAGES_TABLE, + MessageData.getProjection(), + DatabaseHelper.MessageColumns.STATUS + " IN (" + + MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND + "," + + MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY + ")", + null, + null, + null, + DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC"); + pendingCnt = cursor.getCount(); + + while (cursor.moveToNext()) { + final MessageData message = new MessageData(); + message.bind(cursor); + if (message.getInResendWindow(now)) { + // If no messages currently sending + if (!messageCurrentlySending) { + // Resend this message + toSendMessageId = message.getMessageId(); + // Before queuing the message for resending, check if the message's self is + // active. If not, switch back to the system's default subscription. + if (OsUtil.isAtLeastL_MR1()) { + final ParticipantData messageSelf = BugleDatabaseOperations + .getExistingParticipant(db, message.getSelfId()); + if (messageSelf == null || !messageSelf.isActiveSubscription()) { + final ParticipantData defaultSelf = BugleDatabaseOperations + .getOrCreateSelf(db, PhoneUtils.getDefault() + .getDefaultSmsSubscriptionId()); + if (defaultSelf != null) { + message.bindSelfId(defaultSelf.getId()); + final ContentValues selfValues = new ContentValues(); + selfValues.put(MessageColumns.SELF_PARTICIPANT_ID, + defaultSelf.getId()); + BugleDatabaseOperations.updateMessageRow(db, + message.getMessageId(), selfValues); + MessagingContentProvider.notifyMessagesChanged( + message.getConversationId()); + } + } + } + } + break; + } else { + failedCnt++; + + // Mark message as failed + BugleDatabaseOperations.updateMessageRow(db, message.getMessageId(), values); + MessagingContentProvider.notifyMessagesChanged(message.getConversationId()); + } + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + if (cursor != null) { + cursor.close(); + } + if (sending != null) { + sending.close(); + } + } + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "ProcessPendingMessagesAction: " + + sendingCnt + " messages already sending, " + + pendingCnt + " messages to send, " + + failedCnt + " failed messages"); + } + + return toSendMessageId; + } + + private static String findNextMessageToDownload(final DatabaseWrapper db, final long now) { + String toDownloadMessageId = null; + db.beginTransaction(); + Cursor cursor = null; + int downloadingCnt = 0; + int pendingCnt = 0; + try { + // First check if we have any messages already downloading + downloadingCnt = (int) db.queryNumEntries(DatabaseHelper.MESSAGES_TABLE, + DatabaseHelper.MessageColumns.STATUS + " IN (?, ?)", + new String[] { + Integer.toString(MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING), + Integer.toString(MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING) + }); + + // TODO: This query is not actually needed if downloadingCnt == 0. + cursor = db.query(DatabaseHelper.MESSAGES_TABLE, + MessageData.getProjection(), + DatabaseHelper.MessageColumns.STATUS + " =? OR " + + DatabaseHelper.MessageColumns.STATUS + " =?", + new String[]{ + Integer.toString( + MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD), + Integer.toString( + MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD) + }, + null, + null, + DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP + " ASC"); + + pendingCnt = cursor.getCount(); + + // If no messages are currently downloading and there is a download pending, + // queue the download of the oldest pending message. + if (downloadingCnt == 0 && cursor.moveToNext()) { + // Always start the next pending message. We will check if a download has + // expired in DownloadMmsAction and mark message failed there. + final MessageData message = new MessageData(); + message.bind(cursor); + toDownloadMessageId = message.getMessageId(); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + if (cursor != null) { + cursor.close(); + } + } + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "ProcessPendingMessagesAction: " + + downloadingCnt + " messages already downloading, " + + pendingCnt + " messages to download"); + } + + return toDownloadMessageId; + } + + private ProcessPendingMessagesAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public ProcessPendingMessagesAction createFromParcel(final Parcel in) { + return new ProcessPendingMessagesAction(in); + } + + @Override + public ProcessPendingMessagesAction[] newArray(final int size) { + return new ProcessPendingMessagesAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/ProcessSentMessageAction.java b/src/com/android/messaging/datamodel/action/ProcessSentMessageAction.java new file mode 100644 index 0000000..f408e47 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ProcessSentMessageAction.java @@ -0,0 +1,310 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.app.Activity; +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.telephony.PhoneNumberUtils; +import android.telephony.SmsManager; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MmsFileProvider; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.mmslib.pdu.SendConf; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.sms.MmsSender; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +import java.io.File; +import java.util.ArrayList; + +/** +* Update message status to reflect success or failure +* Can also update the message itself if a "final" message is now available from telephony db +*/ +public class ProcessSentMessageAction extends Action { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + // These are always set + private static final String KEY_SMS = "is_sms"; + private static final String KEY_SENT_BY_PLATFORM = "sent_by_platform"; + + // These are set when we're processing a message sent by the user. They are null for messages + // sent automatically (e.g. a NotifyRespInd/AcknowledgeInd sent in response to a download). + private static final String KEY_MESSAGE_ID = "message_id"; + private static final String KEY_MESSAGE_URI = "message_uri"; + private static final String KEY_UPDATED_MESSAGE_URI = "updated_message_uri"; + private static final String KEY_SUB_ID = "sub_id"; + + // These are set for messages sent by the platform (L+) + public static final String KEY_RESULT_CODE = "result_code"; + public static final String KEY_HTTP_STATUS_CODE = "http_status_code"; + private static final String KEY_CONTENT_URI = "content_uri"; + private static final String KEY_RESPONSE = "response"; + private static final String KEY_RESPONSE_IMPORTANT = "response_important"; + + // These are set for messages we sent ourself (legacy), or which we fast-failed before sending. + private static final String KEY_STATUS = "status"; + private static final String KEY_RAW_STATUS = "raw_status"; + + // This is called when MMS lib API returns via PendingIntent + public static void processMmsSent(final int resultCode, final Uri messageUri, + final Bundle extras) { + final ProcessSentMessageAction action = new ProcessSentMessageAction(); + final Bundle params = action.actionParameters; + params.putBoolean(KEY_SMS, false); + params.putBoolean(KEY_SENT_BY_PLATFORM, true); + params.putString(KEY_MESSAGE_ID, extras.getString(SendMessageAction.EXTRA_MESSAGE_ID)); + params.putParcelable(KEY_MESSAGE_URI, messageUri); + params.putParcelable(KEY_UPDATED_MESSAGE_URI, + extras.getParcelable(SendMessageAction.EXTRA_UPDATED_MESSAGE_URI)); + params.putInt(KEY_SUB_ID, + extras.getInt(SendMessageAction.KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID)); + params.putInt(KEY_RESULT_CODE, resultCode); + params.putInt(KEY_HTTP_STATUS_CODE, extras.getInt(SmsManager.EXTRA_MMS_HTTP_STATUS, 0)); + params.putParcelable(KEY_CONTENT_URI, + extras.getParcelable(SendMessageAction.EXTRA_CONTENT_URI)); + params.putByteArray(KEY_RESPONSE, extras.getByteArray(SmsManager.EXTRA_MMS_DATA)); + params.putBoolean(KEY_RESPONSE_IMPORTANT, + extras.getBoolean(SendMessageAction.EXTRA_RESPONSE_IMPORTANT)); + action.start(); + } + + public static void processMessageSentFastFailed(final String messageId, + final Uri messageUri, final Uri updatedMessageUri, final int subId, final boolean isSms, + final int status, final int rawStatus, final int resultCode) { + final ProcessSentMessageAction action = new ProcessSentMessageAction(); + final Bundle params = action.actionParameters; + params.putBoolean(KEY_SMS, isSms); + params.putBoolean(KEY_SENT_BY_PLATFORM, false); + params.putString(KEY_MESSAGE_ID, messageId); + params.putParcelable(KEY_MESSAGE_URI, messageUri); + params.putParcelable(KEY_UPDATED_MESSAGE_URI, updatedMessageUri); + params.putInt(KEY_SUB_ID, subId); + params.putInt(KEY_STATUS, status); + params.putInt(KEY_RAW_STATUS, rawStatus); + params.putInt(KEY_RESULT_CODE, resultCode); + action.start(); + } + + private ProcessSentMessageAction() { + // Callers must use one of the static methods above + } + + /** + * Update message status to reflect success or failure + * Can also update the message itself if a "final" message is now available from telephony db + */ + @Override + protected Object executeAction() { + final Context context = Factory.get().getApplicationContext(); + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + final Uri messageUri = actionParameters.getParcelable(KEY_MESSAGE_URI); + final Uri updatedMessageUri = actionParameters.getParcelable(KEY_UPDATED_MESSAGE_URI); + final boolean isSms = actionParameters.getBoolean(KEY_SMS); + final boolean sentByPlatform = actionParameters.getBoolean(KEY_SENT_BY_PLATFORM); + + int status = actionParameters.getInt(KEY_STATUS, MmsUtils.MMS_REQUEST_MANUAL_RETRY); + int rawStatus = actionParameters.getInt(KEY_RAW_STATUS, + MmsUtils.PDU_HEADER_VALUE_UNDEFINED); + final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + + if (sentByPlatform) { + // Delete temporary file backing the contentUri passed to MMS service + final Uri contentUri = actionParameters.getParcelable(KEY_CONTENT_URI); + Assert.isTrue(contentUri != null); + final File tempFile = MmsFileProvider.getFile(contentUri); + long messageSize = 0; + if (tempFile.exists()) { + messageSize = tempFile.length(); + tempFile.delete(); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "ProcessSentMessageAction: Deleted temp file with outgoing " + + "MMS pdu: " + contentUri); + } + } + + final int resultCode = actionParameters.getInt(KEY_RESULT_CODE); + final boolean responseImportant = actionParameters.getBoolean(KEY_RESPONSE_IMPORTANT); + if (resultCode == Activity.RESULT_OK) { + if (responseImportant) { + // Get the status from the response PDU and update telephony + final byte[] response = actionParameters.getByteArray(KEY_RESPONSE); + final SendConf sendConf = MmsSender.parseSendConf(response, subId); + if (sendConf != null) { + final MmsUtils.StatusPlusUri result = + MmsUtils.updateSentMmsMessageStatus(context, messageUri, sendConf); + status = result.status; + rawStatus = result.rawStatus; + } + } + } else { + String errorMsg = "ProcessSentMessageAction: Platform returned error resultCode: " + + resultCode; + final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE); + if (httpStatusCode != 0) { + errorMsg += (", HTTP status code: " + httpStatusCode); + } + LogUtil.w(TAG, errorMsg); + status = MmsSender.getErrorResultStatus(resultCode, httpStatusCode); + + // Check for MMS messages that failed because they exceeded the maximum size, + // indicated by an I/O error from the platform. + if (resultCode == SmsManager.MMS_ERROR_IO_ERROR) { + if (messageSize > MmsConfig.get(subId).getMaxMessageSize()) { + rawStatus = MessageData.RAW_TELEPHONY_STATUS_MESSAGE_TOO_BIG; + } + } + } + } + if (messageId != null) { + final int resultCode = actionParameters.getInt(KEY_RESULT_CODE); + final int httpStatusCode = actionParameters.getInt(KEY_HTTP_STATUS_CODE); + processResult( + messageId, updatedMessageUri, status, rawStatus, isSms, this, subId, + resultCode, httpStatusCode); + } else { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "ProcessSentMessageAction: No sent message to process (it was " + + "probably a notify response for an MMS download)"); + } + } + return null; + } + + static void processResult(final String messageId, Uri updatedMessageUri, int status, + final int rawStatus, final boolean isSms, final Action processingAction, + final int subId, final int resultCode, final int httpStatusCode) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + MessageData message = BugleDatabaseOperations.readMessage(db, messageId); + final MessageData originalMessage = message; + if (message == null) { + LogUtil.w(TAG, "ProcessSentMessageAction: Sent message " + messageId + + " missing from local database"); + return; + } + final String conversationId = message.getConversationId(); + if (updatedMessageUri != null) { + // Update message if we have newly written final message in the telephony db + final MessageData update = MmsUtils.readSendingMmsMessage(updatedMessageUri, + conversationId, message.getParticipantId(), message.getSelfId()); + if (update != null) { + // Set message Id of final message to that of the existing place holder. + update.updateMessageId(message.getMessageId()); + // Update image sizes. + update.updateSizesForImageParts(); + // Temp attachments are no longer needed + for (final MessagePartData part : message.getParts()) { + part.destroySync(); + } + message = update; + // processResult will rewrite the complete message as part of update + } else { + updatedMessageUri = null; + status = MmsUtils.MMS_REQUEST_MANUAL_RETRY; + LogUtil.e(TAG, "ProcessSentMessageAction: Unable to read sending message"); + } + } + + final long timestamp = System.currentTimeMillis(); + boolean failed; + if (status == MmsUtils.MMS_REQUEST_SUCCEEDED) { + message.markMessageSent(timestamp); + failed = false; + } else if (status == MmsUtils.MMS_REQUEST_AUTO_RETRY + && message.getInResendWindow(timestamp)) { + message.markMessageNotSent(timestamp); + message.setRawTelephonyStatus(rawStatus); + failed = false; + } else { + message.markMessageFailed(timestamp); + message.setRawTelephonyStatus(rawStatus); + message.setMessageSeen(false); + failed = true; + } + + // We have special handling for when a message to an emergency number fails. In this case, + // we notify immediately of any failure (even if we auto-retry), and instruct the user to + // try calling the emergency number instead. + if (status != MmsUtils.MMS_REQUEST_SUCCEEDED) { + final ArrayList recipients = + BugleDatabaseOperations.getRecipientsForConversation(db, conversationId); + for (final String recipient : recipients) { + if (PhoneNumberUtils.isEmergencyNumber(recipient)) { + BugleNotifications.notifyEmergencySmsFailed(recipient, conversationId); + message.markMessageFailedEmergencyNumber(timestamp); + failed = true; + break; + } + } + } + + // Update the message status and optionally refresh the message with final parts/values. + if (SendMessageAction.updateMessageAndStatus(isSms, message, updatedMessageUri, failed)) { + // We shouldn't show any notifications if we're not allowed to modify Telephony for + // this message. + if (failed) { + BugleNotifications.update(false, BugleNotifications.UPDATE_ERRORS); + } + BugleActionToasts.onSendMessageOrManualDownloadActionCompleted( + conversationId, !failed, status, isSms, subId, true/*isSend*/); + } + + LogUtil.i(TAG, "ProcessSentMessageAction: Done sending " + (isSms ? "SMS" : "MMS") + + " message " + message.getMessageId() + + " in conversation " + conversationId + + "; status is " + MmsUtils.getRequestStatusDescription(status)); + + // Whether we succeeded or failed we will check and maybe schedule some more work + ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction( + status != MmsUtils.MMS_REQUEST_SUCCEEDED, processingAction); + } + + private ProcessSentMessageAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public ProcessSentMessageAction createFromParcel(final Parcel in) { + return new ProcessSentMessageAction(in); + } + + @Override + public ProcessSentMessageAction[] newArray(final int size) { + return new ProcessSentMessageAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/ReadDraftDataAction.java b/src/com/android/messaging/datamodel/action/ReadDraftDataAction.java new file mode 100644 index 0000000..7ac646b --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ReadDraftDataAction.java @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.action.ActionMonitor.ActionCompletedListener; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.RunsOnMainThread; +import com.android.messaging.util.LogUtil; +import com.google.common.annotations.VisibleForTesting; + +public class ReadDraftDataAction extends Action implements Parcelable { + + /** + * Interface for ReadDraftDataAction listeners + */ + public interface ReadDraftDataActionListener { + @RunsOnMainThread + abstract void onReadDraftDataSucceeded(final ReadDraftDataAction action, + final Object data, final MessageData message, + final ConversationListItemData conversation); + @RunsOnMainThread + abstract void onReadDraftDataFailed(final ReadDraftDataAction action, final Object data); + } + + /** + * Read draft message and associated data (with listener) + */ + public static ReadDraftDataActionMonitor readDraftData(final String conversationId, + final MessageData incomingDraft, final Object data, + final ReadDraftDataActionListener listener) { + final ReadDraftDataActionMonitor monitor = new ReadDraftDataActionMonitor(data, + listener); + final ReadDraftDataAction action = new ReadDraftDataAction(conversationId, + incomingDraft, monitor.getActionKey()); + action.start(monitor); + return monitor; + } + + private static final String KEY_CONVERSATION_ID = "conversationId"; + private static final String KEY_INCOMING_DRAFT = "draftMessage"; + + private ReadDraftDataAction(final String conversationId, final MessageData incomingDraft, + final String actionKey) { + super(actionKey); + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + actionParameters.putParcelable(KEY_INCOMING_DRAFT, incomingDraft); + } + + @VisibleForTesting + class DraftData { + public final MessageData message; + public final ConversationListItemData conversation; + + DraftData(final MessageData message, final ConversationListItemData conversation) { + this.message = message; + this.conversation = conversation; + } + } + + @Override + protected Object executeAction() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + final MessageData incomingDraft = actionParameters.getParcelable(KEY_INCOMING_DRAFT); + final ConversationListItemData conversation = + ConversationListItemData.getExistingConversation(db, conversationId); + MessageData message = null; + if (conversation != null) { + if (incomingDraft == null) { + message = BugleDatabaseOperations.readDraftMessageData(db, conversationId, + conversation.getSelfId()); + } + if (message == null) { + message = MessageData.createDraftMessage(conversationId, conversation.getSelfId(), + incomingDraft); + LogUtil.d(LogUtil.BUGLE_TAG, "ReadDraftMessage: created draft. " + + "conversationId=" + conversationId + + " selfId=" + conversation.getSelfId()); + } else { + LogUtil.d(LogUtil.BUGLE_TAG, "ReadDraftMessage: read draft. " + + "conversationId=" + conversationId + + " selfId=" + conversation.getSelfId()); + } + return new DraftData(message, conversation); + } + return null; + } + + /** + * An operation that notifies a listener upon completion + */ + public static class ReadDraftDataActionMonitor extends ActionMonitor + implements ActionCompletedListener { + + private final ReadDraftDataActionListener mListener; + + ReadDraftDataActionMonitor(final Object data, + final ReadDraftDataActionListener completed) { + super(STATE_CREATED, generateUniqueActionKey("ReadDraftDataAction"), data); + setCompletedListener(this); + mListener = completed; + } + + @Override + public void onActionSucceeded(final ActionMonitor monitor, + final Action action, final Object data, final Object result) { + final DraftData draft = (DraftData) result; + if (draft == null) { + mListener.onReadDraftDataFailed((ReadDraftDataAction) action, data); + } else { + mListener.onReadDraftDataSucceeded((ReadDraftDataAction) action, data, + draft.message, draft.conversation); + } + } + + @Override + public void onActionFailed(final ActionMonitor monitor, + final Action action, final Object data, final Object result) { + Assert.fail("Reading draft should not fail"); + } + } + + private ReadDraftDataAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public ReadDraftDataAction createFromParcel(final Parcel in) { + return new ReadDraftDataAction(in); + } + + @Override + public ReadDraftDataAction[] newArray(final int size) { + return new ReadDraftDataAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/ReceiveMmsMessageAction.java b/src/com/android/messaging/datamodel/action/ReceiveMmsMessageAction.java new file mode 100644 index 0000000..6794b17 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ReceiveMmsMessageAction.java @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.Context; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DataModelException; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.SyncManager; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.mmslib.pdu.PduHeaders; +import com.android.messaging.sms.DatabaseMessages; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.LogUtil; + +import java.util.List; + +/** + * Action used to "receive" an incoming message + */ +public class ReceiveMmsMessageAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + private static final String KEY_SUB_ID = "sub_id"; + private static final String KEY_PUSH_DATA = "push_data"; + private static final String KEY_TRANSACTION_ID = "transaction_id"; + private static final String KEY_CONTENT_LOCATION = "content_location"; + + /** + * Create a message received from a particular number in a particular conversation + */ + public ReceiveMmsMessageAction(final int subId, final byte[] pushData) { + actionParameters.putInt(KEY_SUB_ID, subId); + actionParameters.putByteArray(KEY_PUSH_DATA, pushData); + } + + @Override + protected Object executeAction() { + final Context context = Factory.get().getApplicationContext(); + final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + final byte[] pushData = actionParameters.getByteArray(KEY_PUSH_DATA); + final DatabaseWrapper db = DataModel.get().getDatabase(); + + // Write received message to telephony DB + MessageData message = null; + final ParticipantData self = BugleDatabaseOperations.getOrCreateSelf(db, subId); + + final long received = System.currentTimeMillis(); + // Inform sync that message has been added at local received timestamp + final SyncManager syncManager = DataModel.get().getSyncManager(); + syncManager.onNewMessageInserted(received); + + // TODO: Should use local time to set received time in MMS message + final DatabaseMessages.MmsMessage mms = MmsUtils.processReceivedPdu( + context, pushData, self.getSubId(), self.getNormalizedDestination()); + + if (mms != null) { + final List recipients = MmsUtils.getRecipientsByThread(mms.mThreadId); + String from = MmsUtils.getMmsSender(recipients, mms.getUri()); + if (from == null) { + LogUtil.w(TAG, "Received an MMS without sender address; using unknown sender."); + from = ParticipantData.getUnknownSenderDestination(); + } + final ParticipantData rawSender = ParticipantData.getFromRawPhoneBySimLocale( + from, subId); + final boolean blocked = BugleDatabaseOperations.isBlockedDestination( + db, rawSender.getNormalizedDestination()); + final boolean autoDownload = (!blocked && MmsUtils.allowMmsAutoRetrieve(subId)); + final String conversationId = + BugleDatabaseOperations.getOrCreateConversationFromThreadId(db, mms.mThreadId, + blocked, subId); + + final boolean messageInFocusedConversation = + DataModel.get().isFocusedConversation(conversationId); + final boolean messageInObservableConversation = + DataModel.get().isNewMessageObservable(conversationId); + + // TODO: Also write these values to the telephony provider + mms.mRead = messageInFocusedConversation; + mms.mSeen = messageInObservableConversation || blocked; + + // Write received placeholder message to our DB + db.beginTransaction(); + try { + final String participantId = + BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, rawSender); + final String selfId = + BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self); + + message = MmsUtils.createMmsMessage(mms, conversationId, participantId, selfId, + (autoDownload ? MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD : + MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD)); + // Write the message + BugleDatabaseOperations.insertNewMessageInTransaction(db, message); + + if (!autoDownload) { + BugleDatabaseOperations.updateConversationMetadataInTransaction(db, + conversationId, message.getMessageId(), message.getReceivedTimeStamp(), + blocked, true /* shouldAutoSwitchSelfId */); + final ParticipantData sender = ParticipantData .getFromId( + db, participantId); + BugleActionToasts.onMessageReceived(conversationId, sender, message); + } + // else update the conversation once we have downloaded final message (or failed) + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + // Update conversation if not immediately initiating a download + if (!autoDownload) { + MessagingContentProvider.notifyMessagesChanged(message.getConversationId()); + MessagingContentProvider.notifyPartsChanged(); + + // Show a notification to let the user know a new message has arrived + BugleNotifications.update(false/*silent*/, conversationId, + BugleNotifications.UPDATE_ALL); + + // Send the NotifyRespInd with DEFERRED status since no auto download + actionParameters.putString(KEY_TRANSACTION_ID, mms.mTransactionId); + actionParameters.putString(KEY_CONTENT_LOCATION, mms.mContentLocation); + requestBackgroundWork(); + } + + LogUtil.i(TAG, "ReceiveMmsMessageAction: Received MMS message " + message.getMessageId() + + " in conversation " + message.getConversationId() + + ", uri = " + message.getSmsMessageUri()); + } else { + LogUtil.e(TAG, "ReceiveMmsMessageAction: Skipping processing of incoming PDU"); + } + + ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this); + + return message; + } + + @Override + protected Bundle doBackgroundWork() throws DataModelException { + final Context context = Factory.get().getApplicationContext(); + final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + final String transactionId = actionParameters.getString(KEY_TRANSACTION_ID); + final String contentLocation = actionParameters.getString(KEY_CONTENT_LOCATION); + MmsUtils.sendNotifyResponseForMmsDownload( + context, + subId, + MmsUtils.stringToBytes(transactionId, "UTF-8"), + contentLocation, + PduHeaders.STATUS_DEFERRED); + // We don't need to return anything. + return null; + } + + private ReceiveMmsMessageAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public ReceiveMmsMessageAction createFromParcel(final Parcel in) { + return new ReceiveMmsMessageAction(in); + } + + @Override + public ReceiveMmsMessageAction[] newArray(final int size) { + return new ReceiveMmsMessageAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/ReceiveSmsMessageAction.java b/src/com/android/messaging/datamodel/action/ReceiveSmsMessageAction.java new file mode 100644 index 0000000..5ffb35d --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ReceiveSmsMessageAction.java @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.ContentValues; +import android.content.Context; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.Telephony.Sms; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.SyncManager; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsSmsUtils; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; + +/** + * Action used to "receive" an incoming message + */ +public class ReceiveSmsMessageAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + private static final String KEY_MESSAGE_VALUES = "message_values"; + + /** + * Create a message received from a particular number in a particular conversation + */ + public ReceiveSmsMessageAction(final ContentValues messageValues) { + actionParameters.putParcelable(KEY_MESSAGE_VALUES, messageValues); + } + + @Override + protected Object executeAction() { + final Context context = Factory.get().getApplicationContext(); + final ContentValues messageValues = actionParameters.getParcelable(KEY_MESSAGE_VALUES); + final DatabaseWrapper db = DataModel.get().getDatabase(); + + // Get the SIM subscription ID + Integer subId = messageValues.getAsInteger(Sms.SUBSCRIPTION_ID); + if (subId == null) { + subId = ParticipantData.DEFAULT_SELF_SUB_ID; + } + // Make sure we have a sender address + String address = messageValues.getAsString(Sms.ADDRESS); + if (TextUtils.isEmpty(address)) { + LogUtil.w(TAG, "Received an SMS without an address; using unknown sender."); + address = ParticipantData.getUnknownSenderDestination(); + messageValues.put(Sms.ADDRESS, address); + } + final ParticipantData rawSender = ParticipantData.getFromRawPhoneBySimLocale( + address, subId); + + // TODO: Should use local timestamp for this? + final long received = messageValues.getAsLong(Sms.DATE); + // Inform sync that message has been added at local received timestamp + final SyncManager syncManager = DataModel.get().getSyncManager(); + syncManager.onNewMessageInserted(received); + + // Make sure we've got a thread id + final long threadId = MmsSmsUtils.Threads.getOrCreateThreadId(context, address); + messageValues.put(Sms.THREAD_ID, threadId); + final boolean blocked = BugleDatabaseOperations.isBlockedDestination( + db, rawSender.getNormalizedDestination()); + final String conversationId = BugleDatabaseOperations. + getOrCreateConversationFromRecipient(db, threadId, blocked, rawSender); + + final boolean messageInFocusedConversation = + DataModel.get().isFocusedConversation(conversationId); + final boolean messageInObservableConversation = + DataModel.get().isNewMessageObservable(conversationId); + + MessageData message = null; + // Only the primary user gets to insert the message into the telephony db and into bugle's + // db. The secondary user goes through this path, but skips doing the actual insert. It + // goes through this path because it needs to compute messageInFocusedConversation in order + // to calculate whether to skip the notification and play a soft sound if the user is + // already in the conversation. + if (!OsUtil.isSecondaryUser()) { + final boolean read = messageValues.getAsBoolean(Sms.Inbox.READ) + || messageInFocusedConversation; + // If you have read it you have seen it + final boolean seen = read || messageInObservableConversation || blocked; + messageValues.put(Sms.Inbox.READ, read ? Integer.valueOf(1) : Integer.valueOf(0)); + + // incoming messages are marked as seen in the telephony db + messageValues.put(Sms.Inbox.SEEN, 1); + + // Insert into telephony + final Uri messageUri = context.getContentResolver().insert(Sms.Inbox.CONTENT_URI, + messageValues); + + if (messageUri != null) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "ReceiveSmsMessageAction: Inserted SMS message into telephony, " + + "uri = " + messageUri); + } + } else { + LogUtil.e(TAG, "ReceiveSmsMessageAction: Failed to insert SMS into telephony!"); + } + + final String text = messageValues.getAsString(Sms.BODY); + final String subject = messageValues.getAsString(Sms.SUBJECT); + final long sent = messageValues.getAsLong(Sms.DATE_SENT); + final ParticipantData self = ParticipantData.getSelfParticipant(subId); + final Integer pathPresent = messageValues.getAsInteger(Sms.REPLY_PATH_PRESENT); + final String smsServiceCenter = messageValues.getAsString(Sms.SERVICE_CENTER); + String conversationServiceCenter = null; + // Only set service center if message REPLY_PATH_PRESENT = 1 + if (pathPresent != null && pathPresent == 1 && !TextUtils.isEmpty(smsServiceCenter)) { + conversationServiceCenter = smsServiceCenter; + } + db.beginTransaction(); + try { + final String participantId = + BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, rawSender); + final String selfId = + BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self); + + message = MessageData.createReceivedSmsMessage(messageUri, conversationId, + participantId, selfId, text, subject, sent, received, seen, read); + + BugleDatabaseOperations.insertNewMessageInTransaction(db, message); + + BugleDatabaseOperations.updateConversationMetadataInTransaction(db, conversationId, + message.getMessageId(), message.getReceivedTimeStamp(), blocked, + conversationServiceCenter, true /* shouldAutoSwitchSelfId */); + + final ParticipantData sender = ParticipantData.getFromId(db, participantId); + BugleActionToasts.onMessageReceived(conversationId, sender, message); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + LogUtil.i(TAG, "ReceiveSmsMessageAction: Received SMS message " + message.getMessageId() + + " in conversation " + message.getConversationId() + + ", uri = " + messageUri); + + ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this); + } else { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "ReceiveSmsMessageAction: Not inserting received SMS message for " + + "secondary user."); + } + } + // Show a notification to let the user know a new message has arrived + BugleNotifications.update(false/*silent*/, conversationId, BugleNotifications.UPDATE_ALL); + + MessagingContentProvider.notifyMessagesChanged(conversationId); + MessagingContentProvider.notifyPartsChanged(); + + return message; + } + + private ReceiveSmsMessageAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public ReceiveSmsMessageAction createFromParcel(final Parcel in) { + return new ReceiveSmsMessageAction(in); + } + + @Override + public ReceiveSmsMessageAction[] newArray(final int size) { + return new ReceiveSmsMessageAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/RedownloadMmsAction.java b/src/com/android/messaging/datamodel/action/RedownloadMmsAction.java new file mode 100644 index 0000000..e899b0c --- /dev/null +++ b/src/com/android/messaging/datamodel/action/RedownloadMmsAction.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.app.PendingIntent; +import android.content.ContentValues; +import android.content.Context; +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.util.LogUtil; + +/** + * Action to manually start an MMS download (after failed or manual mms download) + */ +public class RedownloadMmsAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + private static final int REQUEST_CODE_PENDING_INTENT = 102; + + /** + * Download an MMS message + */ + public static void redownloadMessage(final String messageId) { + final RedownloadMmsAction action = new RedownloadMmsAction(messageId); + action.start(); + } + + /** + * Get a pending intent of for downloading an MMS + */ + public static PendingIntent getPendingIntentForRedownloadMms( + final Context context, final String messageId) { + final Action action = new RedownloadMmsAction(messageId); + return ActionService.makeStartActionPendingIntent(context, + action, REQUEST_CODE_PENDING_INTENT, false /*launchesAnActivity*/); + } + + // Core parameters needed for all types of message + private static final String KEY_MESSAGE_ID = "message_id"; + + /** + * Constructor used for retrying sending in the background (only message id available) + */ + RedownloadMmsAction(final String messageId) { + super(); + actionParameters.putString(KEY_MESSAGE_ID, messageId); + } + + /** + * Read message from database and change status to allow downloading + */ + @Override + protected Object executeAction() { + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + + final DatabaseWrapper db = DataModel.get().getDatabase(); + + MessageData message = BugleDatabaseOperations.readMessage(db, messageId); + // Check message can be redownloaded + if (message != null && message.canRedownloadMessage()) { + final long timestamp = System.currentTimeMillis(); + + final ContentValues values = new ContentValues(2); + values.put(DatabaseHelper.MessageColumns.STATUS, + MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD); + values.put(DatabaseHelper.MessageColumns.RETRY_START_TIMESTAMP, timestamp); + + // Row must exist as was just loaded above (on ActionService thread) + BugleDatabaseOperations.updateMessageRow(db, message.getMessageId(), values); + + MessagingContentProvider.notifyMessagesChanged(message.getConversationId()); + + // Whether we succeeded or failed we will check and maybe schedule some more work + ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this); + } else { + message = null; + LogUtil.e(LogUtil.BUGLE_TAG, + "Attempt to download a missing or un-redownloadable message"); + } + // Immediately update the notifications in case we came from the download action from a + // heads-up notification. This will dismiss the heads-up notification. + BugleNotifications.update(false/*silent*/, BugleNotifications.UPDATE_ALL); + return message; + } + + private RedownloadMmsAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public RedownloadMmsAction createFromParcel(final Parcel in) { + return new RedownloadMmsAction(in); + } + + @Override + public RedownloadMmsAction[] newArray(final int size) { + return new RedownloadMmsAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/ResendMessageAction.java b/src/com/android/messaging/datamodel/action/ResendMessageAction.java new file mode 100644 index 0000000..2201965 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/ResendMessageAction.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.ContentValues; +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.util.LogUtil; + +/** + * Action used to manually resend an outgoing message + */ +public class ResendMessageAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + /** + * Manual send of existing message (no listener) + */ + public static void resendMessage(final String messageId) { + final ResendMessageAction action = new ResendMessageAction(messageId); + action.start(); + } + + // Core parameters needed for all types of message + private static final String KEY_MESSAGE_ID = "message_id"; + + /** + * Constructor used for retrying sending in the background (only message id available) + */ + ResendMessageAction(final String messageId) { + super(); + actionParameters.putString(KEY_MESSAGE_ID, messageId); + } + + /** + * Read message from database and change status to allow sending + */ + @Override + protected Object executeAction() { + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final MessageData message = BugleDatabaseOperations.readMessage(db, messageId); + // Check message can be resent + if (message != null && message.canResendMessage()) { + final boolean isMms = message.getIsMms(); + long timestamp = System.currentTimeMillis(); + if (isMms) { + // MMS expects timestamp rounded to nearest second + timestamp = 1000 * ((timestamp + 500) / 1000); + } + + LogUtil.i(TAG, "ResendMessageAction: Resending message " + messageId + + "; changed timestamp from " + message.getReceivedTimeStamp() + " to " + + timestamp); + + final ContentValues values = new ContentValues(); + values.put(MessageColumns.STATUS, MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND); + values.put(MessageColumns.RECEIVED_TIMESTAMP, timestamp); + values.put(MessageColumns.SENT_TIMESTAMP, timestamp); + values.put(MessageColumns.RETRY_START_TIMESTAMP, timestamp); + + // Row must exist as was just loaded above (on ActionService thread) + BugleDatabaseOperations.updateMessageRow(db, message.getMessageId(), values); + + MessagingContentProvider.notifyMessagesChanged(message.getConversationId()); + + // Whether we succeeded or failed we will check and maybe schedule some more work + ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this); + + return message; + } else { + String error = "ResendMessageAction: Cannot resend message " + messageId + "; "; + if (message != null) { + error += ("status = " + MessageData.getStatusDescription(message.getStatus())); + } else { + error += "not found in database"; + } + LogUtil.e(TAG, error); + } + + return null; + } + + private ResendMessageAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public ResendMessageAction createFromParcel(final Parcel in) { + return new ResendMessageAction(in); + } + + @Override + public ResendMessageAction[] newArray(final int size) { + return new ResendMessageAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/SendMessageAction.java b/src/com/android/messaging/datamodel/action/SendMessageAction.java new file mode 100644 index 0000000..d7ebe8f --- /dev/null +++ b/src/com/android/messaging/datamodel/action/SendMessageAction.java @@ -0,0 +1,447 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.ContentValues; +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.Telephony.Mms; +import android.provider.Telephony.Sms; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.SyncManager; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +import java.util.ArrayList; + +/** + * Action used to send an outgoing message. It writes MMS messages to the telephony db + * ({@link InsertNewMessageAction}) writes SMS messages to the telephony db). It also + * initiates the actual sending. It will all be used for re-sending a failed message. + * NOTE: This action must queue a ProcessPendingMessagesAction when it is done (success or failure). + *

+ * This class is public (not package-private) because the SMS/MMS (e.g. MmsUtils) classes need to + * access the EXTRA_* fields for setting up the 'sent' pending intent. + */ +public class SendMessageAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + /** + * Queue sending of existing message (can only be called during execute of action) + */ + static boolean queueForSendInBackground(final String messageId, + final Action processingAction) { + final SendMessageAction action = new SendMessageAction(); + return action.queueAction(messageId, processingAction); + } + + public static final boolean DEFAULT_DELIVERY_REPORT_MODE = false; + public static final int MAX_SMS_RETRY = 3; + + // Core parameters needed for all types of message + private static final String KEY_MESSAGE_ID = "message_id"; + private static final String KEY_MESSAGE = "message"; + private static final String KEY_MESSAGE_URI = "message_uri"; + private static final String KEY_SUB_PHONE_NUMBER = "sub_phone_number"; + + // For sms messages a few extra values are included in the bundle + private static final String KEY_RECIPIENT = "recipient"; + private static final String KEY_RECIPIENTS = "recipients"; + private static final String KEY_SMS_SERVICE_CENTER = "sms_service_center"; + + // Values we attach to the pending intent that's fired when the message is sent. + // Only applicable when sending via the platform APIs on L+. + public static final String KEY_SUB_ID = "sub_id"; + public static final String EXTRA_MESSAGE_ID = "message_id"; + public static final String EXTRA_UPDATED_MESSAGE_URI = "updated_message_uri"; + public static final String EXTRA_CONTENT_URI = "content_uri"; + public static final String EXTRA_RESPONSE_IMPORTANT = "response_important"; + + /** + * Constructor used for retrying sending in the background (only message id available) + */ + private SendMessageAction() { + super(); + } + + /** + * Read message from database and queue actual sending + */ + private boolean queueAction(final String messageId, final Action processingAction) { + actionParameters.putString(KEY_MESSAGE_ID, messageId); + + final long timestamp = System.currentTimeMillis(); + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final MessageData message = BugleDatabaseOperations.readMessage(db, messageId); + // Check message can be resent + if (message != null && message.canSendMessage()) { + final boolean isSms = (message.getProtocol() == MessageData.PROTOCOL_SMS); + + final ParticipantData self = BugleDatabaseOperations.getExistingParticipant( + db, message.getSelfId()); + final Uri messageUri = message.getSmsMessageUri(); + final String conversationId = message.getConversationId(); + + // Update message status + if (message.getYetToSend()) { + // Initial sending of message + message.markMessageSending(timestamp); + } else { + // Automatic resend of message + message.markMessageResending(timestamp); + } + if (!updateMessageAndStatus(isSms, message, null /* messageUri */, false /*notify*/)) { + // If message is missing in the telephony database we don't need to send it + return false; + } + + final ArrayList recipients = + BugleDatabaseOperations.getRecipientsForConversation(db, conversationId); + + // Update action state with parameters needed for background sending + actionParameters.putParcelable(KEY_MESSAGE_URI, messageUri); + actionParameters.putParcelable(KEY_MESSAGE, message); + actionParameters.putStringArrayList(KEY_RECIPIENTS, recipients); + actionParameters.putInt(KEY_SUB_ID, self.getSubId()); + actionParameters.putString(KEY_SUB_PHONE_NUMBER, self.getNormalizedDestination()); + + if (isSms) { + final String smsc = BugleDatabaseOperations.getSmsServiceCenterForConversation( + db, conversationId); + actionParameters.putString(KEY_SMS_SERVICE_CENTER, smsc); + + if (recipients.size() == 1) { + final String recipient = recipients.get(0); + + actionParameters.putString(KEY_RECIPIENT, recipient); + // Queue actual sending for SMS + processingAction.requestBackgroundWork(this); + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SendMessageAction: Queued SMS message " + messageId + + " for sending"); + } + return true; + } else { + LogUtil.wtf(TAG, "Trying to resend a broadcast SMS - not allowed"); + } + } else { + // Queue actual sending for MMS + processingAction.requestBackgroundWork(this); + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SendMessageAction: Queued MMS message " + messageId + + " for sending"); + } + return true; + } + } + + return false; + } + + + /** + * Never called + */ + @Override + protected Object executeAction() { + Assert.fail("SendMessageAction must be queued rather than started"); + return null; + } + + /** + * Send message on background worker thread + */ + @Override + protected Bundle doBackgroundWork() { + final MessageData message = actionParameters.getParcelable(KEY_MESSAGE); + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + Uri messageUri = actionParameters.getParcelable(KEY_MESSAGE_URI); + Uri updatedMessageUri = null; + final boolean isSms = message.getProtocol() == MessageData.PROTOCOL_SMS; + final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + final String subPhoneNumber = actionParameters.getString(KEY_SUB_PHONE_NUMBER); + + LogUtil.i(TAG, "SendMessageAction: Sending " + (isSms ? "SMS" : "MMS") + " message " + + messageId + " in conversation " + message.getConversationId()); + + int status; + int rawStatus = MessageData.RAW_TELEPHONY_STATUS_UNDEFINED; + int resultCode = MessageData.UNKNOWN_RESULT_CODE; + if (isSms) { + Assert.notNull(messageUri); + final String recipient = actionParameters.getString(KEY_RECIPIENT); + final String messageText = message.getMessageText(); + final String smsServiceCenter = actionParameters.getString(KEY_SMS_SERVICE_CENTER); + final boolean deliveryReportRequired = MmsUtils.isDeliveryReportRequired(subId); + + status = MmsUtils.sendSmsMessage(recipient, messageText, messageUri, subId, + smsServiceCenter, deliveryReportRequired); + } else { + final Context context = Factory.get().getApplicationContext(); + final ArrayList recipients = + actionParameters.getStringArrayList(KEY_RECIPIENTS); + if (messageUri == null) { + final long timestamp = message.getReceivedTimeStamp(); + + // Inform sync that message has been added at local received timestamp + final SyncManager syncManager = DataModel.get().getSyncManager(); + syncManager.onNewMessageInserted(timestamp); + + // For MMS messages first need to write to telephony (resizing images if needed) + updatedMessageUri = MmsUtils.insertSendingMmsMessage(context, recipients, + message, subId, subPhoneNumber, timestamp); + if (updatedMessageUri != null) { + messageUri = updatedMessageUri; + // To prevent Sync seeing inconsistent state must write to DB on this thread + updateMessageUri(messageId, updatedMessageUri); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SendMessageAction: Updated message " + messageId + + " with new uri " + messageUri); + } + } + } + if (messageUri != null) { + // Actually send the MMS + final Bundle extras = new Bundle(); + extras.putString(EXTRA_MESSAGE_ID, messageId); + extras.putParcelable(EXTRA_UPDATED_MESSAGE_URI, updatedMessageUri); + final MmsUtils.StatusPlusUri result = MmsUtils.sendMmsMessage(context, subId, + messageUri, extras); + if (result == MmsUtils.STATUS_PENDING) { + // Async send, so no status yet + LogUtil.d(TAG, "SendMessageAction: Sending MMS message " + messageId + + " asynchronously; waiting for callback to finish processing"); + return null; + } + status = result.status; + rawStatus = result.rawStatus; + resultCode = result.resultCode; + } else { + status = MmsUtils.MMS_REQUEST_MANUAL_RETRY; + } + } + + // When we fast-fail before calling the MMS lib APIs (e.g. airplane mode, + // sending message is deleted). + ProcessSentMessageAction.processMessageSentFastFailed(messageId, messageUri, + updatedMessageUri, subId, isSms, status, rawStatus, resultCode); + return null; + } + + private void updateMessageUri(final String messageId, final Uri updatedMessageUri) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + try { + final ContentValues values = new ContentValues(); + values.put(MessageColumns.SMS_MESSAGE_URI, updatedMessageUri.toString()); + BugleDatabaseOperations.updateMessageRow(db, messageId, values); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + @Override + protected Object processBackgroundResponse(final Bundle response) { + // Nothing to do here, post-send tasks handled by ProcessSentMessageAction + return null; + } + + /** + * Update message status to reflect success or failure + */ + @Override + protected Object processBackgroundFailure() { + final String messageId = actionParameters.getString(KEY_MESSAGE_ID); + final MessageData message = actionParameters.getParcelable(KEY_MESSAGE); + final boolean isSms = message.getProtocol() == MessageData.PROTOCOL_SMS; + final int subId = actionParameters.getInt(KEY_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID); + final int resultCode = actionParameters.getInt(ProcessSentMessageAction.KEY_RESULT_CODE); + final int httpStatusCode = + actionParameters.getInt(ProcessSentMessageAction.KEY_HTTP_STATUS_CODE); + + ProcessSentMessageAction.processResult(messageId, null /* updatedMessageUri */, + MmsUtils.MMS_REQUEST_MANUAL_RETRY, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED, + isSms, this, subId, resultCode, httpStatusCode); + + // Whether we succeeded or failed we will check and maybe schedule some more work + ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(true, this); + + return null; + } + + /** + * Update the message status (and message itself if necessary) + * @param isSms whether this is an SMS or MMS + * @param message message to update + * @param updatedMessageUri message uri for newly-inserted messages; null otherwise + * @param clearSeen whether the message 'seen' status should be reset if error occurs + */ + public static boolean updateMessageAndStatus(final boolean isSms, final MessageData message, + final Uri updatedMessageUri, final boolean clearSeen) { + final Context context = Factory.get().getApplicationContext(); + final DatabaseWrapper db = DataModel.get().getDatabase(); + + // TODO: We're optimistically setting the type/box of outgoing messages to + // 'SENT' even before they actually are. We should technically be using QUEUED or OUTBOX + // instead, but if we do that, it's possible that the Messaging app will try to send them + // as part of its clean-up logic that runs when it starts (http://b/18155366). + // + // We also use the wrong status when inserting queued SMS messages in + // InsertNewMessageAction.insertBroadcastSmsMessage and insertSendingSmsMessage (should be + // QUEUED or OUTBOX), and in MmsUtils.insertSendReq (should be OUTBOX). + + boolean updatedTelephony = true; + int messageBox; + int type; + switch(message.getStatus()) { + case MessageData.BUGLE_STATUS_OUTGOING_COMPLETE: + case MessageData.BUGLE_STATUS_OUTGOING_DELIVERED: + type = Sms.MESSAGE_TYPE_SENT; + messageBox = Mms.MESSAGE_BOX_SENT; + break; + case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND: + case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY: + type = Sms.MESSAGE_TYPE_SENT; + messageBox = Mms.MESSAGE_BOX_SENT; + break; + case MessageData.BUGLE_STATUS_OUTGOING_SENDING: + case MessageData.BUGLE_STATUS_OUTGOING_RESENDING: + type = Sms.MESSAGE_TYPE_SENT; + messageBox = Mms.MESSAGE_BOX_SENT; + break; + case MessageData.BUGLE_STATUS_OUTGOING_FAILED: + case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER: + type = Sms.MESSAGE_TYPE_FAILED; + messageBox = Mms.MESSAGE_BOX_FAILED; + break; + default: + type = Sms.MESSAGE_TYPE_ALL; + messageBox = Mms.MESSAGE_BOX_ALL; + break; + } + // First in the telephony DB + if (isSms) { + // Ignore update message Uri + if (type != Sms.MESSAGE_TYPE_ALL) { + if (!MmsUtils.updateSmsMessageSendingStatus(context, message.getSmsMessageUri(), + type, message.getReceivedTimeStamp())) { + message.markMessageFailed(message.getSentTimeStamp()); + updatedTelephony = false; + } + } + } else if (message.getSmsMessageUri() != null) { + if (messageBox != Mms.MESSAGE_BOX_ALL) { + if (!MmsUtils.updateMmsMessageSendingStatus(context, message.getSmsMessageUri(), + messageBox, message.getReceivedTimeStamp())) { + message.markMessageFailed(message.getSentTimeStamp()); + updatedTelephony = false; + } + } + } + if (updatedTelephony) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SendMessageAction: Updated " + (isSms ? "SMS" : "MMS") + + " message " + message.getMessageId() + + " in telephony (" + message.getSmsMessageUri() + ")"); + } + } else { + LogUtil.w(TAG, "SendMessageAction: Failed to update " + (isSms ? "SMS" : "MMS") + + " message " + message.getMessageId() + + " in telephony (" + message.getSmsMessageUri() + "); marking message failed"); + } + + // Update the local DB + db.beginTransaction(); + try { + if (updatedMessageUri != null) { + // Update all message and part fields + BugleDatabaseOperations.updateMessageInTransaction(db, message); + BugleDatabaseOperations.refreshConversationMetadataInTransaction( + db, message.getConversationId(), false/* shouldAutoSwitchSelfId */, + false/*archived*/); + } else { + final ContentValues values = new ContentValues(); + values.put(MessageColumns.STATUS, message.getStatus()); + + if (clearSeen) { + // When a message fails to send, the message needs to + // be unseen to be selected as an error notification. + values.put(MessageColumns.SEEN, 0); + } + values.put(MessageColumns.RECEIVED_TIMESTAMP, message.getReceivedTimeStamp()); + values.put(MessageColumns.RAW_TELEPHONY_STATUS, message.getRawTelephonyStatus()); + + BugleDatabaseOperations.updateMessageRowIfExists(db, message.getMessageId(), + values); + } + db.setTransactionSuccessful(); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SendMessageAction: Updated " + (isSms ? "SMS" : "MMS") + + " message " + message.getMessageId() + " in local db. Timestamp = " + + message.getReceivedTimeStamp()); + } + } finally { + db.endTransaction(); + } + + MessagingContentProvider.notifyMessagesChanged(message.getConversationId()); + if (updatedMessageUri != null) { + MessagingContentProvider.notifyPartsChanged(); + } + + return updatedTelephony; + } + + private SendMessageAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public SendMessageAction createFromParcel(final Parcel in) { + return new SendMessageAction(in); + } + + @Override + public SendMessageAction[] newArray(final int size) { + return new SendMessageAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/SyncCursorPair.java b/src/com/android/messaging/datamodel/action/SyncCursorPair.java new file mode 100644 index 0000000..b3a2676 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/SyncCursorPair.java @@ -0,0 +1,712 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import android.provider.Telephony.Mms; +import android.provider.Telephony.Sms; +import android.support.v4.util.LongSparseArray; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.SyncManager; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.SyncManager.ThreadInfoCache; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.mmslib.SqliteWrapper; +import com.android.messaging.sms.DatabaseMessages; +import com.android.messaging.sms.DatabaseMessages.DatabaseMessage; +import com.android.messaging.sms.DatabaseMessages.LocalDatabaseMessage; +import com.android.messaging.sms.DatabaseMessages.MmsMessage; +import com.android.messaging.sms.DatabaseMessages.SmsMessage; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.google.common.collect.Sets; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Class holding a pair of cursors - one for local db and one for telephony provider - allowing + * synchronous stepping through messages as part of sync. + */ +class SyncCursorPair { + private static final String TAG = LogUtil.BUGLE_TAG; + + static final long SYNC_COMPLETE = -1L; + static final long SYNC_STARTING = Long.MAX_VALUE; + + private CursorIterator mLocalCursorIterator; + private CursorIterator mRemoteCursorsIterator; + + private final String mLocalSelection; + private final String mRemoteSmsSelection; + private final String mRemoteMmsSelection; + + /** + * Check if SMS has been synchronized. We compare the counts of messages on both + * sides and return true if they are equal. + * + * Note that this may not be the most reliable way to tell if messages are in sync. + * For example, the local misses one message and has one obsolete message. + * However, we have background sms sync once a while, also some other events might + * trigger a full sync. So we will eventually catch up. And this should be rare to + * happen. + * + * @return If sms is in sync with telephony sms/mms providers + */ + static boolean allSynchronized(final DatabaseWrapper db) { + return isSynchronized(db, LOCAL_MESSAGES_SELECTION, null, + getSmsTypeSelectionSql(), null, getMmsTypeSelectionSql(), null); + } + + SyncCursorPair(final long lowerBound, final long upperBound) { + mLocalSelection = getTimeConstrainedQuery( + LOCAL_MESSAGES_SELECTION, + MessageColumns.RECEIVED_TIMESTAMP, + lowerBound, + upperBound, + null /* threadColumn */, null /* threadId */); + mRemoteSmsSelection = getTimeConstrainedQuery( + getSmsTypeSelectionSql(), + "date", + lowerBound, + upperBound, + null /* threadColumn */, null /* threadId */); + mRemoteMmsSelection = getTimeConstrainedQuery( + getMmsTypeSelectionSql(), + "date", + ((lowerBound < 0) ? lowerBound : (lowerBound + 999) / 1000), /*seconds*/ + ((upperBound < 0) ? upperBound : (upperBound + 999) / 1000), /*seconds*/ + null /* threadColumn */, null /* threadId */); + } + + SyncCursorPair(final long threadId, final String conversationId) { + mLocalSelection = getTimeConstrainedQuery( + LOCAL_MESSAGES_SELECTION, + MessageColumns.RECEIVED_TIMESTAMP, + -1L, + -1L, + MessageColumns.CONVERSATION_ID, conversationId); + // Find all SMS messages (excluding drafts) within the sync window + mRemoteSmsSelection = getTimeConstrainedQuery( + getSmsTypeSelectionSql(), + "date", + -1L, + -1L, + Sms.THREAD_ID, Long.toString(threadId)); + mRemoteMmsSelection = getTimeConstrainedQuery( + getMmsTypeSelectionSql(), + "date", + -1L, /*seconds*/ + -1L, /*seconds*/ + Mms.THREAD_ID, Long.toString(threadId)); + } + + void query(final DatabaseWrapper db) { + // Load local messages in the sync window + mLocalCursorIterator = new LocalCursorIterator(db, mLocalSelection); + // Load remote messages in the sync window + mRemoteCursorsIterator = new RemoteCursorsIterator(mRemoteSmsSelection, + mRemoteMmsSelection); + } + + boolean isSynchronized(final DatabaseWrapper db) { + return isSynchronized(db, mLocalSelection, null, mRemoteSmsSelection, + null, mRemoteMmsSelection, null); + } + + void close() { + if (mLocalCursorIterator != null) { + mLocalCursorIterator.close(); + } + if (mRemoteCursorsIterator != null) { + mRemoteCursorsIterator.close(); + } + } + + long scan(final int maxMessagesToScan, + final int maxMessagesToUpdate, final ArrayList smsToAdd, + final LongSparseArray mmsToAdd, + final ArrayList messagesToDelete, + final SyncManager.ThreadInfoCache threadInfoCache) { + // Set of local messages matched with the timestamp of a remote message + final Set matchedLocalMessages = Sets.newHashSet(); + // Set of remote messages matched with the timestamp of a local message + final Set matchedRemoteMessages = Sets.newHashSet(); + long lastTimestampMillis = SYNC_STARTING; + // Number of messages scanned local and remote + int localCount = 0; + int remoteCount = 0; + // Seed the initial values of remote and local messages for comparison + DatabaseMessage remoteMessage = mRemoteCursorsIterator.next(); + DatabaseMessage localMessage = mLocalCursorIterator.next(); + // Iterate through messages on both sides in reverse time order + // Import messages in remote not in local, delete messages in local not in remote + while (localCount + remoteCount < maxMessagesToScan && smsToAdd.size() + + mmsToAdd.size() + messagesToDelete.size() < maxMessagesToUpdate) { + if (remoteMessage == null && localMessage == null) { + // No more message on both sides - scan complete + lastTimestampMillis = SYNC_COMPLETE; + break; + } else if ((remoteMessage == null && localMessage != null) || + (localMessage != null && remoteMessage != null && + localMessage.getTimestampInMillis() + > remoteMessage.getTimestampInMillis())) { + // Found a local message that is not in remote db + // Delete the local message + messagesToDelete.add((LocalDatabaseMessage) localMessage); + lastTimestampMillis = Math.min(lastTimestampMillis, + localMessage.getTimestampInMillis()); + // Advance to next local message + localMessage = mLocalCursorIterator.next(); + localCount += 1; + } else if ((localMessage == null && remoteMessage != null) || + (localMessage != null && remoteMessage != null && + localMessage.getTimestampInMillis() + < remoteMessage.getTimestampInMillis())) { + // Found a remote message that is not in local db + // Add the remote message + saveMessageToAdd(smsToAdd, mmsToAdd, remoteMessage, threadInfoCache); + lastTimestampMillis = Math.min(lastTimestampMillis, + remoteMessage.getTimestampInMillis()); + // Advance to next remote message + remoteMessage = mRemoteCursorsIterator.next(); + remoteCount += 1; + } else { + // Found remote and local messages at the same timestamp + final long matchedTimestamp = localMessage.getTimestampInMillis(); + lastTimestampMillis = Math.min(lastTimestampMillis, matchedTimestamp); + // Get the next local and remote messages + final DatabaseMessage remoteMessagePeek = mRemoteCursorsIterator.next(); + final DatabaseMessage localMessagePeek = mLocalCursorIterator.next(); + // Check if only one message on each side matches the current timestamp + // by looking at the next messages on both sides. If they are either null + // (meaning no more messages) or having a different timestamp. We want + // to optimize for this since this is the most common case when majority + // of the messages are in sync (so they one-to-one pair up at each timestamp), + // by not allocating the data structures required to compare a set of + // messages from both sides. + if ((remoteMessagePeek == null || + remoteMessagePeek.getTimestampInMillis() != matchedTimestamp) && + (localMessagePeek == null || + localMessagePeek.getTimestampInMillis() != matchedTimestamp)) { + // Optimize the common case where only one message on each side + // that matches the same timestamp + if (!remoteMessage.equals(localMessage)) { + // local != remote + // Delete local message + messagesToDelete.add((LocalDatabaseMessage) localMessage); + // Add remote message + saveMessageToAdd(smsToAdd, mmsToAdd, remoteMessage, threadInfoCache); + } + // Get next local and remote messages + localMessage = localMessagePeek; + remoteMessage = remoteMessagePeek; + localCount += 1; + remoteCount += 1; + } else { + // Rare case in which multiple messages are in the same timestamp + // on either or both sides + // Gather all the matched remote messages + matchedRemoteMessages.clear(); + matchedRemoteMessages.add(remoteMessage); + remoteCount += 1; + remoteMessage = remoteMessagePeek; + while (remoteMessage != null && + remoteMessage.getTimestampInMillis() == matchedTimestamp) { + Assert.isTrue(!matchedRemoteMessages.contains(remoteMessage)); + matchedRemoteMessages.add(remoteMessage); + remoteCount += 1; + remoteMessage = mRemoteCursorsIterator.next(); + } + // Gather all the matched local messages + matchedLocalMessages.clear(); + matchedLocalMessages.add(localMessage); + localCount += 1; + localMessage = localMessagePeek; + while (localMessage != null && + localMessage.getTimestampInMillis() == matchedTimestamp) { + if (matchedLocalMessages.contains(localMessage)) { + // Duplicate message is local database is deleted + messagesToDelete.add((LocalDatabaseMessage) localMessage); + } else { + matchedLocalMessages.add(localMessage); + } + localCount += 1; + localMessage = mLocalCursorIterator.next(); + } + // Delete messages local only + for (final DatabaseMessage msg : Sets.difference( + matchedLocalMessages, matchedRemoteMessages)) { + messagesToDelete.add((LocalDatabaseMessage) msg); + } + // Add messages remote only + for (final DatabaseMessage msg : Sets.difference( + matchedRemoteMessages, matchedLocalMessages)) { + saveMessageToAdd(smsToAdd, mmsToAdd, msg, threadInfoCache); + } + } + } + } + return lastTimestampMillis; + } + + DatabaseMessage getLocalMessage() { + return mLocalCursorIterator.next(); + } + + DatabaseMessage getRemoteMessage() { + return mRemoteCursorsIterator.next(); + } + + int getLocalPosition() { + return mLocalCursorIterator.getPosition(); + } + + int getRemotePosition() { + return mRemoteCursorsIterator.getPosition(); + } + + int getLocalCount() { + return mLocalCursorIterator.getCount(); + } + + int getRemoteCount() { + return mRemoteCursorsIterator.getCount(); + } + + /** + * An iterator for a database cursor + */ + interface CursorIterator { + /** + * Move to next element in the cursor + * + * @return The next element (which becomes the current) + */ + public DatabaseMessage next(); + /** + * Close the cursor + */ + public void close(); + /** + * Get the position + */ + public int getPosition(); + /** + * Get the count + */ + public int getCount(); + } + + private static final String ORDER_BY_DATE_DESC = "date DESC"; + + // A subquery that selects SMS/MMS messages in Bugle which are also in telephony + private static final String LOCAL_MESSAGES_SELECTION = String.format( + Locale.US, + "(%s NOTNULL)", + MessageColumns.SMS_MESSAGE_URI); + + private static final String ORDER_BY_TIMESTAMP_DESC = + MessageColumns.RECEIVED_TIMESTAMP + " DESC"; + + // TODO : This should move into the provider + private static class LocalMessageQuery { + private static final String[] PROJECTION = new String[] { + MessageColumns._ID, + MessageColumns.RECEIVED_TIMESTAMP, + MessageColumns.SMS_MESSAGE_URI, + MessageColumns.PROTOCOL, + MessageColumns.CONVERSATION_ID, + }; + private static final int INDEX_MESSAGE_ID = 0; + private static final int INDEX_MESSAGE_TIMESTAMP = 1; + private static final int INDEX_SMS_MESSAGE_URI = 2; + private static final int INDEX_MESSAGE_SMS_TYPE = 3; + private static final int INDEX_CONVERSATION_ID = 4; + } + + /** + * This class provides the same DatabaseMessage interface over a local SMS db message + */ + private static LocalDatabaseMessage getLocalDatabaseMessage(final Cursor cursor) { + if (cursor == null) { + return null; + } + return new LocalDatabaseMessage( + cursor.getLong(LocalMessageQuery.INDEX_MESSAGE_ID), + cursor.getInt(LocalMessageQuery.INDEX_MESSAGE_SMS_TYPE), + cursor.getString(LocalMessageQuery.INDEX_SMS_MESSAGE_URI), + cursor.getLong(LocalMessageQuery.INDEX_MESSAGE_TIMESTAMP), + cursor.getString(LocalMessageQuery.INDEX_CONVERSATION_ID)); + } + + /** + * The buffered cursor iterator for local SMS + */ + private static class LocalCursorIterator implements CursorIterator { + private Cursor mCursor; + private final DatabaseWrapper mDatabase; + + LocalCursorIterator(final DatabaseWrapper database, final String selection) + throws SQLiteException { + mDatabase = database; + try { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SyncCursorPair: Querying for local messages; selection = " + + selection); + } + mCursor = mDatabase.query( + DatabaseHelper.MESSAGES_TABLE, + LocalMessageQuery.PROJECTION, + selection, + null /*selectionArgs*/, + null/*groupBy*/, + null/*having*/, + ORDER_BY_TIMESTAMP_DESC); + } catch (final SQLiteException e) { + LogUtil.e(TAG, "SyncCursorPair: failed to query local sms/mms", e); + // Can't query local database. So let's throw up the exception and abort sync + // because we may end up import duplicate messages. + throw e; + } + } + + @Override + public DatabaseMessage next() { + if (mCursor != null && mCursor.moveToNext()) { + return getLocalDatabaseMessage(mCursor); + } + return null; + } + + @Override + public int getCount() { + return (mCursor == null ? 0 : mCursor.getCount()); + } + + @Override + public int getPosition() { + return (mCursor == null ? 0 : mCursor.getPosition()); + } + + @Override + public void close() { + if (mCursor != null) { + mCursor.close(); + mCursor = null; + } + } + } + + /** + * The cursor iterator for remote sms. + * Since SMS and MMS are stored in different tables in telephony provider, + * this class merges the two cursors and provides a unified view of messages + * from both cursors. Note that the order is DESC. + */ + private static class RemoteCursorsIterator implements CursorIterator { + private Cursor mSmsCursor; + private Cursor mMmsCursor; + private DatabaseMessage mNextSms; + private DatabaseMessage mNextMms; + + RemoteCursorsIterator(final String smsSelection, final String mmsSelection) + throws SQLiteException { + mSmsCursor = null; + mMmsCursor = null; + try { + final Context context = Factory.get().getApplicationContext(); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SyncCursorPair: Querying for remote SMS; selection = " + + smsSelection); + } + mSmsCursor = SqliteWrapper.query( + context, + context.getContentResolver(), + Sms.CONTENT_URI, + SmsMessage.getProjection(), + smsSelection, + null /* selectionArgs */, + ORDER_BY_DATE_DESC); + if (mSmsCursor == null) { + LogUtil.w(TAG, "SyncCursorPair: Remote SMS query returned null cursor; " + + "need to cancel sync"); + throw new RuntimeException("Null cursor from remote SMS query"); + } + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SyncCursorPair: Querying for remote MMS; selection = " + + mmsSelection); + } + mMmsCursor = SqliteWrapper.query( + context, + context.getContentResolver(), + Mms.CONTENT_URI, + DatabaseMessages.MmsMessage.getProjection(), + mmsSelection, + null /* selectionArgs */, + ORDER_BY_DATE_DESC); + if (mMmsCursor == null) { + LogUtil.w(TAG, "SyncCursorPair: Remote MMS query returned null cursor; " + + "need to cancel sync"); + throw new RuntimeException("Null cursor from remote MMS query"); + } + // Move to the first element in the combined stream from both cursors + mNextSms = getSmsCursorNext(); + mNextMms = getMmsCursorNext(); + } catch (final SQLiteException e) { + LogUtil.e(TAG, "SyncCursorPair: failed to query remote messages", e); + // If we ignore this, the following code would think there is no remote message + // and will delete all the local sms. We should be cautious here. So instead, + // let's throw the exception to the caller and abort sms sync. We do the same + // thing if either of the remote cursors is null. + throw e; + } + } + + @Override + public DatabaseMessage next() { + DatabaseMessage result = null; + if (mNextSms != null && mNextMms != null) { + if (mNextSms.getTimestampInMillis() >= mNextMms.getTimestampInMillis()) { + result = mNextSms; + mNextSms = getSmsCursorNext(); + } else { + result = mNextMms; + mNextMms = getMmsCursorNext(); + } + } else { + if (mNextSms != null) { + result = mNextSms; + mNextSms = getSmsCursorNext(); + } else { + result = mNextMms; + mNextMms = getMmsCursorNext(); + } + } + return result; + } + + private DatabaseMessage getSmsCursorNext() { + if (mSmsCursor != null && mSmsCursor.moveToNext()) { + return SmsMessage.get(mSmsCursor); + } + return null; + } + + private DatabaseMessage getMmsCursorNext() { + if (mMmsCursor != null && mMmsCursor.moveToNext()) { + return MmsMessage.get(mMmsCursor); + } + return null; + } + + @Override + // Return approximate cursor position allowing for read ahead on two cursors (hence -1) + public int getPosition() { + return (mSmsCursor == null ? 0 : mSmsCursor.getPosition()) + + (mMmsCursor == null ? 0 : mMmsCursor.getPosition()) - 1; + } + + @Override + public int getCount() { + return (mSmsCursor == null ? 0 : mSmsCursor.getCount()) + + (mMmsCursor == null ? 0 : mMmsCursor.getCount()); + } + + @Override + public void close() { + if (mSmsCursor != null) { + mSmsCursor.close(); + mSmsCursor = null; + } + if (mMmsCursor != null) { + mMmsCursor.close(); + mMmsCursor = null; + } + } + } + + /** + * Type selection for importing sms messages. Only SENT and INBOX messages are imported. + * + * @return The SQL selection for importing sms messages + */ + public static String getSmsTypeSelectionSql() { + return MmsUtils.getSmsTypeSelectionSql(); + } + + /** + * Type selection for importing mms messages. + * + * Criteria: + * MESSAGE_BOX is INBOX, SENT or OUTBOX + * MESSAGE_TYPE is SEND_REQ (sent), RETRIEVE_CONF (received) or NOTIFICATION_IND (download) + * + * @return The SQL selection for importing mms messages. This selects the message type, + * not including the selection on timestamp. + */ + public static String getMmsTypeSelectionSql() { + return MmsUtils.getMmsTypeSelectionSql(); + } + + /** + * Get a SQL selection string using an existing selection and time window limits + * The limits are not applied if the value is < 0 + * + * @param typeSelection The existing selection + * @param from The inclusive lower bound + * @param to The exclusive upper bound + * @return The created SQL selection + */ + private static String getTimeConstrainedQuery(final String typeSelection, + final String timeColumn, final long from, final long to, + final String threadColumn, final String threadId) { + final StringBuilder queryBuilder = new StringBuilder(); + queryBuilder.append(typeSelection); + if (from > 0) { + queryBuilder.append(" AND ").append(timeColumn).append(">=").append(from); + } + if (to > 0) { + queryBuilder.append(" AND ").append(timeColumn).append("<").append(to); + } + if (!TextUtils.isEmpty(threadColumn) && !TextUtils.isEmpty(threadId)) { + queryBuilder.append(" AND ").append(threadColumn).append("=").append(threadId); + } + return queryBuilder.toString(); + } + + private static final String[] COUNT_PROJECTION = new String[] { "count()" }; + + private static int getCountFromCursor(final Cursor cursor) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } + // We should only return a number if we were able to read it from the cursor. + // Otherwise, we throw an exception to cancel the sync. + String cursorDesc = ""; + if (cursor == null) { + cursorDesc = "null"; + } else if (cursor.getCount() == 0) { + cursorDesc = "empty"; + } + throw new IllegalArgumentException("Cannot get count from " + cursorDesc + " cursor"); + } + + private void saveMessageToAdd(final List smsToAdd, + final LongSparseArray mmsToAdd, final DatabaseMessage message, + final ThreadInfoCache threadInfoCache) { + long threadId; + if (message.getProtocol() == MessageData.PROTOCOL_MMS) { + final MmsMessage mms = (MmsMessage) message; + mmsToAdd.append(mms.getId(), mms); + threadId = mms.mThreadId; + } else { + final SmsMessage sms = (SmsMessage) message; + smsToAdd.add(sms); + threadId = sms.mThreadId; + } + // Cache the lookup and canonicalization of the phone number outside of the transaction... + threadInfoCache.getThreadRecipients(threadId); + } + + /** + * Check if SMS has been synchronized. We compare the counts of messages on both + * sides and return true if they are equal. + * + * Note that this may not be the most reliable way to tell if messages are in sync. + * For example, the local misses one message and has one obsolete message. + * However, we have background sms sync once a while, also some other events might + * trigger a full sync. So we will eventually catch up. And this should be rare to + * happen. + * + * @return If sms is in sync with telephony sms/mms providers + */ + private static boolean isSynchronized(final DatabaseWrapper db, final String localSelection, + final String[] localSelectionArgs, final String smsSelection, + final String[] smsSelectionArgs, final String mmsSelection, + final String[] mmsSelectionArgs) { + final Context context = Factory.get().getApplicationContext(); + Cursor localCursor = null; + Cursor remoteSmsCursor = null; + Cursor remoteMmsCursor = null; + try { + localCursor = db.query( + DatabaseHelper.MESSAGES_TABLE, + COUNT_PROJECTION, + localSelection, + localSelectionArgs, + null/*groupBy*/, + null/*having*/, + null/*orderBy*/); + final int localCount = getCountFromCursor(localCursor); + remoteSmsCursor = SqliteWrapper.query( + context, + context.getContentResolver(), + Sms.CONTENT_URI, + COUNT_PROJECTION, + smsSelection, + smsSelectionArgs, + null/*orderBy*/); + final int smsCount = getCountFromCursor(remoteSmsCursor); + remoteMmsCursor = SqliteWrapper.query( + context, + context.getContentResolver(), + Mms.CONTENT_URI, + COUNT_PROJECTION, + mmsSelection, + mmsSelectionArgs, + null/*orderBy*/); + final int mmsCount = getCountFromCursor(remoteMmsCursor); + final int remoteCount = smsCount + mmsCount; + final boolean isInSync = (localCount == remoteCount); + if (isInSync) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncCursorPair: Same # of local and remote messages = " + + localCount); + } + } else { + LogUtil.i(TAG, "SyncCursorPair: Not in sync; # local messages = " + localCount + + ", # remote message = " + remoteCount); + } + return isInSync; + } catch (final Exception e) { + LogUtil.e(TAG, "SyncCursorPair: failed to query local or remote message counts", e); + // If something is wrong in querying database, assume we are synced so + // we don't retry indefinitely + } finally { + if (localCursor != null) { + localCursor.close(); + } + if (remoteSmsCursor != null) { + remoteSmsCursor.close(); + } + if (remoteMmsCursor != null) { + remoteMmsCursor.close(); + } + } + return true; + } +} diff --git a/src/com/android/messaging/datamodel/action/SyncMessageBatch.java b/src/com/android/messaging/datamodel/action/SyncMessageBatch.java new file mode 100644 index 0000000..972d691 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/SyncMessageBatch.java @@ -0,0 +1,383 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.database.Cursor; +import android.database.sqlite.SQLiteConstraintException; +import android.provider.Telephony; +import android.provider.Telephony.Mms; +import android.provider.Telephony.Sms; +import android.text.TextUtils; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.SyncManager.ThreadInfoCache; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.mmslib.pdu.PduHeaders; +import com.android.messaging.sms.DatabaseMessages.LocalDatabaseMessage; +import com.android.messaging.sms.DatabaseMessages.MmsMessage; +import com.android.messaging.sms.DatabaseMessages.SmsMessage; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; + +/** + * Update local database with a batch of messages to add/delete in one transaction + */ +class SyncMessageBatch { + private static final String TAG = LogUtil.BUGLE_TAG; + + // Variables used during executeAction + private final HashSet mConversationsToUpdate; + // Cache of thread->conversationId map + private final ThreadInfoCache mCache; + + // Set of SMS messages to add + private final ArrayList mSmsToAdd; + // Set of MMS messages to add + private final ArrayList mMmsToAdd; + // Set of local messages to delete + private final ArrayList mMessagesToDelete; + + SyncMessageBatch(final ArrayList smsToAdd, + final ArrayList mmsToAdd, + final ArrayList messagesToDelete, + final ThreadInfoCache cache) { + mSmsToAdd = smsToAdd; + mMmsToAdd = mmsToAdd; + mMessagesToDelete = messagesToDelete; + mCache = cache; + mConversationsToUpdate = new HashSet(); + } + + void updateLocalDatabase() { + // Perform local database changes in one transaction + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + try { + // Store all the SMS messages + for (final SmsMessage sms : mSmsToAdd) { + storeSms(db, sms); + } + // Store all the MMS messages + for (final MmsMessage mms : mMmsToAdd) { + storeMms(db, mms); + } + // Keep track of conversations with messages deleted + for (final LocalDatabaseMessage message : mMessagesToDelete) { + mConversationsToUpdate.add(message.getConversationId()); + } + // Batch delete local messages + batchDelete(db, DatabaseHelper.MESSAGES_TABLE, MessageColumns._ID, + messageListToIds(mMessagesToDelete)); + + for (final LocalDatabaseMessage message : mMessagesToDelete) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SyncMessageBatch: Deleted message " + message.getLocalId() + + " for SMS/MMS " + message.getUri() + " with timestamp " + + message.getTimestampInMillis()); + } + } + + // Update conversation state for imported messages, like snippet, + updateConversations(db); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + private static String[] messageListToIds(final List messagesToDelete) { + final String[] ids = new String[messagesToDelete.size()]; + for (int i = 0; i < ids.length; i++) { + ids[i] = Long.toString(messagesToDelete.get(i).getLocalId()); + } + return ids; + } + + /** + * Store the SMS message into local database. + * + * @param sms + */ + private void storeSms(final DatabaseWrapper db, final SmsMessage sms) { + if (sms.mBody == null) { + LogUtil.w(TAG, "SyncMessageBatch: SMS " + sms.mUri + " has no body; adding empty one"); + // try to fix it + sms.mBody = ""; + } + + if (TextUtils.isEmpty(sms.mAddress)) { + LogUtil.e(TAG, "SyncMessageBatch: SMS has no address; using unknown sender"); + // try to fix it + sms.mAddress = ParticipantData.getUnknownSenderDestination(); + } + + // TODO : We need to also deal with messages in a failed/retry state + final boolean isOutgoing = sms.mType != Sms.MESSAGE_TYPE_INBOX; + + final String otherPhoneNumber = sms.mAddress; + + // A forced resync of all messages should still keep the archived states. + // The database upgrade code notifies sync manager of this. We need to + // honor the original customization to this conversation if created. + final String conversationId = mCache.getOrCreateConversation(db, sms.mThreadId, sms.mSubId, + DataModel.get().getSyncManager().getCustomizationForThread(sms.mThreadId)); + if (conversationId == null) { + // Cannot create conversation for this message? This should not happen. + LogUtil.e(TAG, "SyncMessageBatch: Failed to create conversation for SMS thread " + + sms.mThreadId); + return; + } + final ParticipantData self = ParticipantData.getSelfParticipant(sms.getSubId()); + final String selfId = + BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self); + final ParticipantData sender = isOutgoing ? + self : + ParticipantData.getFromRawPhoneBySimLocale(otherPhoneNumber, sms.getSubId()); + final String participantId = (isOutgoing ? selfId : + BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, sender)); + + final int bugleStatus = bugleStatusForSms(isOutgoing, sms.mType, sms.mStatus); + + final MessageData message = MessageData.createSmsMessage( + sms.mUri, + participantId, + selfId, + conversationId, + bugleStatus, + sms.mSeen, + sms.mRead, + sms.mTimestampSentInMillis, + sms.mTimestampInMillis, + sms.mBody); + + // Inserting sms content into messages table + try { + BugleDatabaseOperations.insertNewMessageInTransaction(db, message); + } catch (SQLiteConstraintException e) { + rethrowSQLiteConstraintExceptionWithDetails(e, db, sms.mUri, sms.mThreadId, + conversationId, selfId, participantId); + } + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SyncMessageBatch: Inserted new message " + message.getMessageId() + + " for SMS " + message.getSmsMessageUri() + " received at " + + message.getReceivedTimeStamp()); + } + + // Keep track of updated conversation for later updating the conversation snippet, etc. + mConversationsToUpdate.add(conversationId); + } + + public static int bugleStatusForSms(final boolean isOutgoing, final int type, + final int status) { + int bugleStatus = MessageData.BUGLE_STATUS_UNKNOWN; + // For a message we sync either + if (isOutgoing) { + // Outgoing message not yet been sent + if (type == Telephony.Sms.MESSAGE_TYPE_FAILED || + type == Telephony.Sms.MESSAGE_TYPE_OUTBOX || + type == Telephony.Sms.MESSAGE_TYPE_QUEUED || + (type == Telephony.Sms.MESSAGE_TYPE_SENT && + status == Telephony.Sms.STATUS_FAILED)) { + // Not sent counts as failed and available for manual resend + bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_FAILED; + } else if (status == Sms.STATUS_COMPLETE) { + bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_DELIVERED; + } else { + // Otherwise outgoing message is complete + bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_COMPLETE; + } + } else { + // All incoming SMS messages are complete + bugleStatus = MessageData.BUGLE_STATUS_INCOMING_COMPLETE; + } + return bugleStatus; + } + + /** + * Store the MMS message into local database + * + * @param mms + */ + private void storeMms(final DatabaseWrapper db, final MmsMessage mms) { + if (mms.mParts.size() < 1) { + LogUtil.w(TAG, "SyncMessageBatch: MMS " + mms.mUri + " has no parts"); + } + + // TODO : We need to also deal with messages in a failed/retry state + final boolean isOutgoing = mms.mType != Mms.MESSAGE_BOX_INBOX; + final boolean isNotification = (mms.mMmsMessageType == + PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND); + + final String senderId = mms.mSender; + + // A forced resync of all messages should still keep the archived states. + // The database upgrade code notifies sync manager of this. We need to + // honor the original customization to this conversation if created. + final String conversationId = mCache.getOrCreateConversation(db, mms.mThreadId, mms.mSubId, + DataModel.get().getSyncManager().getCustomizationForThread(mms.mThreadId)); + if (conversationId == null) { + LogUtil.e(TAG, "SyncMessageBatch: Failed to create conversation for MMS thread " + + mms.mThreadId); + return; + } + final ParticipantData self = ParticipantData.getSelfParticipant(mms.getSubId()); + final String selfId = + BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, self); + final ParticipantData sender = isOutgoing ? + self : ParticipantData.getFromRawPhoneBySimLocale(senderId, mms.getSubId()); + final String participantId = (isOutgoing ? selfId : + BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, sender)); + + final int bugleStatus = MmsUtils.bugleStatusForMms(isOutgoing, isNotification, mms.mType); + + // Import message and all of the parts. + // TODO : For now we are importing these in the order we found them in the MMS + // database. Ideally we would load and parse the SMIL which describes how the parts relate + // to one another. + + // TODO: Need to set correct status on message + final MessageData message = MmsUtils.createMmsMessage(mms, conversationId, participantId, + selfId, bugleStatus); + + // Inserting mms content into messages table + try { + BugleDatabaseOperations.insertNewMessageInTransaction(db, message); + } catch (SQLiteConstraintException e) { + rethrowSQLiteConstraintExceptionWithDetails(e, db, mms.mUri, mms.mThreadId, + conversationId, selfId, participantId); + } + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SyncMessageBatch: Inserted new message " + message.getMessageId() + + " for MMS " + message.getSmsMessageUri() + " received at " + + message.getReceivedTimeStamp()); + } + + // Keep track of updated conversation for later updating the conversation snippet, etc. + mConversationsToUpdate.add(conversationId); + } + + // TODO: Remove this after we no longer see this crash (b/18375758) + private static void rethrowSQLiteConstraintExceptionWithDetails(SQLiteConstraintException e, + DatabaseWrapper db, String messageUri, long threadId, String conversationId, + String selfId, String senderId) { + // Add some extra debug information to the exception for tracking down b/18375758. + // The default detail message for SQLiteConstraintException tells us that a foreign + // key constraint failed, but not which one! Messages have foreign keys to 3 tables: + // conversations, participants (self), participants (sender). We'll query each one + // to determine which one(s) violated the constraint, and then throw a new exception + // with those details. + + String foundConversationId = null; + Cursor cursor = null; + try { + // Look for an existing conversation in the db with the conversation id + cursor = db.rawQuery("SELECT " + ConversationColumns._ID + + " FROM " + DatabaseHelper.CONVERSATIONS_TABLE + + " WHERE " + ConversationColumns._ID + "=" + conversationId, + null); + if (cursor != null && cursor.moveToFirst()) { + Assert.isTrue(cursor.getCount() == 1); + foundConversationId = cursor.getString(0); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + ParticipantData foundSelfParticipant = + BugleDatabaseOperations.getExistingParticipant(db, selfId); + ParticipantData foundSenderParticipant = + BugleDatabaseOperations.getExistingParticipant(db, senderId); + + String errorMsg = "SQLiteConstraintException while inserting message for " + messageUri + + "; conversation id from getOrCreateConversation = " + conversationId + + " (lookup thread = " + threadId + "), found conversation id = " + + foundConversationId + ", found self participant = " + + LogUtil.sanitizePII(foundSelfParticipant.getNormalizedDestination()) + + " (lookup id = " + selfId + "), found sender participant = " + + LogUtil.sanitizePII(foundSenderParticipant.getNormalizedDestination()) + + " (lookup id = " + senderId + ")"; + throw new RuntimeException(errorMsg, e); + } + + /** + * Use the tracked latest message info to update conversations, including + * latest chat message and sort timestamp. + */ + private void updateConversations(final DatabaseWrapper db) { + for (final String conversationId : mConversationsToUpdate) { + if (BugleDatabaseOperations.deleteConversationIfEmptyInTransaction(db, + conversationId)) { + continue; + } + + final boolean archived = mCache.isArchived(conversationId); + // Always attempt to auto-switch conversation self id for sync/import case. + BugleDatabaseOperations.maybeRefreshConversationMetadataInTransaction(db, + conversationId, true /*shouldAutoSwitchSelfId*/, archived /*keepArchived*/); + } + } + + + /** + * Batch delete database rows by matching a column with a list of values, usually some + * kind of IDs. + * + * @param table + * @param column + * @param ids + * @return Total number of deleted messages + */ + private static int batchDelete(final DatabaseWrapper db, final String table, + final String column, final String[] ids) { + int totalDeleted = 0; + final int totalIds = ids.length; + for (int start = 0; start < totalIds; start += MmsUtils.MAX_IDS_PER_QUERY) { + final int end = Math.min(start + MmsUtils.MAX_IDS_PER_QUERY, totalIds); //excluding + final int count = end - start; + final String batchSelection = String.format( + Locale.US, + "%s IN %s", + column, + MmsUtils.getSqlInOperand(count)); + final String[] batchSelectionArgs = Arrays.copyOfRange(ids, start, end); + final int deleted = db.delete( + table, + batchSelection, + batchSelectionArgs); + totalDeleted += deleted; + } + return totalDeleted; + } +} diff --git a/src/com/android/messaging/datamodel/action/SyncMessagesAction.java b/src/com/android/messaging/datamodel/action/SyncMessagesAction.java new file mode 100644 index 0000000..f4a3e1f --- /dev/null +++ b/src/com/android/messaging/datamodel/action/SyncMessagesAction.java @@ -0,0 +1,637 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.os.SystemClock; +import android.provider.Telephony.Mms; +import android.support.v4.util.LongSparseArray; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.SyncManager; +import com.android.messaging.datamodel.SyncManager.ThreadInfoCache; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.mmslib.SqliteWrapper; +import com.android.messaging.sms.DatabaseMessages; +import com.android.messaging.sms.DatabaseMessages.LocalDatabaseMessage; +import com.android.messaging.sms.DatabaseMessages.MmsMessage; +import com.android.messaging.sms.DatabaseMessages.SmsMessage; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.BuglePrefsKeys; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * Action used to sync messages from smsmms db to local database + */ +public class SyncMessagesAction extends Action implements Parcelable { + static final long SYNC_FAILED = Long.MIN_VALUE; + + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + private static final String KEY_START_TIMESTAMP = "start_timestamp"; + private static final String KEY_MAX_UPDATE = "max_update"; + private static final String KEY_LOWER_BOUND = "lower_bound"; + private static final String KEY_UPPER_BOUND = "upper_bound"; + private static final String BUNDLE_KEY_LAST_TIMESTAMP = "last_timestamp"; + private static final String BUNDLE_KEY_SMS_MESSAGES = "sms_to_add"; + private static final String BUNDLE_KEY_MMS_MESSAGES = "mms_to_add"; + private static final String BUNDLE_KEY_MESSAGES_TO_DELETE = "messages_to_delete"; + + /** + * Start a full sync (backed off a few seconds to avoid pulling sending/receiving messages). + */ + public static void fullSync() { + final BugleGservices bugleGservices = BugleGservices.get(); + final long smsSyncBackoffTimeMillis = bugleGservices.getLong( + BugleGservicesKeys.SMS_SYNC_BACKOFF_TIME_MILLIS, + BugleGservicesKeys.SMS_SYNC_BACKOFF_TIME_MILLIS_DEFAULT); + + final long now = System.currentTimeMillis(); + // TODO: Could base this off most recent message in db but now should be okay... + final long startTimestamp = now - smsSyncBackoffTimeMillis; + + final SyncMessagesAction action = new SyncMessagesAction(-1L, startTimestamp, + 0, startTimestamp); + action.start(); + } + + /** + * Start an incremental sync to pull messages since last sync (backed off a few seconds).. + */ + public static void sync() { + final BugleGservices bugleGservices = BugleGservices.get(); + final long smsSyncBackoffTimeMillis = bugleGservices.getLong( + BugleGservicesKeys.SMS_SYNC_BACKOFF_TIME_MILLIS, + BugleGservicesKeys.SMS_SYNC_BACKOFF_TIME_MILLIS_DEFAULT); + + final long now = System.currentTimeMillis(); + // TODO: Could base this off most recent message in db but now should be okay... + final long startTimestamp = now - smsSyncBackoffTimeMillis; + + sync(startTimestamp); + } + + /** + * Start an incremental sync when the application starts up (no back off as not yet + * sending/receiving). + */ + public static void immediateSync() { + final long now = System.currentTimeMillis(); + // TODO: Could base this off most recent message in db but now should be okay... + final long startTimestamp = now; + + sync(startTimestamp); + } + + private static void sync(final long startTimestamp) { + if (!OsUtil.hasSmsPermission()) { + // Sync requires READ_SMS permission + return; + } + + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + // Lower bound is end of previous sync + final long syncLowerBoundTimeMillis = prefs.getLong(BuglePrefsKeys.LAST_SYNC_TIME, + BuglePrefsKeys.LAST_SYNC_TIME_DEFAULT); + + final SyncMessagesAction action = new SyncMessagesAction(syncLowerBoundTimeMillis, + startTimestamp, 0, startTimestamp); + action.start(); + } + + private SyncMessagesAction(final long lowerBound, final long upperBound, + final int maxMessagesToUpdate, final long startTimestamp) { + actionParameters.putLong(KEY_LOWER_BOUND, lowerBound); + actionParameters.putLong(KEY_UPPER_BOUND, upperBound); + actionParameters.putInt(KEY_MAX_UPDATE, maxMessagesToUpdate); + actionParameters.putLong(KEY_START_TIMESTAMP, startTimestamp); + } + + @Override + protected Object executeAction() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + + long lowerBoundTimeMillis = actionParameters.getLong(KEY_LOWER_BOUND); + final long upperBoundTimeMillis = actionParameters.getLong(KEY_UPPER_BOUND); + final int initialMaxMessagesToUpdate = actionParameters.getInt(KEY_MAX_UPDATE); + final long startTimestamp = actionParameters.getLong(KEY_START_TIMESTAMP); + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncMessagesAction: Request to sync messages from " + + lowerBoundTimeMillis + " to " + upperBoundTimeMillis + " (start timestamp = " + + startTimestamp + ", message update limit = " + initialMaxMessagesToUpdate + + ")"); + } + + final SyncManager syncManager = DataModel.get().getSyncManager(); + if (lowerBoundTimeMillis >= 0) { + // Cursors + final SyncCursorPair cursors = new SyncCursorPair(-1L, lowerBoundTimeMillis); + final boolean inSync = cursors.isSynchronized(db); + if (!inSync) { + if (syncManager.delayUntilFullSync(startTimestamp) == 0) { + lowerBoundTimeMillis = -1; + actionParameters.putLong(KEY_LOWER_BOUND, lowerBoundTimeMillis); + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncMessagesAction: Messages before " + + lowerBoundTimeMillis + " not in sync; promoting to full sync"); + } + } else if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncMessagesAction: Messages before " + + lowerBoundTimeMillis + " not in sync; will do incremental sync"); + } + } else { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncMessagesAction: Messages before " + lowerBoundTimeMillis + + " are in sync"); + } + } + } + + // Check if sync allowed (can be too soon after last or one is already running) + if (syncManager.shouldSync(lowerBoundTimeMillis < 0, startTimestamp)) { + syncManager.startSyncBatch(upperBoundTimeMillis); + requestBackgroundWork(); + } + + return null; + } + + @Override + protected Bundle doBackgroundWork() { + final BugleGservices bugleGservices = BugleGservices.get(); + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final int maxMessagesToScan = bugleGservices.getInt( + BugleGservicesKeys.SMS_SYNC_BATCH_MAX_MESSAGES_TO_SCAN, + BugleGservicesKeys.SMS_SYNC_BATCH_MAX_MESSAGES_TO_SCAN_DEFAULT); + + final int initialMaxMessagesToUpdate = actionParameters.getInt(KEY_MAX_UPDATE); + final int smsSyncSubsequentBatchSizeMin = bugleGservices.getInt( + BugleGservicesKeys.SMS_SYNC_BATCH_SIZE_MIN, + BugleGservicesKeys.SMS_SYNC_BATCH_SIZE_MIN_DEFAULT); + final int smsSyncSubsequentBatchSizeMax = bugleGservices.getInt( + BugleGservicesKeys.SMS_SYNC_BATCH_SIZE_MAX, + BugleGservicesKeys.SMS_SYNC_BATCH_SIZE_MAX_DEFAULT); + + // Cap sync size to GServices limits + final int maxMessagesToUpdate = Math.max(smsSyncSubsequentBatchSizeMin, + Math.min(initialMaxMessagesToUpdate, smsSyncSubsequentBatchSizeMax)); + + final long lowerBoundTimeMillis = actionParameters.getLong(KEY_LOWER_BOUND); + final long upperBoundTimeMillis = actionParameters.getLong(KEY_UPPER_BOUND); + + LogUtil.i(TAG, "SyncMessagesAction: Starting batch for messages from " + + lowerBoundTimeMillis + " to " + upperBoundTimeMillis + + " (message update limit = " + maxMessagesToUpdate + ", message scan limit = " + + maxMessagesToScan + ")"); + + // Clear last change time so that we can work out if this batch is dirty when it completes + final SyncManager syncManager = DataModel.get().getSyncManager(); + + // Clear the singleton cache that maps threads to recipients and to conversations. + final SyncManager.ThreadInfoCache cache = syncManager.getThreadInfoCache(); + cache.clear(); + + // Sms messages to store + final ArrayList smsToAdd = new ArrayList(); + // Mms messages to store + final LongSparseArray mmsToAdd = new LongSparseArray(); + // List of local SMS/MMS to remove + final ArrayList messagesToDelete = + new ArrayList(); + + long lastTimestampMillis = SYNC_FAILED; + if (syncManager.isSyncing(upperBoundTimeMillis)) { + // Cursors + final SyncCursorPair cursors = new SyncCursorPair(lowerBoundTimeMillis, + upperBoundTimeMillis); + + // Actually compare the messages using cursor pair + lastTimestampMillis = syncCursorPair(db, cursors, smsToAdd, mmsToAdd, + messagesToDelete, maxMessagesToScan, maxMessagesToUpdate, cache); + } + final Bundle response = new Bundle(); + + // If comparison succeeds bundle up the changes for processing in ActionService + if (lastTimestampMillis > SYNC_FAILED) { + final ArrayList mmsToAddList = new ArrayList(); + for (int i = 0; i < mmsToAdd.size(); i++) { + final MmsMessage mms = mmsToAdd.valueAt(i); + mmsToAddList.add(mms); + } + + response.putParcelableArrayList(BUNDLE_KEY_SMS_MESSAGES, smsToAdd); + response.putParcelableArrayList(BUNDLE_KEY_MMS_MESSAGES, mmsToAddList); + response.putParcelableArrayList(BUNDLE_KEY_MESSAGES_TO_DELETE, messagesToDelete); + } + response.putLong(BUNDLE_KEY_LAST_TIMESTAMP, lastTimestampMillis); + + return response; + } + + /** + * Compare messages based on timestamp and uri + * @param db local database wrapper + * @param cursors cursor pair holding references to local and remote messages + * @param smsToAdd newly found sms messages to add + * @param mmsToAdd newly found mms messages to add + * @param messagesToDelete messages not found needing deletion + * @param maxMessagesToScan max messages to scan for changes + * @param maxMessagesToUpdate max messages to return for updates + * @param cache cache for conversation id / thread id / recipient set mapping + * @return timestamp of the oldest message seen during the sync scan + */ + private long syncCursorPair(final DatabaseWrapper db, final SyncCursorPair cursors, + final ArrayList smsToAdd, final LongSparseArray mmsToAdd, + final ArrayList messagesToDelete, final int maxMessagesToScan, + final int maxMessagesToUpdate, final ThreadInfoCache cache) { + long lastTimestampMillis; + final long startTimeMillis = SystemClock.elapsedRealtime(); + + // Number of messages scanned local and remote + int localPos = 0; + int remotePos = 0; + int localTotal = 0; + int remoteTotal = 0; + // Scan through the messages on both sides and prepare messages for local message table + // changes (including adding and deleting) + try { + cursors.query(db); + + localTotal = cursors.getLocalCount(); + remoteTotal = cursors.getRemoteCount(); + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncMessagesAction: Scanning cursors (local count = " + localTotal + + ", remote count = " + remoteTotal + ", message update limit = " + + maxMessagesToUpdate + ", message scan limit = " + maxMessagesToScan + + ")"); + } + + lastTimestampMillis = cursors.scan(maxMessagesToScan, maxMessagesToUpdate, + smsToAdd, mmsToAdd, messagesToDelete, cache); + + localPos = cursors.getLocalPosition(); + remotePos = cursors.getRemotePosition(); + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncMessagesAction: Scanned cursors (local position = " + localPos + + " of " + localTotal + ", remote position = " + remotePos + " of " + + remoteTotal + ")"); + } + + // Batch loading the parts of the MMS messages in this batch + loadMmsParts(mmsToAdd); + // Lookup senders for incoming mms messages + setMmsSenders(mmsToAdd, cache); + } catch (final SQLiteException e) { + LogUtil.e(TAG, "SyncMessagesAction: Database exception", e); + // Let's abort + lastTimestampMillis = SYNC_FAILED; + } catch (final Exception e) { + // We want to catch anything unexpected since this is running in a separate thread + // and any unexpected exception will just fail this thread silently. + // Let's crash for dogfooders! + LogUtil.wtf(TAG, "SyncMessagesAction: unexpected failure in scan", e); + lastTimestampMillis = SYNC_FAILED; + } finally { + if (cursors != null) { + cursors.close(); + } + } + + final long endTimeMillis = SystemClock.elapsedRealtime(); + + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncMessagesAction: Scan complete (took " + + (endTimeMillis - startTimeMillis) + " ms). " + smsToAdd.size() + + " remote SMS to add, " + mmsToAdd.size() + " MMS to add, " + + messagesToDelete.size() + " local messages to delete. " + + "Oldest timestamp seen = " + lastTimestampMillis); + } + + return lastTimestampMillis; + } + + /** + * Perform local database updates and schedule follow on sync actions + */ + @Override + protected Object processBackgroundResponse(final Bundle response) { + final long lastTimestampMillis = response.getLong(BUNDLE_KEY_LAST_TIMESTAMP); + final long lowerBoundTimeMillis = actionParameters.getLong(KEY_LOWER_BOUND); + final long upperBoundTimeMillis = actionParameters.getLong(KEY_UPPER_BOUND); + final int maxMessagesToUpdate = actionParameters.getInt(KEY_MAX_UPDATE); + final long startTimestamp = actionParameters.getLong(KEY_START_TIMESTAMP); + + // Check with the sync manager if any conflicting updates have been made to databases + final SyncManager syncManager = DataModel.get().getSyncManager(); + final boolean orphan = !syncManager.isSyncing(upperBoundTimeMillis); + + // lastTimestampMillis used to indicate failure + if (orphan) { + // This batch does not match current in progress timestamp. + LogUtil.w(TAG, "SyncMessagesAction: Ignoring orphan sync batch for messages from " + + lowerBoundTimeMillis + " to " + upperBoundTimeMillis); + } else { + final boolean dirty = syncManager.isBatchDirty(lastTimestampMillis); + if (lastTimestampMillis == SYNC_FAILED) { + LogUtil.e(TAG, "SyncMessagesAction: Sync failed - terminating"); + + // Failed - update last sync times to throttle our failure rate + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + // Save sync completion time so next sync will start from here + prefs.putLong(BuglePrefsKeys.LAST_SYNC_TIME, startTimestamp); + // Remember last full sync so that don't start background full sync right away + prefs.putLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, startTimestamp); + + syncManager.complete(); + } else if (dirty) { + LogUtil.w(TAG, "SyncMessagesAction: Redoing dirty sync batch of messages from " + + lowerBoundTimeMillis + " to " + upperBoundTimeMillis); + + // Redo this batch + final SyncMessagesAction nextBatch = + new SyncMessagesAction(lowerBoundTimeMillis, upperBoundTimeMillis, + maxMessagesToUpdate, startTimestamp); + + syncManager.startSyncBatch(upperBoundTimeMillis); + requestBackgroundWork(nextBatch); + } else { + // Succeeded + final ArrayList smsToAdd = + response.getParcelableArrayList(BUNDLE_KEY_SMS_MESSAGES); + final ArrayList mmsToAdd = + response.getParcelableArrayList(BUNDLE_KEY_MMS_MESSAGES); + final ArrayList messagesToDelete = + response.getParcelableArrayList(BUNDLE_KEY_MESSAGES_TO_DELETE); + + final int messagesUpdated = smsToAdd.size() + mmsToAdd.size() + + messagesToDelete.size(); + + // Perform local database changes in one transaction + long txnTimeMillis = 0; + if (messagesUpdated > 0) { + final long startTimeMillis = SystemClock.elapsedRealtime(); + final SyncMessageBatch batch = new SyncMessageBatch(smsToAdd, mmsToAdd, + messagesToDelete, syncManager.getThreadInfoCache()); + batch.updateLocalDatabase(); + final long endTimeMillis = SystemClock.elapsedRealtime(); + txnTimeMillis = endTimeMillis - startTimeMillis; + + LogUtil.i(TAG, "SyncMessagesAction: Updated local database " + + "(took " + txnTimeMillis + " ms). Added " + + smsToAdd.size() + " SMS, added " + mmsToAdd.size() + " MMS, deleted " + + messagesToDelete.size() + " messages."); + + // TODO: Investigate whether we can make this more fine-grained. + MessagingContentProvider.notifyEverythingChanged(); + } else { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncMessagesAction: No local database updates to make"); + } + + if (!syncManager.getHasFirstSyncCompleted()) { + // If we have never completed a sync before (fresh install) and there are + // no messages, still inform the UI of a change so it can update syncing + // messages shown to the user + MessagingContentProvider.notifyConversationListChanged(); + MessagingContentProvider.notifyPartsChanged(); + } + } + // Determine if there are more messages that need to be scanned + if (lastTimestampMillis >= 0 && lastTimestampMillis >= lowerBoundTimeMillis) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SyncMessagesAction: More messages to sync; scheduling next " + + "sync batch now."); + } + + // Include final millisecond of last sync in next sync + final long newUpperBoundTimeMillis = lastTimestampMillis + 1; + final int newMaxMessagesToUpdate = nextBatchSize(messagesUpdated, + txnTimeMillis); + + final SyncMessagesAction nextBatch = + new SyncMessagesAction(lowerBoundTimeMillis, newUpperBoundTimeMillis, + newMaxMessagesToUpdate, startTimestamp); + + // Proceed with next batch + syncManager.startSyncBatch(newUpperBoundTimeMillis); + requestBackgroundWork(nextBatch); + } else { + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + // Save sync completion time so next sync will start from here + prefs.putLong(BuglePrefsKeys.LAST_SYNC_TIME, startTimestamp); + if (lowerBoundTimeMillis < 0) { + // Remember last full sync so that don't start another full sync right away + prefs.putLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, startTimestamp); + } + + final long now = System.currentTimeMillis(); + + // After any sync check if new messages have arrived + final SyncCursorPair recents = new SyncCursorPair(startTimestamp, now); + final SyncCursorPair olders = new SyncCursorPair(-1L, startTimestamp); + final DatabaseWrapper db = DataModel.get().getDatabase(); + if (!recents.isSynchronized(db)) { + LogUtil.i(TAG, "SyncMessagesAction: Changed messages after sync; " + + "scheduling an incremental sync now."); + + // Just add a new batch for recent messages + final SyncMessagesAction nextBatch = + new SyncMessagesAction(startTimestamp, now, 0, startTimestamp); + syncManager.startSyncBatch(now); + requestBackgroundWork(nextBatch); + // After partial sync verify sync state + } else if (lowerBoundTimeMillis >= 0 && !olders.isSynchronized(db)) { + // Add a batch going back to start of time + LogUtil.w(TAG, "SyncMessagesAction: Changed messages before sync batch; " + + "scheduling a full sync now."); + + final SyncMessagesAction nextBatch = + new SyncMessagesAction(-1L, startTimestamp, 0, startTimestamp); + + syncManager.startSyncBatch(startTimestamp); + requestBackgroundWork(nextBatch); + } else { + LogUtil.i(TAG, "SyncMessagesAction: All messages now in sync"); + + // All done, in sync + syncManager.complete(); + } + } + // Either sync should be complete or we should have a follow up request + Assert.isTrue(hasBackgroundActions() || !syncManager.isSyncing()); + } + } + + return null; + } + + /** + * Decide the next batch size based on the stats we collected with past batch + * @param messagesUpdated number of messages updated in this batch + * @param txnTimeMillis time the transaction took in ms + * @return Target number of messages to sync for next batch + */ + private static int nextBatchSize(final int messagesUpdated, final long txnTimeMillis) { + final BugleGservices bugleGservices = BugleGservices.get(); + final long smsSyncSubsequentBatchTimeLimitMillis = bugleGservices.getLong( + BugleGservicesKeys.SMS_SYNC_BATCH_TIME_LIMIT_MILLIS, + BugleGservicesKeys.SMS_SYNC_BATCH_TIME_LIMIT_MILLIS_DEFAULT); + + if (txnTimeMillis <= 0) { + return 0; + } + // Number of messages we can sync within the batch time limit using + // the average sync time calculated based on the stats we collected + // in previous batch + return (int) ((double) (messagesUpdated) / (double) txnTimeMillis + * smsSyncSubsequentBatchTimeLimitMillis); + } + + /** + * Batch loading MMS parts for the messages in current batch + */ + private void loadMmsParts(final LongSparseArray mmses) { + final Context context = Factory.get().getApplicationContext(); + final int totalIds = mmses.size(); + for (int start = 0; start < totalIds; start += MmsUtils.MAX_IDS_PER_QUERY) { + final int end = Math.min(start + MmsUtils.MAX_IDS_PER_QUERY, totalIds); //excluding + final int count = end - start; + final String batchSelection = String.format( + Locale.US, + "%s != '%s' AND %s IN %s", + Mms.Part.CONTENT_TYPE, + ContentType.APP_SMIL, + Mms.Part.MSG_ID, + MmsUtils.getSqlInOperand(count)); + final String[] batchSelectionArgs = new String[count]; + for (int i = 0; i < count; i++) { + batchSelectionArgs[i] = Long.toString(mmses.valueAt(start + i).getId()); + } + final Cursor cursor = SqliteWrapper.query( + context, + context.getContentResolver(), + MmsUtils.MMS_PART_CONTENT_URI, + DatabaseMessages.MmsPart.PROJECTION, + batchSelection, + batchSelectionArgs, + null/*sortOrder*/); + if (cursor != null) { + try { + while (cursor.moveToNext()) { + // Delay loading the media content for parsing for efficiency + // TODO: load the media and fill in the dimensions when + // we actually display it + final DatabaseMessages.MmsPart part = + DatabaseMessages.MmsPart.get(cursor, false/*loadMedia*/); + final DatabaseMessages.MmsMessage mms = mmses.get(part.mMessageId); + if (mms != null) { + mms.addPart(part); + } + } + } finally { + cursor.close(); + } + } + } + } + + /** + * Batch loading MMS sender for the messages in current batch + */ + private void setMmsSenders(final LongSparseArray mmses, + final ThreadInfoCache cache) { + // Store all the MMS messages + for (int i = 0; i < mmses.size(); i++) { + final MmsMessage mms = mmses.valueAt(i); + + final boolean isOutgoing = mms.mType != Mms.MESSAGE_BOX_INBOX; + String senderId = null; + if (!isOutgoing) { + // We only need to find out sender phone number for received message + senderId = getMmsSender(mms, cache); + if (senderId == null) { + LogUtil.w(TAG, "SyncMessagesAction: Could not find sender of incoming MMS " + + "message " + mms.getUri() + "; using 'unknown sender' instead"); + senderId = ParticipantData.getUnknownSenderDestination(); + } + } + mms.setSender(senderId); + } + } + + /** + * Find out the sender of an MMS message + */ + private String getMmsSender(final MmsMessage mms, final ThreadInfoCache cache) { + final List recipients = cache.getThreadRecipients(mms.mThreadId); + Assert.notNull(recipients); + Assert.isTrue(recipients.size() > 0); + + if (recipients.size() == 1 + && recipients.get(0).equals(ParticipantData.getUnknownSenderDestination())) { + LogUtil.w(TAG, "SyncMessagesAction: MMS message " + mms.mUri + " has unknown sender " + + "(thread id = " + mms.mThreadId + ")"); + } + + return MmsUtils.getMmsSender(recipients, mms.mUri); + } + + private SyncMessagesAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public SyncMessagesAction createFromParcel(final Parcel in) { + return new SyncMessagesAction(in); + } + + @Override + public SyncMessagesAction[] newArray(final int size) { + return new SyncMessagesAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/UpdateConversationArchiveStatusAction.java b/src/com/android/messaging/datamodel/action/UpdateConversationArchiveStatusAction.java new file mode 100644 index 0000000..066ad74 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/UpdateConversationArchiveStatusAction.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.action; + +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.util.Assert; + +public class UpdateConversationArchiveStatusAction extends Action { + + public static void archiveConversation(final String conversationId) { + final UpdateConversationArchiveStatusAction action = + new UpdateConversationArchiveStatusAction(conversationId, true /* isArchive */); + action.start(); + } + + public static void unarchiveConversation(final String conversationId) { + final UpdateConversationArchiveStatusAction action = + new UpdateConversationArchiveStatusAction(conversationId, false /* isArchive */); + action.start(); + } + + private static final String KEY_CONVERSATION_ID = "conversation_id"; + private static final String KEY_IS_ARCHIVE = "is_archive"; + + protected UpdateConversationArchiveStatusAction( + final String conversationId, final boolean isArchive) { + Assert.isTrue(!TextUtils.isEmpty(conversationId)); + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + actionParameters.putBoolean(KEY_IS_ARCHIVE, isArchive); + } + + @Override + protected Object executeAction() { + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + final boolean isArchived = actionParameters.getBoolean(KEY_IS_ARCHIVE); + + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + try { + BugleDatabaseOperations.updateConversationArchiveStatusInTransaction( + db, conversationId, isArchived); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + MessagingContentProvider.notifyConversationListChanged(); + MessagingContentProvider.notifyConversationMetadataChanged(conversationId); + return null; + } + + protected UpdateConversationArchiveStatusAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public UpdateConversationArchiveStatusAction createFromParcel(final Parcel in) { + return new UpdateConversationArchiveStatusAction(in); + } + + @Override + public UpdateConversationArchiveStatusAction[] newArray(final int size) { + return new UpdateConversationArchiveStatusAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/UpdateConversationOptionsAction.java b/src/com/android/messaging/datamodel/action/UpdateConversationOptionsAction.java new file mode 100644 index 0000000..6c9e739 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/UpdateConversationOptionsAction.java @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.ContentValues; +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.util.Assert; + +/** + * Action used to update conversation options such as notification settings. + */ +public class UpdateConversationOptionsAction extends Action + implements Parcelable { + /** + * Enable/disable conversation notifications. + */ + public static void enableConversationNotifications(final String conversationId, + final boolean enableNotification) { + Assert.notNull(conversationId); + + final UpdateConversationOptionsAction action = new UpdateConversationOptionsAction( + conversationId, enableNotification, null, null); + action.start(); + } + + /** + * Sets conversation notification sound. + */ + public static void setConversationNotificationSound(final String conversationId, + final String ringtoneUri) { + Assert.notNull(conversationId); + + final UpdateConversationOptionsAction action = new UpdateConversationOptionsAction( + conversationId, null, ringtoneUri, null); + action.start(); + } + + /** + * Enable/disable vibrations for conversation notification. + */ + public static void enableVibrationForConversationNotification(final String conversationId, + final boolean enableVibration) { + Assert.notNull(conversationId); + + final UpdateConversationOptionsAction action = new UpdateConversationOptionsAction( + conversationId, null, null, enableVibration); + action.start(); + } + + private static final String KEY_CONVERSATION_ID = "conversation_id"; + + // Keys for all settable settings. + private static final String KEY_ENABLE_NOTIFICATION = "enable_notification"; + private static final String KEY_RINGTONE_URI = "ringtone_uri"; + private static final String KEY_ENABLE_VIBRATION = "enable_vibration"; + + protected UpdateConversationOptionsAction(final String conversationId, + final Boolean enableNotification, final String ringtoneUri, + final Boolean enableVibration) { + Assert.notNull(conversationId); + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + if (enableNotification != null) { + actionParameters.putBoolean(KEY_ENABLE_NOTIFICATION, enableNotification); + } + + if (ringtoneUri != null) { + actionParameters.putString(KEY_RINGTONE_URI, ringtoneUri); + } + + if (enableVibration != null) { + actionParameters.putBoolean(KEY_ENABLE_VIBRATION, enableVibration); + } + } + + protected void putOptionValuesInTransaction(final ContentValues values, + final DatabaseWrapper dbWrapper) { + Assert.isTrue(dbWrapper.getDatabase().inTransaction()); + if (actionParameters.containsKey(KEY_ENABLE_NOTIFICATION)) { + values.put(ConversationColumns.NOTIFICATION_ENABLED, + actionParameters.getBoolean(KEY_ENABLE_NOTIFICATION)); + } + + if (actionParameters.containsKey(KEY_RINGTONE_URI)) { + values.put(ConversationColumns.NOTIFICATION_SOUND_URI, + actionParameters.getString(KEY_RINGTONE_URI)); + } + + if (actionParameters.containsKey(KEY_ENABLE_VIBRATION)) { + values.put(ConversationColumns.NOTIFICATION_VIBRATION, + actionParameters.getBoolean(KEY_ENABLE_VIBRATION)); + } + } + + @Override + protected Object executeAction() { + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + try { + final ContentValues values = new ContentValues(); + putOptionValuesInTransaction(values, db); + + BugleDatabaseOperations.updateConversationRowIfExists(db, conversationId, values); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + MessagingContentProvider.notifyConversationMetadataChanged(conversationId); + return null; + } + + protected UpdateConversationOptionsAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public UpdateConversationOptionsAction createFromParcel(final Parcel in) { + return new UpdateConversationOptionsAction(in); + } + + @Override + public UpdateConversationOptionsAction[] newArray(final int size) { + return new UpdateConversationOptionsAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/UpdateDestinationBlockedAction.java b/src/com/android/messaging/datamodel/action/UpdateDestinationBlockedAction.java new file mode 100644 index 0000000..c74096d --- /dev/null +++ b/src/com/android/messaging/datamodel/action/UpdateDestinationBlockedAction.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.action; + +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.util.Assert; + +public class UpdateDestinationBlockedAction extends Action { + public interface UpdateDestinationBlockedActionListener { + @Assert.RunsOnMainThread + abstract void onUpdateDestinationBlockedAction(final UpdateDestinationBlockedAction action, + final boolean success, + final boolean block, + final String destination); + } + + public static class UpdateDestinationBlockedActionMonitor extends ActionMonitor + implements ActionMonitor.ActionCompletedListener { + private final UpdateDestinationBlockedActionListener mListener; + + public UpdateDestinationBlockedActionMonitor( + Object data, UpdateDestinationBlockedActionListener mListener) { + super(STATE_CREATED, generateUniqueActionKey("UpdateDestinationBlockedAction"), data); + setCompletedListener(this); + this.mListener = mListener; + } + + private void onActionDone(final boolean succeeded, + final ActionMonitor monitor, + final Action action, + final Object data, + final Object result) { + mListener.onUpdateDestinationBlockedAction( + (UpdateDestinationBlockedAction) action, + succeeded, + action.actionParameters.getBoolean(KEY_BLOCKED), + action.actionParameters.getString(KEY_DESTINATION)); + } + + @Override + public void onActionSucceeded(final ActionMonitor monitor, + final Action action, + final Object data, + final Object result) { + onActionDone(true, monitor, action, data, result); + } + + @Override + public void onActionFailed(final ActionMonitor monitor, + final Action action, + final Object data, + final Object result) { + onActionDone(false, monitor, action, data, result); + } + } + + + public static UpdateDestinationBlockedActionMonitor updateDestinationBlocked( + final String destination, final boolean blocked, final String conversationId, + final UpdateDestinationBlockedActionListener listener) { + Assert.notNull(listener); + final UpdateDestinationBlockedActionMonitor monitor = + new UpdateDestinationBlockedActionMonitor(null, listener); + final UpdateDestinationBlockedAction action = + new UpdateDestinationBlockedAction(destination, blocked, conversationId, + monitor.getActionKey()); + action.start(monitor); + return monitor; + } + + private static final String KEY_CONVERSATION_ID = "conversation_id"; + private static final String KEY_DESTINATION = "destination"; + private static final String KEY_BLOCKED = "blocked"; + + protected UpdateDestinationBlockedAction( + final String destination, final boolean blocked, final String conversationId, + final String actionKey) { + super(actionKey); + Assert.isTrue(!TextUtils.isEmpty(destination)); + actionParameters.putString(KEY_DESTINATION, destination); + actionParameters.putBoolean(KEY_BLOCKED, blocked); + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + } + + @Override + protected Object executeAction() { + final String destination = actionParameters.getString(KEY_DESTINATION); + final boolean isBlocked = actionParameters.getBoolean(KEY_BLOCKED); + String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + final DatabaseWrapper db = DataModel.get().getDatabase(); + BugleDatabaseOperations.updateDestination(db, destination, isBlocked); + if (conversationId == null) { + conversationId = BugleDatabaseOperations + .getConversationFromOtherParticipantDestination(db, destination); + } + if (conversationId != null) { + if (isBlocked) { + UpdateConversationArchiveStatusAction.archiveConversation(conversationId); + } else { + UpdateConversationArchiveStatusAction.unarchiveConversation(conversationId); + } + MessagingContentProvider.notifyParticipantsChanged(conversationId); + } + return null; + } + + protected UpdateDestinationBlockedAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public UpdateDestinationBlockedAction createFromParcel(final Parcel in) { + return new UpdateDestinationBlockedAction(in); + } + + @Override + public UpdateDestinationBlockedAction[] newArray(final int size) { + return new UpdateDestinationBlockedAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/UpdateMessageNotificationAction.java b/src/com/android/messaging/datamodel/action/UpdateMessageNotificationAction.java new file mode 100644 index 0000000..94e6f3b --- /dev/null +++ b/src/com/android/messaging/datamodel/action/UpdateMessageNotificationAction.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.action; + +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.datamodel.BugleNotifications; + +/** + * Updates the message notification (generally, to include voice replies we've + * made since the notification was first posted). + */ +public class UpdateMessageNotificationAction extends Action { + + public static void updateMessageNotification() { + new UpdateMessageNotificationAction().start(); + } + + private UpdateMessageNotificationAction() { + } + + @Override + protected Object executeAction() { + BugleNotifications.update(true /* silent */, BugleNotifications.UPDATE_MESSAGES); + return null; + } + + private UpdateMessageNotificationAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public UpdateMessageNotificationAction createFromParcel(final Parcel in) { + return new UpdateMessageNotificationAction(in); + } + + @Override + public UpdateMessageNotificationAction[] newArray(final int size) { + return new UpdateMessageNotificationAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/UpdateMessagePartSizeAction.java b/src/com/android/messaging/datamodel/action/UpdateMessagePartSizeAction.java new file mode 100644 index 0000000..273dce9 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/UpdateMessagePartSizeAction.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.ContentValues; +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.DatabaseHelper.PartColumns; +import com.android.messaging.util.Assert; + +/** + * Action used to update size fields of a single part + */ +public class UpdateMessagePartSizeAction extends Action implements Parcelable { + /** + * Update size of part + */ + public static void updateSize(final String partId, final int width, final int height) { + Assert.notNull(partId); + Assert.inRange(width, 0, Integer.MAX_VALUE); + Assert.inRange(height, 0, Integer.MAX_VALUE); + + final UpdateMessagePartSizeAction action = new UpdateMessagePartSizeAction( + partId, width, height); + action.start(); + } + + private static final String KEY_PART_ID = "part_id"; + private static final String KEY_WIDTH = "width"; + private static final String KEY_HEIGHT = "height"; + + private UpdateMessagePartSizeAction(final String partId, final int width, final int height) { + actionParameters.putString(KEY_PART_ID, partId); + actionParameters.putInt(KEY_WIDTH, width); + actionParameters.putInt(KEY_HEIGHT, height); + } + + @Override + protected Object executeAction() { + final String partId = actionParameters.getString(KEY_PART_ID); + final int width = actionParameters.getInt(KEY_WIDTH); + final int height = actionParameters.getInt(KEY_HEIGHT); + + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + try { + final ContentValues values = new ContentValues(); + + values.put(PartColumns.WIDTH, width); + values.put(PartColumns.HEIGHT, height); + + // Part may have been deleted so allow update to fail without asserting + BugleDatabaseOperations.updateRowIfExists(db, DatabaseHelper.PARTS_TABLE, + PartColumns._ID, partId, values); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + return null; + } + + private UpdateMessagePartSizeAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public UpdateMessagePartSizeAction createFromParcel(final Parcel in) { + return new UpdateMessagePartSizeAction(in); + } + + @Override + public UpdateMessagePartSizeAction[] newArray(final int size) { + return new UpdateMessagePartSizeAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/action/WriteDraftMessageAction.java b/src/com/android/messaging/datamodel/action/WriteDraftMessageAction.java new file mode 100644 index 0000000..c1f39e1 --- /dev/null +++ b/src/com/android/messaging/datamodel/action/WriteDraftMessageAction.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.util.LogUtil; + +public class WriteDraftMessageAction extends Action implements Parcelable { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + /** + * Set draft message (no listener) + */ + public static void writeDraftMessage(final String conversationId, final MessageData message) { + final WriteDraftMessageAction action = new WriteDraftMessageAction(conversationId, message); + action.start(); + } + + private static final String KEY_CONVERSATION_ID = "conversationId"; + private static final String KEY_MESSAGE = "message"; + + private WriteDraftMessageAction(final String conversationId, final MessageData message) { + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + actionParameters.putParcelable(KEY_MESSAGE, message); + } + + @Override + protected Object executeAction() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + final MessageData message = actionParameters.getParcelable(KEY_MESSAGE); + if (message.getSelfId() == null || message.getParticipantId() == null) { + // This could happen when this occurs before the draft message is loaded + // In this case, we just use the conversation's current self id as draft's + // self id and/or participant id + final ConversationListItemData conversation = + ConversationListItemData.getExistingConversation(db, conversationId); + if (conversation != null) { + final String senderAndSelf = conversation.getSelfId(); + if (message.getSelfId() == null) { + message.bindSelfId(senderAndSelf); + } + if (message.getParticipantId() == null) { + message.bindParticipantId(senderAndSelf); + } + } else { + LogUtil.w(LogUtil.BUGLE_DATAMODEL_TAG, "Conversation " + conversationId + + "already deleted before saving draft message " + + message.getMessageId() + ". Aborting WriteDraftMessageAction."); + return null; + } + } + // Drafts are only kept in the local DB... + final String messageId = BugleDatabaseOperations.updateDraftMessageData( + db, conversationId, message, BugleDatabaseOperations.UPDATE_MODE_ADD_DRAFT); + MessagingContentProvider.notifyConversationListChanged(); + MessagingContentProvider.notifyConversationMetadataChanged(conversationId); + return messageId; + } + + private WriteDraftMessageAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public WriteDraftMessageAction createFromParcel(final Parcel in) { + return new WriteDraftMessageAction(in); + } + + @Override + public WriteDraftMessageAction[] newArray(final int size) { + return new WriteDraftMessageAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/src/com/android/messaging/datamodel/binding/BindableData.java b/src/com/android/messaging/datamodel/binding/BindableData.java new file mode 100644 index 0000000..5446098 --- /dev/null +++ b/src/com/android/messaging/datamodel/binding/BindableData.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.binding; + +/** + * Base class for data objects that will be bound to a piece of the UI + */ +public abstract class BindableData { + /** + * Called by Binding during unbind to allow data to proactively unregister callbacks + * Data instance should release all listeners that may call back to the host UI + */ + protected abstract void unregisterListeners(); + + /** + * Key used to identify the piece of UI that the data is currently bound to + */ + private String mBindingId; + + /** + * Bind this data to the ui host - checks data is currently unbound + */ + public void bind(final String bindingId) { + if (isBound() || bindingId == null) { + throw new IllegalStateException(); + } + mBindingId = bindingId; + } + + /** + * Unbind this data from the ui host - checks that the data is currently bound to specified id + */ + public void unbind(final String bindingId) { + if (!isBound(bindingId)) { + throw new IllegalStateException(); + } + unregisterListeners(); + mBindingId = null; + } + + /** + * Check to see if the data is bound to anything + * + * TODO: This should be package private because it's supposed to only be used by Binding, + * however, several classes call this directly. We want the classes to track what they are + * bound to. + */ + protected boolean isBound() { + return (mBindingId != null); + } + + /** + * Check to see if data is still bound with specified bindingId before calling over to ui + */ + public boolean isBound(final String bindingId) { + return (bindingId.equals(mBindingId)); + } +} diff --git a/src/com/android/messaging/datamodel/binding/BindableOnceData.java b/src/com/android/messaging/datamodel/binding/BindableOnceData.java new file mode 100644 index 0000000..08e11da --- /dev/null +++ b/src/com/android/messaging/datamodel/binding/BindableOnceData.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.binding; + +/** + * A BindableData that's only used to be bound once. If the client needs to rebind, it needs + * to create a new instance of the BindableOnceData. + */ +public abstract class BindableOnceData extends BindableData { + private boolean boundOnce = false; + + @Override + public void bind(final String bindingId) { + // Ensures that we can't re-bind again after the first binding. + if (boundOnce) { + throw new IllegalStateException(); + } + super.bind(bindingId); + boundOnce = true; + } + + /** + * Checks if the instance is bound to anything. + */ + @Override + public boolean isBound() { + return super.isBound(); + } +} diff --git a/src/com/android/messaging/datamodel/binding/Binding.java b/src/com/android/messaging/datamodel/binding/Binding.java new file mode 100644 index 0000000..3ec01dd --- /dev/null +++ b/src/com/android/messaging/datamodel/binding/Binding.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.binding; + +import java.util.concurrent.atomic.AtomicLong; + +public class Binding extends BindingBase { + private static AtomicLong sBindingIdx = new AtomicLong(System.currentTimeMillis() * 1000); + + private String mBindingId; + private T mData; + private final Object mOwner; + private boolean mWasBound; + + /** + * Initialize a binding instance - the owner is typically the containing class + */ + Binding(final Object owner) { + mOwner = owner; + } + + @Override + public T getData() { + ensureBound(); + return mData; + } + + @Override + public boolean isBound() { + return (mData != null && mData.isBound(mBindingId)); + } + + @Override + public boolean isBound(final T data) { + return (isBound() && data == mData); + } + + @Override + public void ensureBound() { + if (!isBound()) { + throw new IllegalStateException("not bound; wasBound = " + mWasBound); + } + } + + @Override + public void ensureBound(final T data) { + if (!isBound()) { + throw new IllegalStateException("not bound; wasBound = " + mWasBound); + } else if (data != mData) { + throw new IllegalStateException("not bound to correct data " + data + " vs " + mData); + } + } + + @Override + public String getBindingId() { + return mBindingId; + } + + public void bind(final T data) { + // Check both this binding and the data not already bound + if (mData != null || data.isBound()) { + throw new IllegalStateException("already bound when binding to " + data); + } + // Generate a unique identifier for this bind call + mBindingId = Long.toHexString(sBindingIdx.getAndIncrement()); + data.bind(mBindingId); + mData = data; + mWasBound = true; + } + + public void unbind() { + // Check this binding is bound and that data is bound to this binding + if (mData == null || !mData.isBound(mBindingId)) { + throw new IllegalStateException("not bound when unbind"); + } + mData.unbind(mBindingId); + mData = null; + mBindingId = null; + } +} diff --git a/src/com/android/messaging/datamodel/binding/BindingBase.java b/src/com/android/messaging/datamodel/binding/BindingBase.java new file mode 100644 index 0000000..3d6da9b --- /dev/null +++ b/src/com/android/messaging/datamodel/binding/BindingBase.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.binding; + +/** + * The binding class keeps track of a binding between a ui component and an item of BindableData + * It allows each side to ensure that when it communicates with the other they are still bound + * together. + * NOTE: Ensure that the UI component uses the same binding instance for it's whole lifetime + * (DO NOT CREATE A NEW BINDING EACH TIME A NEW PIECE OF DATA IS BOUND)... + * + * The ui component owns the binding instance. + * It can use it [isBound(data)] to see if the binding still binds to the right piece of data + * + * Upon binding the data is informed of a unique binding key generated in this class and can use + * that to ensure that it is still issuing callbacks to the right piece of ui. + */ +public abstract class BindingBase { + /** + * Creates a new exclusively owned binding for the owner object. + */ + public static Binding createBinding(final Object owner) { + return new Binding(owner); + } + + /** + * Creates a new read-only binding referencing the source binding object. + * TODO: We may want to refcount the Binding references, so that when the binding owner + * calls unbind() when there's still outstanding references we can catch it. + */ + public static ImmutableBindingRef createBindingReference( + final BindingBase srcBinding) { + return new ImmutableBindingRef(srcBinding); + } + + /** + * Creates a detachable binding for the owner object. Use this if your owner object is a UI + * component that may undergo a "detached from window" -> "re-attached to window" transition. + */ + public static DetachableBinding createDetachableBinding( + final Object owner) { + return new DetachableBinding(owner); + } + + public abstract T getData(); + + /** + * Check if binding connects to the specified data instance + */ + public abstract boolean isBound(); + + /** + * Check if binding connects to the specified data instance + */ + public abstract boolean isBound(final T data); + + /** + * Throw if binding connects to the specified data instance + */ + public abstract void ensureBound(); + + /** + * Throw if binding connects to the specified data instance + */ + public abstract void ensureBound(final T data); + + /** + * Return the binding id for this binding (will be null if not bound) + */ + public abstract String getBindingId(); +} diff --git a/src/com/android/messaging/datamodel/binding/DetachableBinding.java b/src/com/android/messaging/datamodel/binding/DetachableBinding.java new file mode 100644 index 0000000..a414c3b --- /dev/null +++ b/src/com/android/messaging/datamodel/binding/DetachableBinding.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.binding; + +import com.android.messaging.util.Assert; + +/** + * An extension on {@link Binding} that allows for temporary data detachment from the UI component. + * This is used when, instead of destruction or data rebinding, the owning UI undergoes a + * "detached from window" -> "re-attached to window" transition, in which case we want to + * temporarily unbind the data and remember it so that it can be rebound when the UI is re-attached + * to window later. + */ +public class DetachableBinding extends Binding { + private T mDetachedData; + + DetachableBinding(Object owner) { + super(owner); + } + + @Override + public void bind(T data) { + super.bind(data); + // Rebinding before re-attaching. Pre-emptively throw away the detached data because + // it's now stale. + mDetachedData = null; + } + + public void detach() { + Assert.isNull(mDetachedData); + Assert.isTrue(isBound()); + mDetachedData = getData(); + unbind(); + } + + public void reAttachIfPossible() { + if (mDetachedData != null) { + Assert.isFalse(isBound()); + bind(mDetachedData); + mDetachedData = null; + } + } +} diff --git a/src/com/android/messaging/datamodel/binding/ImmutableBindingRef.java b/src/com/android/messaging/datamodel/binding/ImmutableBindingRef.java new file mode 100644 index 0000000..9a0a3d6 --- /dev/null +++ b/src/com/android/messaging/datamodel/binding/ImmutableBindingRef.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.binding; + +import com.android.messaging.util.Assert; + +/** + * A immutable wrapper around a Binding object. Callers can only access readonly methods like + * getData(), isBound() and ensureBound() but not bind() and unbind(). This is used for MVC pattern + * where both the View and the Controller needs access to a centrally bound Model object. The View + * is the one that owns the bind/unbind logic of the Binding, whereas controller only serves as a + * consumer. + */ +public class ImmutableBindingRef extends BindingBase { + /** + * The referenced, read-only binding object. + */ + private final BindingBase mBinding; + + /** + * Hidden ctor. + */ + ImmutableBindingRef(final BindingBase binding) { + mBinding = resolveBinding(binding); + } + + @Override + public T getData() { + return mBinding.getData(); + } + + @Override + public boolean isBound() { + return mBinding.isBound(); + } + + @Override + public boolean isBound(final T data) { + return mBinding.isBound(data); + } + + @Override + public void ensureBound() { + mBinding.ensureBound(); + } + + @Override + public void ensureBound(final T data) { + mBinding.ensureBound(data); + } + + @Override + public String getBindingId() { + return mBinding.getBindingId(); + } + + /** + * Resolve the source binding to the real BindingImpl it's referencing. This avoids the + * redundancy of multiple wrapper calls when creating a binding reference from an existing + * binding reference. + */ + private BindingBase resolveBinding(final BindingBase binding) { + BindingBase resolvedBinding = binding; + while (resolvedBinding instanceof ImmutableBindingRef) { + resolvedBinding = ((ImmutableBindingRef) resolvedBinding).mBinding; + } + Assert.isTrue(resolvedBinding instanceof Binding); + return resolvedBinding; + } +} diff --git a/src/com/android/messaging/datamodel/data/BlockedParticipantsData.java b/src/com/android/messaging/datamodel/data/BlockedParticipantsData.java new file mode 100644 index 0000000..4e94ee1 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/BlockedParticipantsData.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.data; + +import android.app.LoaderManager; +import android.content.Context; +import android.content.Loader; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; + +import com.android.messaging.datamodel.BoundCursorLoader; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.util.Assert; + +/** + * Services data needs for BlockedParticipantsFragment + */ +public class BlockedParticipantsData extends BindableData implements + LoaderManager.LoaderCallbacks { + public interface BlockedParticipantsDataListener { + public void onBlockedParticipantsCursorUpdated(final Cursor cursor); + } + private static final String BINDING_ID = "bindingId"; + private static final int BLOCKED_PARTICIPANTS_LOADER = 1; + private final Context mContext; + private LoaderManager mLoaderManager; + private BlockedParticipantsDataListener mListener; + + public BlockedParticipantsData(final Context context, + final BlockedParticipantsDataListener listener) { + mContext = context; + mListener = listener; + } + + @Override + public Loader onCreateLoader(final int id, final Bundle args) { + Assert.isTrue(id == BLOCKED_PARTICIPANTS_LOADER); + final String bindingId = args.getString(BINDING_ID); + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + final Uri uri = MessagingContentProvider.PARTICIPANTS_URI; + return new BoundCursorLoader(bindingId, mContext, uri, + ParticipantData.ParticipantsQuery.PROJECTION, + ParticipantColumns.BLOCKED + "=1", null, null); + } + return null; + } + + @Override + public void onLoadFinished(final Loader loader, final Cursor cursor) { + Assert.isTrue(loader.getId() == BLOCKED_PARTICIPANTS_LOADER); + final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; + Assert.isTrue(isBound(cursorLoader.getBindingId())); + mListener.onBlockedParticipantsCursorUpdated(cursor); + } + + @Override + public void onLoaderReset(final Loader loader) { + Assert.isTrue(loader.getId() == BLOCKED_PARTICIPANTS_LOADER); + final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; + Assert.isTrue(isBound(cursorLoader.getBindingId())); + mListener.onBlockedParticipantsCursorUpdated(null); + } + + public void init(final LoaderManager loaderManager, + final BindingBase binding) { + final Bundle args = new Bundle(); + args.putString(BINDING_ID, binding.getBindingId()); + mLoaderManager = loaderManager; + mLoaderManager.initLoader(BLOCKED_PARTICIPANTS_LOADER, args, this); + } + + @Override + protected void unregisterListeners() { + mListener = null; + if (mLoaderManager != null) { + mLoaderManager.destroyLoader(BLOCKED_PARTICIPANTS_LOADER); + mLoaderManager = null; + } + } + + public ParticipantListItemData createParticipantListItemData(Cursor cursor) { + return new ParticipantListItemData(ParticipantData.getFromCursor(cursor)); + } +} diff --git a/src/com/android/messaging/datamodel/data/ContactListItemData.java b/src/com/android/messaging/datamodel/data/ContactListItemData.java new file mode 100644 index 0000000..dcc7e20 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ContactListItemData.java @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.data; + +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract.DisplayNameSources; + +import com.android.ex.chips.RecipientEntry; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContactRecipientEntryUtils; +import com.android.messaging.util.ContactUtil; + +/** + * Data model object used to power ContactListItemViews, which may be displayed either in + * our contact list, or in the chips UI search drop down presented by ContactDropdownLayouter. + */ +public class ContactListItemData { + // Keeps the contact data in the form of RecipientEntry that RecipientEditTextView can + // directly use. + private RecipientEntry mRecipientEntry; + + private CharSequence mStyledName; + private CharSequence mStyledDestination; + + // If this contact is the first in the list for its first letter, then this will be the + // first letter, otherwise this is null. + private String mAlphabetHeader; + + // Is the contact the only item in the list (happens when the user clicks on an + // existing chip for which we show full contact detail for the selected contact). + private boolean mSingleRecipient; + + /** + * Bind to a contact cursor in the contact list. + */ + public void bind(final Cursor cursor, final String alphabetHeader) { + final long dataId = cursor.getLong(ContactUtil.INDEX_DATA_ID); + final long contactId = cursor.getLong(ContactUtil.INDEX_CONTACT_ID); + final String lookupKey = cursor.getString(ContactUtil.INDEX_LOOKUP_KEY); + final String displayName = cursor.getString(ContactUtil.INDEX_DISPLAY_NAME); + final String photoThumbnailUri = cursor.getString(ContactUtil.INDEX_PHOTO_URI); + final String destination = cursor.getString(ContactUtil.INDEX_PHONE_EMAIL); + final int destinationType = cursor.getInt(ContactUtil.INDEX_PHONE_EMAIL_TYPE); + final String destinationLabel = cursor.getString(ContactUtil.INDEX_PHONE_EMAIL_LABEL); + mStyledName = null; + mStyledDestination = null; + mAlphabetHeader = alphabetHeader; + mSingleRecipient = false; + + // Check whether this contact is first level (i.e. whether it's the first entry of this + // contact in the contact list). + boolean isFirstLevel = true; + if (!cursor.isFirst() && cursor.moveToPrevious()) { + final long contactIdPrevious = cursor.getLong(ContactUtil.INDEX_CONTACT_ID); + if (contactId == contactIdPrevious) { + isFirstLevel = false; + } + cursor.moveToNext(); + } + + mRecipientEntry = ContactUtil.createRecipientEntry(displayName, + DisplayNameSources.STRUCTURED_NAME, destination, destinationType, destinationLabel, + contactId, lookupKey, dataId, photoThumbnailUri, isFirstLevel); + } + + /** + * Bind to a RecipientEntry produced by the chips text view in the search drop down, plus + * optional styled name & destination for showing bold search match. + */ + public void bind(final RecipientEntry entry, final CharSequence styledName, + final CharSequence styledDestination, final boolean singleRecipient) { + Assert.isTrue(entry.isValid()); + mRecipientEntry = entry; + mStyledName = styledName; + mStyledDestination = styledDestination; + mAlphabetHeader = null; + mSingleRecipient = singleRecipient; + } + + public CharSequence getDisplayName() { + final CharSequence displayName = mStyledName != null ? mStyledName : + ContactRecipientEntryUtils.getDisplayNameForContactList(mRecipientEntry); + return displayName == null ? "" : displayName; + } + + public Uri getPhotoThumbnailUri() { + return mRecipientEntry.getPhotoThumbnailUri() == null ? null : + mRecipientEntry.getPhotoThumbnailUri(); + } + + public CharSequence getDestination() { + final CharSequence destination = mStyledDestination != null ? + mStyledDestination : ContactRecipientEntryUtils.formatDestination(mRecipientEntry); + return destination == null ? "" : destination; + } + + public int getDestinationType() { + return mRecipientEntry.getDestinationType(); + } + + public String getDestinationLabel() { + return mRecipientEntry.getDestinationLabel(); + } + + public long getContactId() { + return mRecipientEntry.getContactId(); + } + + public String getLookupKey() { + return mRecipientEntry.getLookupKey(); + } + + /** + * Returns if this item is "first-level," i.e. whether it's the first entry of the contact + * that it represents in the list. For example, if John Smith has 3 different phone numbers, + * then the first number is considered first-level, while the other two are considered + * second-level. + */ + public boolean getIsFirstLevel() { + // Treat the item as first level if it's a top-level recipient entry, or if it's the only + // item in the list. + return mRecipientEntry.isFirstLevel() || mSingleRecipient; + } + + /** + * Returns if this item is simple, i.e. it has only avatar and a display name with phone number + * embedded so we can hide everything else. + */ + public boolean getIsSimpleContactItem() { + return ContactRecipientEntryUtils.isAvatarAndNumberOnlyContact(mRecipientEntry) || + ContactRecipientEntryUtils.isSendToDestinationContact(mRecipientEntry); + } + + public String getAlphabetHeader() { + return mAlphabetHeader; + } + + /** + * Returns a RecipientEntry instance readily usable by the RecipientEditTextView. + */ + public RecipientEntry getRecipientEntry() { + return mRecipientEntry; + } +} diff --git a/src/com/android/messaging/datamodel/data/ContactPickerData.java b/src/com/android/messaging/datamodel/data/ContactPickerData.java new file mode 100644 index 0000000..fd6fca0 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ContactPickerData.java @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.data; + +import android.app.LoaderManager; +import android.content.Context; +import android.content.Loader; +import android.database.Cursor; +import android.os.Bundle; + +import com.android.messaging.datamodel.BoundCursorLoader; +import com.android.messaging.datamodel.FrequentContactsCursorBuilder; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.LogUtil; + +/** + * Class to access phone contacts. + * The caller is responsible for ensuring that the app has READ_CONTACTS permission (see + * {@link ContactUtil#hasReadContactsPermission()}) before instantiating this class. + */ +public class ContactPickerData extends BindableData implements + LoaderManager.LoaderCallbacks { + public interface ContactPickerDataListener { + void onAllContactsCursorUpdated(Cursor data); + void onFrequentContactsCursorUpdated(Cursor data); + void onContactCustomColorLoaded(ContactPickerData data); + } + + private static final String BINDING_ID = "bindingId"; + private final Context mContext; + private LoaderManager mLoaderManager; + private ContactPickerDataListener mListener; + private final FrequentContactsCursorBuilder mFrequentContactsCursorBuilder; + + public ContactPickerData(final Context context, final ContactPickerDataListener listener) { + mListener = listener; + mContext = context; + mFrequentContactsCursorBuilder = new FrequentContactsCursorBuilder(); + } + + private static final int ALL_CONTACTS_LOADER = 1; + private static final int FREQUENT_CONTACTS_LOADER = 2; + private static final int PARTICIPANT_LOADER = 3; + + @Override + public Loader onCreateLoader(final int id, final Bundle args) { + final String bindingId = args.getString(BINDING_ID); + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + switch (id) { + case ALL_CONTACTS_LOADER: + return ContactUtil.getPhones(mContext) + .createBoundCursorLoader(bindingId); + case FREQUENT_CONTACTS_LOADER: + return ContactUtil.getFrequentContacts(mContext) + .createBoundCursorLoader(bindingId); + case PARTICIPANT_LOADER: + return new BoundCursorLoader(bindingId, mContext, + MessagingContentProvider.PARTICIPANTS_URI, + ParticipantData.ParticipantsQuery.PROJECTION, null, null, null); + default: + Assert.fail("Unknown loader id for contact picker!"); + break; + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding the contacts list"); + } + return null; + } + + /** + * {@inheritDoc} + */ + @Override + public void onLoadFinished(final Loader loader, final Cursor data) { + final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; + if (isBound(cursorLoader.getBindingId())) { + switch (loader.getId()) { + case ALL_CONTACTS_LOADER: + mListener.onAllContactsCursorUpdated(data); + mFrequentContactsCursorBuilder.setAllContacts(data); + break; + case FREQUENT_CONTACTS_LOADER: + mFrequentContactsCursorBuilder.setFrequents(data); + break; + case PARTICIPANT_LOADER: + mListener.onContactCustomColorLoaded(this); + break; + default: + Assert.fail("Unknown loader id for contact picker!"); + break; + } + + if (loader.getId() != PARTICIPANT_LOADER) { + // The frequent contacts cursor to be used in the UI depends on results from both + // all contacts and frequent contacts loader, and we don't know which will finish + // first. Therefore, try to build the cursor and notify the listener if it's + // successfully built. + final Cursor frequentContactsCursor = mFrequentContactsCursorBuilder.build(); + if (frequentContactsCursor != null) { + mListener.onFrequentContactsCursorUpdated(frequentContactsCursor); + } + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Loader finished after unbinding the contacts list"); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void onLoaderReset(final Loader loader) { + final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; + if (isBound(cursorLoader.getBindingId())) { + switch (loader.getId()) { + case ALL_CONTACTS_LOADER: + mListener.onAllContactsCursorUpdated(null); + mFrequentContactsCursorBuilder.setAllContacts(null); + break; + case FREQUENT_CONTACTS_LOADER: + mListener.onFrequentContactsCursorUpdated(null); + mFrequentContactsCursorBuilder.setFrequents(null); + break; + case PARTICIPANT_LOADER: + mListener.onContactCustomColorLoaded(this); + break; + default: + Assert.fail("Unknown loader id for contact picker!"); + break; + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Loader reset after unbinding the contacts list"); + } + } + + public void init(final LoaderManager loaderManager, + final BindingBase binding) { + final Bundle args = new Bundle(); + args.putString(BINDING_ID, binding.getBindingId()); + mLoaderManager = loaderManager; + mLoaderManager.initLoader(ALL_CONTACTS_LOADER, args, this); + mLoaderManager.initLoader(FREQUENT_CONTACTS_LOADER, args, this); + mLoaderManager.initLoader(PARTICIPANT_LOADER, args, this); + } + + @Override + protected void unregisterListeners() { + mListener = null; + + + // This could be null if we bind but the caller doesn't init the BindableData + if (mLoaderManager != null) { + mLoaderManager.destroyLoader(ALL_CONTACTS_LOADER); + mLoaderManager.destroyLoader(FREQUENT_CONTACTS_LOADER); + mLoaderManager.destroyLoader(PARTICIPANT_LOADER); + mLoaderManager = null; + } + mFrequentContactsCursorBuilder.resetBuilder(); + } + + public static boolean isTooManyParticipants(final int participantCount) { + // When creating a conversation, the conversation will be created using the system's + // default SIM, so use the default MmsConfig's recipient limit. + return (participantCount > MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID) + .getRecipientLimit()); + } + + public static boolean getCanAddMoreParticipants(final int participantCount) { + // When creating a conversation, the conversation will be created using the system's + // default SIM, so use the default MmsConfig's recipient limit. + return (participantCount < MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID) + .getRecipientLimit()); + } +} diff --git a/src/com/android/messaging/datamodel/data/ConversationData.java b/src/com/android/messaging/datamodel/data/ConversationData.java new file mode 100644 index 0000000..d504928 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ConversationData.java @@ -0,0 +1,849 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.data; + +import android.app.LoaderManager; +import android.content.Context; +import android.content.Loader; +import android.database.Cursor; +import android.database.CursorWrapper; +import android.database.sqlite.SQLiteFullException; +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.text.TextUtils; + +import com.android.common.contacts.DataUsageStatUpdater; +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.BoundCursorLoader; +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.action.DeleteConversationAction; +import com.android.messaging.datamodel.action.DeleteMessageAction; +import com.android.messaging.datamodel.action.InsertNewMessageAction; +import com.android.messaging.datamodel.action.RedownloadMmsAction; +import com.android.messaging.datamodel.action.ResendMessageAction; +import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; +import com.android.messaging.sms.MmsSmsUtils; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.RunsOnMainThread; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.widget.WidgetConversationProvider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class ConversationData extends BindableData { + + private static final String TAG = "bugle_datamodel"; + private static final String BINDING_ID = "bindingId"; + private static final long LAST_MESSAGE_TIMESTAMP_NaN = -1; + private static final int MESSAGE_COUNT_NaN = -1; + + /** + * Takes a conversation id and a list of message ids and computes the positions + * for each message. + */ + public List getPositions(final String conversationId, final List ids) { + final ArrayList result = new ArrayList(); + + if (ids.isEmpty()) { + return result; + } + + final Cursor c = new ConversationData.ReversedCursor( + DataModel.get().getDatabase().rawQuery( + ConversationMessageData.getConversationMessageIdsQuerySql(), + new String [] { conversationId })); + if (c != null) { + try { + final Set idsSet = new HashSet(ids); + if (c.moveToLast()) { + do { + final long messageId = c.getLong(0); + if (idsSet.contains(messageId)) { + result.add(c.getPosition()); + } + } while (c.moveToPrevious()); + } + } finally { + c.close(); + } + } + Collections.sort(result); + return result; + } + + public interface ConversationDataListener { + public void onConversationMessagesCursorUpdated(ConversationData data, Cursor cursor, + @Nullable ConversationMessageData newestMessage, boolean isSync); + public void onConversationMetadataUpdated(ConversationData data); + public void closeConversation(String conversationId); + public void onConversationParticipantDataLoaded(ConversationData data); + public void onSubscriptionListDataLoaded(ConversationData data); + } + + private static class ReversedCursor extends CursorWrapper { + final int mCount; + + public ReversedCursor(final Cursor cursor) { + super(cursor); + mCount = cursor.getCount(); + } + + @Override + public boolean moveToPosition(final int position) { + return super.moveToPosition(mCount - position - 1); + } + + @Override + public int getPosition() { + return mCount - super.getPosition() - 1; + } + + @Override + public boolean isAfterLast() { + return super.isBeforeFirst(); + } + + @Override + public boolean isBeforeFirst() { + return super.isAfterLast(); + } + + @Override + public boolean isFirst() { + return super.isLast(); + } + + @Override + public boolean isLast() { + return super.isFirst(); + } + + @Override + public boolean move(final int offset) { + return super.move(-offset); + } + + @Override + public boolean moveToFirst() { + return super.moveToLast(); + } + + @Override + public boolean moveToLast() { + return super.moveToFirst(); + } + + @Override + public boolean moveToNext() { + return super.moveToPrevious(); + } + + @Override + public boolean moveToPrevious() { + return super.moveToNext(); + } + } + + /** + * A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times. + */ + private class MetadataLoaderCallbacks implements LoaderManager.LoaderCallbacks { + @Override + public Loader onCreateLoader(final int id, final Bundle args) { + Assert.equals(CONVERSATION_META_DATA_LOADER, id); + Loader loader = null; + + final String bindingId = args.getString(BINDING_ID); + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + final Uri uri = + MessagingContentProvider.buildConversationMetadataUri(mConversationId); + loader = new BoundCursorLoader(bindingId, mContext, uri, + ConversationListItemData.PROJECTION, null, null, null); + } else { + LogUtil.w(TAG, "Creating messages loader after unbinding mConversationId = " + + mConversationId); + } + return loader; + } + + @Override + public void onLoadFinished(final Loader generic, final Cursor data) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + if (data.moveToNext()) { + Assert.isTrue(data.getCount() == 1); + mConversationMetadata.bind(data); + mListeners.onConversationMetadataUpdated(ConversationData.this); + } else { + // Close the conversation, no meta data means conversation was deleted + LogUtil.w(TAG, "Meta data loader returned nothing for mConversationId = " + + mConversationId); + mListeners.closeConversation(mConversationId); + // Notify the widget the conversation is deleted so it can go into its + // configure state. + WidgetConversationProvider.notifyConversationDeleted( + Factory.get().getApplicationContext(), + mConversationId); + } + } else { + LogUtil.w(TAG, "Meta data loader finished after unbinding mConversationId = " + + mConversationId); + } + } + + @Override + public void onLoaderReset(final Loader generic) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + // Clear the conversation meta data + mConversationMetadata = new ConversationListItemData(); + mListeners.onConversationMetadataUpdated(ConversationData.this); + } else { + LogUtil.w(TAG, "Meta data loader reset after unbinding mConversationId = " + + mConversationId); + } + } + } + + /** + * A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times. + */ + private class MessagesLoaderCallbacks implements LoaderManager.LoaderCallbacks { + @Override + public Loader onCreateLoader(final int id, final Bundle args) { + Assert.equals(CONVERSATION_MESSAGES_LOADER, id); + Loader loader = null; + + final String bindingId = args.getString(BINDING_ID); + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + final Uri uri = + MessagingContentProvider.buildConversationMessagesUri(mConversationId); + loader = new BoundCursorLoader(bindingId, mContext, uri, + ConversationMessageData.getProjection(), null, null, null); + mLastMessageTimestamp = LAST_MESSAGE_TIMESTAMP_NaN; + mMessageCount = MESSAGE_COUNT_NaN; + } else { + LogUtil.w(TAG, "Creating messages loader after unbinding mConversationId = " + + mConversationId); + } + return loader; + } + + @Override + public void onLoadFinished(final Loader generic, final Cursor rawData) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + // Check if we have a new message, or if we had a message sync. + ConversationMessageData newMessage = null; + boolean isSync = false; + Cursor data = null; + if (rawData != null) { + // Note that the cursor is sorted DESC so here we reverse it. + // This is a performance issue (improvement) for large cursors. + data = new ReversedCursor(rawData); + + final int messageCountOld = mMessageCount; + mMessageCount = data.getCount(); + final ConversationMessageData lastMessage = getLastMessage(data); + if (lastMessage != null) { + final long lastMessageTimestampOld = mLastMessageTimestamp; + mLastMessageTimestamp = lastMessage.getReceivedTimeStamp(); + final String lastMessageIdOld = mLastMessageId; + mLastMessageId = lastMessage.getMessageId(); + if (TextUtils.equals(lastMessageIdOld, mLastMessageId) && + messageCountOld < mMessageCount) { + // Last message stays the same (no incoming message) but message + // count increased, which means there has been a message sync. + isSync = true; + } else if (messageCountOld != MESSAGE_COUNT_NaN && // Ignore initial load + mLastMessageTimestamp != LAST_MESSAGE_TIMESTAMP_NaN && + mLastMessageTimestamp > lastMessageTimestampOld) { + newMessage = lastMessage; + } + } else { + mLastMessageTimestamp = LAST_MESSAGE_TIMESTAMP_NaN; + } + } else { + mMessageCount = MESSAGE_COUNT_NaN; + } + + mListeners.onConversationMessagesCursorUpdated(ConversationData.this, data, + newMessage, isSync); + } else { + LogUtil.w(TAG, "Messages loader finished after unbinding mConversationId = " + + mConversationId); + } + } + + @Override + public void onLoaderReset(final Loader generic) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + mListeners.onConversationMessagesCursorUpdated(ConversationData.this, null, null, + false); + mLastMessageTimestamp = LAST_MESSAGE_TIMESTAMP_NaN; + mMessageCount = MESSAGE_COUNT_NaN; + } else { + LogUtil.w(TAG, "Messages loader reset after unbinding mConversationId = " + + mConversationId); + } + } + + private ConversationMessageData getLastMessage(final Cursor cursor) { + if (cursor != null && cursor.getCount() > 0) { + final int position = cursor.getPosition(); + if (cursor.moveToLast()) { + final ConversationMessageData messageData = new ConversationMessageData(); + messageData.bind(cursor); + cursor.move(position); + return messageData; + } + } + return null; + } + } + + /** + * A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times. + */ + private class ParticipantLoaderCallbacks implements LoaderManager.LoaderCallbacks { + @Override + public Loader onCreateLoader(final int id, final Bundle args) { + Assert.equals(PARTICIPANT_LOADER, id); + Loader loader = null; + + final String bindingId = args.getString(BINDING_ID); + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + final Uri uri = + MessagingContentProvider.buildConversationParticipantsUri(mConversationId); + loader = new BoundCursorLoader(bindingId, mContext, uri, + ParticipantData.ParticipantsQuery.PROJECTION, null, null, null); + } else { + LogUtil.w(TAG, "Creating participant loader after unbinding mConversationId = " + + mConversationId); + } + return loader; + } + + @Override + public void onLoadFinished(final Loader generic, final Cursor data) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + mParticipantData.bind(data); + mListeners.onConversationParticipantDataLoaded(ConversationData.this); + } else { + LogUtil.w(TAG, "Participant loader finished after unbinding mConversationId = " + + mConversationId); + } + } + + @Override + public void onLoaderReset(final Loader generic) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + mParticipantData.bind(null); + } else { + LogUtil.w(TAG, "Participant loader reset after unbinding mConversationId = " + + mConversationId); + } + } + } + + /** + * A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times. + */ + private class SelfParticipantLoaderCallbacks implements LoaderManager.LoaderCallbacks { + @Override + public Loader onCreateLoader(final int id, final Bundle args) { + Assert.equals(SELF_PARTICIPANT_LOADER, id); + Loader loader = null; + + final String bindingId = args.getString(BINDING_ID); + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + loader = new BoundCursorLoader(bindingId, mContext, + MessagingContentProvider.PARTICIPANTS_URI, + ParticipantData.ParticipantsQuery.PROJECTION, + ParticipantColumns.SUB_ID + " <> ?", + new String[] { String.valueOf(ParticipantData.OTHER_THAN_SELF_SUB_ID) }, + null); + } else { + LogUtil.w(TAG, "Creating self loader after unbinding mConversationId = " + + mConversationId); + } + return loader; + } + + @Override + public void onLoadFinished(final Loader generic, final Cursor data) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + mSelfParticipantsData.bind(data); + mSubscriptionListData.bind(mSelfParticipantsData.getSelfParticipants(true)); + mListeners.onSubscriptionListDataLoaded(ConversationData.this); + } else { + LogUtil.w(TAG, "Self loader finished after unbinding mConversationId = " + + mConversationId); + } + } + + @Override + public void onLoaderReset(final Loader generic) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + mSelfParticipantsData.bind(null); + } else { + LogUtil.w(TAG, "Self loader reset after unbinding mConversationId = " + + mConversationId); + } + } + } + + private final ConversationDataEventDispatcher mListeners; + private final MetadataLoaderCallbacks mMetadataLoaderCallbacks; + private final MessagesLoaderCallbacks mMessagesLoaderCallbacks; + private final ParticipantLoaderCallbacks mParticipantsLoaderCallbacks; + private final SelfParticipantLoaderCallbacks mSelfParticipantLoaderCallbacks; + private final Context mContext; + private final String mConversationId; + private final ConversationParticipantsData mParticipantData; + private final SelfParticipantsData mSelfParticipantsData; + private ConversationListItemData mConversationMetadata; + private final SubscriptionListData mSubscriptionListData; + private LoaderManager mLoaderManager; + private long mLastMessageTimestamp = LAST_MESSAGE_TIMESTAMP_NaN; + private int mMessageCount = MESSAGE_COUNT_NaN; + private String mLastMessageId; + + public ConversationData(final Context context, final ConversationDataListener listener, + final String conversationId) { + Assert.isTrue(conversationId != null); + mContext = context; + mConversationId = conversationId; + mMetadataLoaderCallbacks = new MetadataLoaderCallbacks(); + mMessagesLoaderCallbacks = new MessagesLoaderCallbacks(); + mParticipantsLoaderCallbacks = new ParticipantLoaderCallbacks(); + mSelfParticipantLoaderCallbacks = new SelfParticipantLoaderCallbacks(); + mParticipantData = new ConversationParticipantsData(); + mConversationMetadata = new ConversationListItemData(); + mSelfParticipantsData = new SelfParticipantsData(); + mSubscriptionListData = new SubscriptionListData(context); + + mListeners = new ConversationDataEventDispatcher(); + mListeners.add(listener); + } + + @RunsOnMainThread + public void addConversationDataListener(final ConversationDataListener listener) { + Assert.isMainThread(); + mListeners.add(listener); + } + + public String getConversationName() { + return mConversationMetadata.getName(); + } + + public boolean getIsArchived() { + return mConversationMetadata.getIsArchived(); + } + + public String getIcon() { + return mConversationMetadata.getIcon(); + } + + public String getConversationId() { + return mConversationId; + } + + public void setFocus() { + DataModel.get().setFocusedConversation(mConversationId); + // As we are loading the conversation assume the user has read the messages... + // Do this late though so that it doesn't get in the way of other actions + BugleNotifications.markMessagesAsRead(mConversationId); + } + + public void unsetFocus() { + DataModel.get().setFocusedConversation(null); + } + + public boolean isFocused() { + return isBound() && DataModel.get().isFocusedConversation(mConversationId); + } + + private static final int CONVERSATION_META_DATA_LOADER = 1; + private static final int CONVERSATION_MESSAGES_LOADER = 2; + private static final int PARTICIPANT_LOADER = 3; + private static final int SELF_PARTICIPANT_LOADER = 4; + + public void init(final LoaderManager loaderManager, + final BindingBase binding) { + // Remember the binding id so that loader callbacks can check if data is still bound + // to same ui component + final Bundle args = new Bundle(); + args.putString(BINDING_ID, binding.getBindingId()); + mLoaderManager = loaderManager; + mLoaderManager.initLoader(CONVERSATION_META_DATA_LOADER, args, mMetadataLoaderCallbacks); + mLoaderManager.initLoader(CONVERSATION_MESSAGES_LOADER, args, mMessagesLoaderCallbacks); + mLoaderManager.initLoader(PARTICIPANT_LOADER, args, mParticipantsLoaderCallbacks); + mLoaderManager.initLoader(SELF_PARTICIPANT_LOADER, args, mSelfParticipantLoaderCallbacks); + } + + @Override + protected void unregisterListeners() { + mListeners.clear(); + // Make sure focus has moved away from this conversation + // TODO: May false trigger if destroy happens after "new" conversation is focused. + // Assert.isTrue(!DataModel.get().isFocusedConversation(mConversationId)); + + // This could be null if we bind but the caller doesn't init the BindableData + if (mLoaderManager != null) { + mLoaderManager.destroyLoader(CONVERSATION_META_DATA_LOADER); + mLoaderManager.destroyLoader(CONVERSATION_MESSAGES_LOADER); + mLoaderManager.destroyLoader(PARTICIPANT_LOADER); + mLoaderManager.destroyLoader(SELF_PARTICIPANT_LOADER); + mLoaderManager = null; + } + } + + /** + * Gets the default self participant in the participant table (NOT the conversation's self). + * This is available as soon as self participant data is loaded. + */ + public ParticipantData getDefaultSelfParticipant() { + return mSelfParticipantsData.getDefaultSelfParticipant(); + } + + public List getSelfParticipants(final boolean activeOnly) { + return mSelfParticipantsData.getSelfParticipants(activeOnly); + } + + public int getSelfParticipantsCountExcludingDefault(final boolean activeOnly) { + return mSelfParticipantsData.getSelfParticipantsCountExcludingDefault(activeOnly); + } + + public ParticipantData getSelfParticipantById(final String selfId) { + return mSelfParticipantsData.getSelfParticipantById(selfId); + } + + /** + * For a 1:1 conversation return the other (not self) participant (else null) + */ + public ParticipantData getOtherParticipant() { + return mParticipantData.getOtherParticipant(); + } + + /** + * Return true once the participants are loaded + */ + public boolean getParticipantsLoaded() { + return mParticipantData.isLoaded(); + } + + public void sendMessage(final BindingBase binding, + final MessageData message) { + Assert.isTrue(TextUtils.equals(mConversationId, message.getConversationId())); + Assert.isTrue(binding.getData() == this); + + if (!OsUtil.isAtLeastL_MR1() || message.getSelfId() == null) { + InsertNewMessageAction.insertNewMessage(message); + } else { + final int systemDefaultSubId = PhoneUtils.getDefault().getDefaultSmsSubscriptionId(); + if (systemDefaultSubId != ParticipantData.DEFAULT_SELF_SUB_ID && + mSelfParticipantsData.isDefaultSelf(message.getSelfId())) { + // Lock the sub selection to the system default SIM as soon as the user clicks on + // the send button to avoid races between this and when InsertNewMessageAction is + // actually executed on the data model thread, during which the user can potentially + // change the system default SIM in Settings. + InsertNewMessageAction.insertNewMessage(message, systemDefaultSubId); + } else { + InsertNewMessageAction.insertNewMessage(message); + } + } + // Update contacts so Frequents will reflect messaging activity. + if (!getParticipantsLoaded()) { + return; // oh well, not critical + } + final ArrayList phones = new ArrayList<>(); + final ArrayList emails = new ArrayList<>(); + for (final ParticipantData participant : mParticipantData) { + if (!participant.isSelf()) { + if (participant.isEmail()) { + emails.add(participant.getSendDestination()); + } else { + phones.add(participant.getSendDestination()); + } + } + } + + if (ContactUtil.hasReadContactsPermission()) { + SafeAsyncTask.executeOnThreadPool(new Runnable() { + @Override + public void run() { + final DataUsageStatUpdater updater = new DataUsageStatUpdater( + Factory.get().getApplicationContext()); + try { + if (!phones.isEmpty()) { + updater.updateWithPhoneNumber(phones); + } + if (!emails.isEmpty()) { + updater.updateWithAddress(emails); + } + } catch (final SQLiteFullException ex) { + LogUtil.w(TAG, "Unable to update contact", ex); + } + } + }); + } + } + + public void downloadMessage(final BindingBase binding, + final String messageId) { + Assert.isTrue(binding.getData() == this); + Assert.notNull(messageId); + RedownloadMmsAction.redownloadMessage(messageId); + } + + public void resendMessage(final BindingBase binding, final String messageId) { + Assert.isTrue(binding.getData() == this); + Assert.notNull(messageId); + ResendMessageAction.resendMessage(messageId); + } + + public void deleteMessage(final BindingBase binding, final String messageId) { + Assert.isTrue(binding.getData() == this); + Assert.notNull(messageId); + DeleteMessageAction.deleteMessage(messageId); + } + + public void deleteConversation(final Binding binding) { + Assert.isTrue(binding.getData() == this); + // If possible use timestamp of last message shown to delete only messages user is aware of + if (mConversationMetadata == null) { + DeleteConversationAction.deleteConversation(mConversationId, + System.currentTimeMillis()); + } else { + mConversationMetadata.deleteConversation(); + } + } + + public void archiveConversation(final BindingBase binding) { + Assert.isTrue(binding.getData() == this); + UpdateConversationArchiveStatusAction.archiveConversation(mConversationId); + } + + public void unarchiveConversation(final BindingBase binding) { + Assert.isTrue(binding.getData() == this); + UpdateConversationArchiveStatusAction.unarchiveConversation(mConversationId); + } + + public ConversationParticipantsData getParticipants() { + return mParticipantData; + } + + /** + * Returns a dialable phone number for the participant if we are in a 1-1 conversation. + * @return the participant phone number, or null if the phone number is not valid or if there + * are more than one participant. + */ + public String getParticipantPhoneNumber() { + final ParticipantData participant = this.getOtherParticipant(); + if (participant != null) { + final String phoneNumber = participant.getSendDestination(); + if (!TextUtils.isEmpty(phoneNumber) && MmsSmsUtils.isPhoneNumber(phoneNumber)) { + return phoneNumber; + } + } + return null; + } + + /** + * Create a message to be forwarded from an existing message. + */ + public MessageData createForwardedMessage(final ConversationMessageData message) { + final MessageData forwardedMessage = new MessageData(); + + final String originalSubject = + MmsUtils.cleanseMmsSubject(mContext.getResources(), message.getMmsSubject()); + if (!TextUtils.isEmpty(originalSubject)) { + forwardedMessage.setMmsSubject( + mContext.getResources().getString(R.string.message_fwd, originalSubject)); + } + + for (final MessagePartData part : message.getParts()) { + MessagePartData forwardedPart; + + // Depending on the part type, if it is text, we can directly create a text part; + // if it is attachment, then we need to create a pending attachment data out of it, so + // that we may persist the attachment locally in the scratch folder when the user picks + // a conversation to forward to. + if (part.isText()) { + forwardedPart = MessagePartData.createTextMessagePart(part.getText()); + } else { + final PendingAttachmentData pendingAttachmentData = PendingAttachmentData + .createPendingAttachmentData(part.getContentType(), part.getContentUri()); + forwardedPart = pendingAttachmentData; + } + forwardedMessage.addPart(forwardedPart); + } + return forwardedMessage; + } + + public int getNumberOfParticipantsExcludingSelf() { + return mParticipantData.getNumberOfParticipantsExcludingSelf(); + } + + /** + * Returns {@link com.android.messaging.datamodel.data.SubscriptionListData + * .SubscriptionListEntry} for a given self participant so UI can display SIM-related info + * (icon, name etc.) for multi-SIM. + */ + public SubscriptionListEntry getSubscriptionEntryForSelfParticipant( + final String selfParticipantId, final boolean excludeDefault) { + return getSubscriptionEntryForSelfParticipant(selfParticipantId, excludeDefault, + mSubscriptionListData, mSelfParticipantsData); + } + + /** + * Returns {@link com.android.messaging.datamodel.data.SubscriptionListData + * .SubscriptionListEntry} for a given self participant so UI can display SIM-related info + * (icon, name etc.) for multi-SIM. + */ + public static SubscriptionListEntry getSubscriptionEntryForSelfParticipant( + final String selfParticipantId, final boolean excludeDefault, + final SubscriptionListData subscriptionListData, + final SelfParticipantsData selfParticipantsData) { + // SIM indicators are shown in the UI only if: + // 1. Framework has MSIM support AND + // 2. The device has had multiple *active* subscriptions. AND + // 3. The message's subscription is active. + if (OsUtil.isAtLeastL_MR1() && + selfParticipantsData.getSelfParticipantsCountExcludingDefault(true) > 1) { + return subscriptionListData.getActiveSubscriptionEntryBySelfId(selfParticipantId, + excludeDefault); + } + return null; + } + + public SubscriptionListData getSubscriptionListData() { + return mSubscriptionListData; + } + + /** + * A dummy implementation of {@link ConversationDataListener} so that subclasses may opt to + * implement some, but not all, of the interface methods. + */ + public static class SimpleConversationDataListener implements ConversationDataListener { + + @Override + public void onConversationMessagesCursorUpdated(final ConversationData data, final Cursor cursor, + @Nullable + final + ConversationMessageData newestMessage, final boolean isSync) {} + + @Override + public void onConversationMetadataUpdated(final ConversationData data) {} + + @Override + public void closeConversation(final String conversationId) {} + + @Override + public void onConversationParticipantDataLoaded(final ConversationData data) {} + + @Override + public void onSubscriptionListDataLoaded(final ConversationData data) {} + + } + + private class ConversationDataEventDispatcher + extends ArrayList + implements ConversationDataListener { + + @Override + public void onConversationMessagesCursorUpdated(final ConversationData data, final Cursor cursor, + @Nullable + final ConversationMessageData newestMessage, final boolean isSync) { + for (final ConversationDataListener listener : this) { + listener.onConversationMessagesCursorUpdated(data, cursor, newestMessage, isSync); + } + } + + @Override + public void onConversationMetadataUpdated(final ConversationData data) { + for (final ConversationDataListener listener : this) { + listener.onConversationMetadataUpdated(data); + } + } + + @Override + public void closeConversation(final String conversationId) { + for (final ConversationDataListener listener : this) { + listener.closeConversation(conversationId); + } + } + + @Override + public void onConversationParticipantDataLoaded(final ConversationData data) { + for (final ConversationDataListener listener : this) { + listener.onConversationParticipantDataLoaded(data); + } + } + + @Override + public void onSubscriptionListDataLoaded(final ConversationData data) { + for (final ConversationDataListener listener : this) { + listener.onSubscriptionListDataLoaded(data); + } + } + } +} diff --git a/src/com/android/messaging/datamodel/data/ConversationListData.java b/src/com/android/messaging/datamodel/data/ConversationListData.java new file mode 100644 index 0000000..3d27ecd --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ConversationListData.java @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.data; + +import android.app.LoaderManager; +import android.content.Context; +import android.content.Loader; +import android.database.Cursor; +import android.os.Bundle; + +import com.android.messaging.datamodel.BoundCursorLoader; +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.SyncManager; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.ConversationListItemData.ConversationListViewColumns; +import com.android.messaging.receiver.SmsReceiver; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +import java.util.HashSet; + +public class ConversationListData extends BindableData + implements LoaderManager.LoaderCallbacks { + + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + private static final String BINDING_ID = "bindingId"; + public static final String SORT_ORDER = + ConversationListViewColumns.SORT_TIMESTAMP + " DESC"; + + private static final String WHERE_ARCHIVED = + "(" + ConversationListViewColumns.ARCHIVE_STATUS + " = 1)"; + public static final String WHERE_NOT_ARCHIVED = + "(" + ConversationListViewColumns.ARCHIVE_STATUS + " = 0)"; + + public interface ConversationListDataListener { + public void onConversationListCursorUpdated(ConversationListData data, Cursor cursor); + public void setBlockedParticipantsAvailable(boolean blockedAvailable); + } + + private ConversationListDataListener mListener; + private final Context mContext; + private final boolean mArchivedMode; + private LoaderManager mLoaderManager; + + public ConversationListData(final Context context, final ConversationListDataListener listener, + final boolean archivedMode) { + mListener = listener; + mContext = context; + mArchivedMode = archivedMode; + } + + private static final int CONVERSATION_LIST_LOADER = 1; + private static final int BLOCKED_PARTICIPANTS_AVAILABLE_LOADER = 2; + + private static final String[] BLOCKED_PARTICIPANTS_PROJECTION = new String[] { + ParticipantColumns._ID, + ParticipantColumns.NORMALIZED_DESTINATION, + }; + private static final int INDEX_BLOCKED_PARTICIPANTS_ID = 0; + private static final int INDEX_BLOCKED_PARTICIPANTS_NORMALIZED_DESTINATION = 1; + + // all blocked participants + private final HashSet mBlockedParticipants = new HashSet(); + + @Override + public Loader onCreateLoader(final int id, final Bundle args) { + final String bindingId = args.getString(BINDING_ID); + Loader loader = null; + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + switch (id) { + case BLOCKED_PARTICIPANTS_AVAILABLE_LOADER: + loader = new BoundCursorLoader(bindingId, mContext, + MessagingContentProvider.PARTICIPANTS_URI, + BLOCKED_PARTICIPANTS_PROJECTION, + ParticipantColumns.BLOCKED + "=1", null, null); + break; + case CONVERSATION_LIST_LOADER: + loader = new BoundCursorLoader(bindingId, mContext, + MessagingContentProvider.CONVERSATIONS_URI, + ConversationListItemData.PROJECTION, + mArchivedMode ? WHERE_ARCHIVED : WHERE_NOT_ARCHIVED, + null, // selection args + SORT_ORDER); + break; + default: + Assert.fail("Unknown loader id"); + break; + } + } else { + LogUtil.w(TAG, "Creating loader after unbinding list"); + } + return loader; + } + + /** + * {@inheritDoc} + */ + @Override + public void onLoadFinished(final Loader generic, final Cursor data) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + if (isBound(loader.getBindingId())) { + switch (loader.getId()) { + case BLOCKED_PARTICIPANTS_AVAILABLE_LOADER: + mBlockedParticipants.clear(); + for (int i = 0; i < data.getCount(); i++) { + data.moveToPosition(i); + mBlockedParticipants.add(data.getString( + INDEX_BLOCKED_PARTICIPANTS_NORMALIZED_DESTINATION)); + } + mListener.setBlockedParticipantsAvailable(data != null && data.getCount() > 0); + break; + case CONVERSATION_LIST_LOADER: + mListener.onConversationListCursorUpdated(this, data); + break; + default: + Assert.fail("Unknown loader id"); + break; + } + } else { + LogUtil.w(TAG, "Loader finished after unbinding list"); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void onLoaderReset(final Loader generic) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + if (isBound(loader.getBindingId())) { + switch (loader.getId()) { + case BLOCKED_PARTICIPANTS_AVAILABLE_LOADER: + mListener.setBlockedParticipantsAvailable(false); + break; + case CONVERSATION_LIST_LOADER: + mListener.onConversationListCursorUpdated(this, null); + break; + default: + Assert.fail("Unknown loader id"); + break; + } + } else { + LogUtil.w(TAG, "Loader reset after unbinding list"); + } + } + + private Bundle mArgs; + + public void init(final LoaderManager loaderManager, + final BindingBase binding) { + mArgs = new Bundle(); + mArgs.putString(BINDING_ID, binding.getBindingId()); + mLoaderManager = loaderManager; + mLoaderManager.initLoader(CONVERSATION_LIST_LOADER, mArgs, this); + mLoaderManager.initLoader(BLOCKED_PARTICIPANTS_AVAILABLE_LOADER, mArgs, this); + } + + public void handleMessagesSeen() { + BugleNotifications.markAllMessagesAsSeen(); + + SmsReceiver.cancelSecondaryUserNotification(); + } + + @Override + protected void unregisterListeners() { + mListener = null; + + // This could be null if we bind but the caller doesn't init the BindableData + if (mLoaderManager != null) { + mLoaderManager.destroyLoader(CONVERSATION_LIST_LOADER); + mLoaderManager.destroyLoader(BLOCKED_PARTICIPANTS_AVAILABLE_LOADER); + mLoaderManager = null; + } + } + + public boolean getHasFirstSyncCompleted() { + final SyncManager syncManager = DataModel.get().getSyncManager(); + return syncManager.getHasFirstSyncCompleted(); + } + + public void setScrolledToNewestConversation(boolean scrolledToNewestConversation) { + DataModel.get().setConversationListScrolledToNewestConversation( + scrolledToNewestConversation); + if (scrolledToNewestConversation) { + handleMessagesSeen(); + } + } + + public HashSet getBlockedParticipants() { + return mBlockedParticipants; + } +} diff --git a/src/com/android/messaging/datamodel/data/ConversationListItemData.java b/src/com/android/messaging/datamodel/data/ConversationListItemData.java new file mode 100644 index 0000000..b2e6e1c --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ConversationListItemData.java @@ -0,0 +1,510 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.data; + +import android.database.Cursor; +import android.net.Uri; +import android.provider.BaseColumns; +import android.text.TextUtils; + +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.action.DeleteConversationAction; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Dates; +import com.google.common.base.Joiner; + +import java.util.ArrayList; +import java.util.List; + +/** + * Class wrapping the conversation list view used to display each item in conversation list + */ +public class ConversationListItemData { + private String mConversationId; + private String mName; + private String mIcon; + private boolean mIsRead; + private long mTimestamp; + private String mSnippetText; + private Uri mPreviewUri; + private String mPreviewContentType; + private long mParticipantContactId; + private String mParticipantLookupKey; + private String mOtherParticipantNormalizedDestination; + private String mSelfId; + private int mParticipantCount; + private boolean mNotificationEnabled; + private String mNotificationSoundUri; + private boolean mNotificationVibrate; + private boolean mIncludeEmailAddress; + private int mMessageStatus; + private int mMessageRawTelephonyStatus; + private boolean mShowDraft; + private Uri mDraftPreviewUri; + private String mDraftPreviewContentType; + private String mDraftSnippetText; + private boolean mIsArchived; + private String mSubject; + private String mDraftSubject; + private String mSnippetSenderFirstName; + private String mSnippetSenderDisplayDestination; + + public ConversationListItemData() { + } + + public void bind(final Cursor cursor) { + bind(cursor, false); + } + + public void bind(final Cursor cursor, final boolean ignoreDraft) { + mConversationId = cursor.getString(INDEX_ID); + mName = cursor.getString(INDEX_CONVERSATION_NAME); + mIcon = cursor.getString(INDEX_CONVERSATION_ICON); + mSnippetText = cursor.getString(INDEX_SNIPPET_TEXT); + mTimestamp = cursor.getLong(INDEX_SORT_TIMESTAMP); + mIsRead = cursor.getInt(INDEX_READ) == 1; + final String previewUriString = cursor.getString(INDEX_PREVIEW_URI); + mPreviewUri = TextUtils.isEmpty(previewUriString) ? null : Uri.parse(previewUriString); + mPreviewContentType = cursor.getString(INDEX_PREVIEW_CONTENT_TYPE); + mParticipantContactId = cursor.getLong(INDEX_PARTICIPANT_CONTACT_ID); + mParticipantLookupKey = cursor.getString(INDEX_PARTICIPANT_LOOKUP_KEY); + mOtherParticipantNormalizedDestination = cursor.getString( + INDEX_OTHER_PARTICIPANT_NORMALIZED_DESTINATION); + mSelfId = cursor.getString(INDEX_SELF_ID); + mParticipantCount = cursor.getInt(INDEX_PARTICIPANT_COUNT); + mNotificationEnabled = cursor.getInt(INDEX_NOTIFICATION_ENABLED) == 1; + mNotificationSoundUri = cursor.getString(INDEX_NOTIFICATION_SOUND_URI); + mNotificationVibrate = cursor.getInt(INDEX_NOTIFICATION_VIBRATION) == 1; + mIncludeEmailAddress = cursor.getInt(INDEX_INCLUDE_EMAIL_ADDRESS) == 1; + mMessageStatus = cursor.getInt(INDEX_MESSAGE_STATUS); + mMessageRawTelephonyStatus = cursor.getInt(INDEX_MESSAGE_RAW_TELEPHONY_STATUS); + if (!ignoreDraft) { + mShowDraft = cursor.getInt(INDEX_SHOW_DRAFT) == 1; + final String draftPreviewUriString = cursor.getString(INDEX_DRAFT_PREVIEW_URI); + mDraftPreviewUri = TextUtils.isEmpty(draftPreviewUriString) ? + null : Uri.parse(draftPreviewUriString); + mDraftPreviewContentType = cursor.getString(INDEX_DRAFT_PREVIEW_CONTENT_TYPE); + mDraftSnippetText = cursor.getString(INDEX_DRAFT_SNIPPET_TEXT); + mDraftSubject = cursor.getString(INDEX_DRAFT_SUBJECT_TEXT); + } else { + mShowDraft = false; + mDraftPreviewUri = null; + mDraftPreviewContentType = null; + mDraftSnippetText = null; + mDraftSubject = null; + } + + mIsArchived = cursor.getInt(INDEX_ARCHIVE_STATUS) == 1; + mSubject = cursor.getString(INDEX_SUBJECT_TEXT); + mSnippetSenderFirstName = cursor.getString(INDEX_SNIPPET_SENDER_FIRST_NAME); + mSnippetSenderDisplayDestination = + cursor.getString(INDEX_SNIPPET_SENDER_DISPLAY_DESTINATION); + } + + public String getConversationId() { + return mConversationId; + } + + public String getName() { + return mName; + } + + public String getIcon() { + return mIcon; + } + + public boolean getIsRead() { + return mIsRead; + } + + public String getFormattedTimestamp() { + return Dates.getConversationTimeString(mTimestamp).toString(); + } + + public long getTimestamp() { + return mTimestamp; + } + + public String getSnippetText() { + return mSnippetText; + } + + public Uri getPreviewUri() { + return mPreviewUri; + } + + public String getPreviewContentType() { + return mPreviewContentType; + } + + public long getParticipantContactId() { + return mParticipantContactId; + } + + public String getParticipantLookupKey() { + return mParticipantLookupKey; + } + + public String getOtherParticipantNormalizedDestination() { + return mOtherParticipantNormalizedDestination; + } + + public String getSelfId() { + return mSelfId; + } + + public int getParticipantCount() { + return mParticipantCount; + } + + public boolean getIsGroup() { + // Participant count excludes self + return (mParticipantCount > 1); + } + + public boolean getIncludeEmailAddress() { + return mIncludeEmailAddress; + } + + public boolean getNotificationEnabled() { + return mNotificationEnabled; + } + + public String getNotificationSoundUri() { + return mNotificationSoundUri; + } + + public boolean getNotifiationVibrate() { + return mNotificationVibrate; + } + + public final boolean getIsFailedStatus() { + return (mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_FAILED || + mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER || + mMessageStatus == MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED || + mMessageStatus == MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE); + } + + public final boolean getIsSendRequested() { + return (mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND || + mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY || + mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_SENDING || + mMessageStatus == MessageData.BUGLE_STATUS_OUTGOING_RESENDING); + } + + public boolean getIsMessageTypeOutgoing() { + return !MessageData.getIsIncoming(mMessageStatus); + } + + public int getMessageRawTelephonyStatus() { + return mMessageRawTelephonyStatus; + } + + public int getMessageStatus() { + return mMessageStatus; + } + + public boolean getShowDraft() { + return mShowDraft; + } + + public String getDraftSnippetText() { + return mDraftSnippetText; + } + + public Uri getDraftPreviewUri() { + return mDraftPreviewUri; + } + + public String getDraftPreviewContentType() { + return mDraftPreviewContentType; + } + + public boolean getIsArchived() { + return mIsArchived; + } + + public String getSubject() { + return mSubject; + } + + public String getDraftSubject() { + return mDraftSubject; + } + + public String getSnippetSenderName() { + if (!TextUtils.isEmpty(mSnippetSenderFirstName)) { + return mSnippetSenderFirstName; + } + return mSnippetSenderDisplayDestination; + } + + public void deleteConversation() { + DeleteConversationAction.deleteConversation(mConversationId, mTimestamp); + } + + /** + * Get the name of the view for this data item + */ + public static final String getConversationListView() { + return CONVERSATION_LIST_VIEW; + } + + public static final String getConversationListViewSql() { + return CONVERSATION_LIST_VIEW_SQL; + } + + private static final String CONVERSATION_LIST_VIEW = "conversation_list_view"; + + private static final String CONVERSATION_LIST_VIEW_PROJECTION = + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns._ID + + " as " + ConversationListViewColumns._ID + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.NAME + + " as " + ConversationListViewColumns.NAME + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.CURRENT_SELF_ID + + " as " + ConversationListViewColumns.CURRENT_SELF_ID + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.ARCHIVE_STATUS + + " as " + ConversationListViewColumns.ARCHIVE_STATUS + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.READ + + " as " + ConversationListViewColumns.READ + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.ICON + + " as " + ConversationListViewColumns.ICON + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PARTICIPANT_CONTACT_ID + + " as " + ConversationListViewColumns.PARTICIPANT_CONTACT_ID + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PARTICIPANT_LOOKUP_KEY + + " as " + ConversationListViewColumns.PARTICIPANT_LOOKUP_KEY + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + + ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION + + " as " + ConversationListViewColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.SORT_TIMESTAMP + + " as " + ConversationListViewColumns.SORT_TIMESTAMP + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.SHOW_DRAFT + + " as " + ConversationListViewColumns.SHOW_DRAFT + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.DRAFT_SNIPPET_TEXT + + " as " + ConversationListViewColumns.DRAFT_SNIPPET_TEXT + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.DRAFT_PREVIEW_URI + + " as " + ConversationListViewColumns.DRAFT_PREVIEW_URI + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.DRAFT_SUBJECT_TEXT + + " as " + ConversationListViewColumns.DRAFT_SUBJECT_TEXT + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + + ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE + + " as " + ConversationListViewColumns.DRAFT_PREVIEW_CONTENT_TYPE + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PREVIEW_URI + + " as " + ConversationListViewColumns.PREVIEW_URI + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PREVIEW_CONTENT_TYPE + + " as " + ConversationListViewColumns.PREVIEW_CONTENT_TYPE + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.PARTICIPANT_COUNT + + " as " + ConversationListViewColumns.PARTICIPANT_COUNT + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.NOTIFICATION_ENABLED + + " as " + ConversationListViewColumns.NOTIFICATION_ENABLED + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.NOTIFICATION_SOUND_URI + + " as " + ConversationListViewColumns.NOTIFICATION_SOUND_URI + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.NOTIFICATION_VIBRATION + + " as " + ConversationListViewColumns.NOTIFICATION_VIBRATION + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + + ConversationColumns.INCLUDE_EMAIL_ADDRESS + + " as " + ConversationListViewColumns.INCLUDE_EMAIL_ADDRESS + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.STATUS + + " as " + ConversationListViewColumns.MESSAGE_STATUS + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RAW_TELEPHONY_STATUS + + " as " + ConversationListViewColumns.MESSAGE_RAW_TELEPHONY_STATUS + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns._ID + + " as " + ConversationListViewColumns.MESSAGE_ID + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.FIRST_NAME + + " as " + ConversationListViewColumns.SNIPPET_SENDER_FIRST_NAME + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.DISPLAY_DESTINATION + + " as " + ConversationListViewColumns.SNIPPET_SENDER_DISPLAY_DESTINATION; + + private static final String JOIN_PARTICIPANTS = + " LEFT JOIN " + DatabaseHelper.PARTICIPANTS_TABLE + " ON (" + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENDER_PARTICIPANT_ID + + '=' + DatabaseHelper.PARTICIPANTS_TABLE + '.' + DatabaseHelper.ParticipantColumns._ID + + ") "; + + // View that makes latest message read flag available with rest of conversation data. + private static final String CONVERSATION_LIST_VIEW_SQL = "CREATE VIEW " + + CONVERSATION_LIST_VIEW + " AS SELECT " + + CONVERSATION_LIST_VIEW_PROJECTION + ", " + // Snippet not part of the base projection shared with search view + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.SNIPPET_TEXT + + " as " + ConversationListViewColumns.SNIPPET_TEXT + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.SUBJECT_TEXT + + " as " + ConversationListViewColumns.SUBJECT_TEXT + " " + + " FROM " + DatabaseHelper.CONVERSATIONS_TABLE + + " LEFT JOIN " + DatabaseHelper.MESSAGES_TABLE + " ON (" + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.LATEST_MESSAGE_ID + + '=' + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns._ID + ") " + + JOIN_PARTICIPANTS + + "ORDER BY " + DatabaseHelper.CONVERSATIONS_TABLE + '.' + + ConversationColumns.SORT_TIMESTAMP + " DESC"; + + public static class ConversationListViewColumns implements BaseColumns { + public static final String _ID = ConversationColumns._ID; + static final String NAME = ConversationColumns.NAME; + static final String ARCHIVE_STATUS = ConversationColumns.ARCHIVE_STATUS; + static final String READ = MessageColumns.READ; + static final String SORT_TIMESTAMP = ConversationColumns.SORT_TIMESTAMP; + static final String PREVIEW_URI = ConversationColumns.PREVIEW_URI; + static final String PREVIEW_CONTENT_TYPE = ConversationColumns.PREVIEW_CONTENT_TYPE; + static final String SNIPPET_TEXT = ConversationColumns.SNIPPET_TEXT; + static final String SUBJECT_TEXT = ConversationColumns.SUBJECT_TEXT; + static final String ICON = ConversationColumns.ICON; + static final String SHOW_DRAFT = ConversationColumns.SHOW_DRAFT; + static final String DRAFT_SUBJECT_TEXT = ConversationColumns.DRAFT_SUBJECT_TEXT; + static final String DRAFT_PREVIEW_URI = ConversationColumns.DRAFT_PREVIEW_URI; + static final String DRAFT_PREVIEW_CONTENT_TYPE = + ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE; + static final String DRAFT_SNIPPET_TEXT = ConversationColumns.DRAFT_SNIPPET_TEXT; + static final String PARTICIPANT_CONTACT_ID = ConversationColumns.PARTICIPANT_CONTACT_ID; + static final String PARTICIPANT_LOOKUP_KEY = ConversationColumns.PARTICIPANT_LOOKUP_KEY; + static final String OTHER_PARTICIPANT_NORMALIZED_DESTINATION = + ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION; + static final String CURRENT_SELF_ID = ConversationColumns.CURRENT_SELF_ID; + static final String PARTICIPANT_COUNT = ConversationColumns.PARTICIPANT_COUNT; + static final String NOTIFICATION_ENABLED = ConversationColumns.NOTIFICATION_ENABLED; + static final String NOTIFICATION_SOUND_URI = ConversationColumns.NOTIFICATION_SOUND_URI; + static final String NOTIFICATION_VIBRATION = ConversationColumns.NOTIFICATION_VIBRATION; + static final String INCLUDE_EMAIL_ADDRESS = + ConversationColumns.INCLUDE_EMAIL_ADDRESS; + static final String MESSAGE_STATUS = MessageColumns.STATUS; + static final String MESSAGE_RAW_TELEPHONY_STATUS = MessageColumns.RAW_TELEPHONY_STATUS; + static final String MESSAGE_ID = "message_id"; + static final String SNIPPET_SENDER_FIRST_NAME = "snippet_sender_first_name"; + static final String SNIPPET_SENDER_DISPLAY_DESTINATION = + "snippet_sender_display_destination"; + } + + public static final String[] PROJECTION = { + ConversationListViewColumns._ID, + ConversationListViewColumns.NAME, + ConversationListViewColumns.ICON, + ConversationListViewColumns.SNIPPET_TEXT, + ConversationListViewColumns.SORT_TIMESTAMP, + ConversationListViewColumns.READ, + ConversationListViewColumns.PREVIEW_URI, + ConversationListViewColumns.PREVIEW_CONTENT_TYPE, + ConversationListViewColumns.PARTICIPANT_CONTACT_ID, + ConversationListViewColumns.PARTICIPANT_LOOKUP_KEY, + ConversationListViewColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION, + ConversationListViewColumns.PARTICIPANT_COUNT, + ConversationListViewColumns.CURRENT_SELF_ID, + ConversationListViewColumns.NOTIFICATION_ENABLED, + ConversationListViewColumns.NOTIFICATION_SOUND_URI, + ConversationListViewColumns.NOTIFICATION_VIBRATION, + ConversationListViewColumns.INCLUDE_EMAIL_ADDRESS, + ConversationListViewColumns.MESSAGE_STATUS, + ConversationListViewColumns.SHOW_DRAFT, + ConversationListViewColumns.DRAFT_PREVIEW_URI, + ConversationListViewColumns.DRAFT_PREVIEW_CONTENT_TYPE, + ConversationListViewColumns.DRAFT_SNIPPET_TEXT, + ConversationListViewColumns.ARCHIVE_STATUS, + ConversationListViewColumns.MESSAGE_ID, + ConversationListViewColumns.SUBJECT_TEXT, + ConversationListViewColumns.DRAFT_SUBJECT_TEXT, + ConversationListViewColumns.MESSAGE_RAW_TELEPHONY_STATUS, + ConversationListViewColumns.SNIPPET_SENDER_FIRST_NAME, + ConversationListViewColumns.SNIPPET_SENDER_DISPLAY_DESTINATION, + }; + + private static final int INDEX_ID = 0; + private static final int INDEX_CONVERSATION_NAME = 1; + private static final int INDEX_CONVERSATION_ICON = 2; + private static final int INDEX_SNIPPET_TEXT = 3; + private static final int INDEX_SORT_TIMESTAMP = 4; + private static final int INDEX_READ = 5; + private static final int INDEX_PREVIEW_URI = 6; + private static final int INDEX_PREVIEW_CONTENT_TYPE = 7; + private static final int INDEX_PARTICIPANT_CONTACT_ID = 8; + private static final int INDEX_PARTICIPANT_LOOKUP_KEY = 9; + private static final int INDEX_OTHER_PARTICIPANT_NORMALIZED_DESTINATION = 10; + private static final int INDEX_PARTICIPANT_COUNT = 11; + private static final int INDEX_SELF_ID = 12; + private static final int INDEX_NOTIFICATION_ENABLED = 13; + private static final int INDEX_NOTIFICATION_SOUND_URI = 14; + private static final int INDEX_NOTIFICATION_VIBRATION = 15; + private static final int INDEX_INCLUDE_EMAIL_ADDRESS = 16; + private static final int INDEX_MESSAGE_STATUS = 17; + private static final int INDEX_SHOW_DRAFT = 18; + private static final int INDEX_DRAFT_PREVIEW_URI = 19; + private static final int INDEX_DRAFT_PREVIEW_CONTENT_TYPE = 20; + private static final int INDEX_DRAFT_SNIPPET_TEXT = 21; + private static final int INDEX_ARCHIVE_STATUS = 22; + private static final int INDEX_MESSAGE_ID = 23; + private static final int INDEX_SUBJECT_TEXT = 24; + private static final int INDEX_DRAFT_SUBJECT_TEXT = 25; + private static final int INDEX_MESSAGE_RAW_TELEPHONY_STATUS = 26; + private static final int INDEX_SNIPPET_SENDER_FIRST_NAME = 27; + private static final int INDEX_SNIPPET_SENDER_DISPLAY_DESTINATION = 28; + + private static final String DIVIDER_TEXT = ", "; + + /** + * Get a conversation from the local DB based on the conversation id. + * + * @param dbWrapper The database + * @param conversationId The conversation Id to read + * @return The existing conversation or null + */ + public static ConversationListItemData getExistingConversation(final DatabaseWrapper dbWrapper, + final String conversationId) { + ConversationListItemData conversation = null; + + // Look for an existing conversation in the db with this conversation id + Cursor cursor = null; + try { + // TODO: Should we be able to read a row from just the conversation table? + cursor = dbWrapper.query(getConversationListView(), + PROJECTION, + ConversationColumns._ID + "=?", + new String[] { conversationId }, + null, null, null); + Assert.inRange(cursor.getCount(), 0, 1); + if (cursor.moveToFirst()) { + conversation = new ConversationListItemData(); + conversation.bind(cursor); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return conversation; + } + + public static String generateConversationName(final List + participants) { + if (participants.size() == 1) { + // Prefer full name over first name for 1:1 conversation + return participants.get(0).getDisplayName(true); + } + + final ArrayList participantNames = new ArrayList(); + for (final ParticipantData participant : participants) { + // Prefer first name over full name for group conversation + participantNames.add(participant.getDisplayName(false)); + } + + final Joiner joiner = Joiner.on(DIVIDER_TEXT).skipNulls(); + return joiner.join(participantNames); + } + +} diff --git a/src/com/android/messaging/datamodel/data/ConversationMessageBubbleData.java b/src/com/android/messaging/datamodel/data/ConversationMessageBubbleData.java new file mode 100644 index 0000000..f329f46 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ConversationMessageBubbleData.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.data; + +import android.text.TextUtils; + +/** + * Holds data for conversation message bubble which keeps track of whether it's been bound to + * a new message. + */ +public class ConversationMessageBubbleData { + private String mMessageId; + + /** + * Binds to ConversationMessageData instance. + * @return true if we are binding to a different message, false if we are binding to the + * same message (e.g. in order to update the status text) + */ + public boolean bind(final ConversationMessageData data) { + final boolean changed = !TextUtils.equals(mMessageId, data.getMessageId()); + mMessageId = data.getMessageId(); + return changed; + } +} diff --git a/src/com/android/messaging/datamodel/data/ConversationMessageData.java b/src/com/android/messaging/datamodel/data/ConversationMessageData.java new file mode 100644 index 0000000..19e1b97 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ConversationMessageData.java @@ -0,0 +1,917 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.data; + +import android.database.Cursor; +import android.net.Uri; +import android.provider.BaseColumns; +import android.provider.ContactsContract; +import android.text.TextUtils; +import android.text.format.DateUtils; + +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseHelper.PartColumns; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.Dates; +import com.android.messaging.util.LogUtil; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Predicate; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +/** + * Class representing a message within a conversation sequence. The message parts + * are available via the getParts() method. + * + * TODO: See if we can delegate to MessageData for the logic that this class duplicates + * (e.g. getIsMms). + */ +public class ConversationMessageData { + private static final String TAG = LogUtil.BUGLE_TAG; + + private String mMessageId; + private String mConversationId; + private String mParticipantId; + private int mPartsCount; + private List mParts; + private long mSentTimestamp; + private long mReceivedTimestamp; + private boolean mSeen; + private boolean mRead; + private int mProtocol; + private int mStatus; + private String mSmsMessageUri; + private int mSmsPriority; + private int mSmsMessageSize; + private String mMmsSubject; + private long mMmsExpiry; + private int mRawTelephonyStatus; + private String mSenderFullName; + private String mSenderFirstName; + private String mSenderDisplayDestination; + private String mSenderNormalizedDestination; + private String mSenderProfilePhotoUri; + private long mSenderContactId; + private String mSenderContactLookupKey; + private String mSelfParticipantId; + + /** Are we similar enough to the previous/next messages that we can cluster them? */ + private boolean mCanClusterWithPreviousMessage; + private boolean mCanClusterWithNextMessage; + + public ConversationMessageData() { + } + + public void bind(final Cursor cursor) { + mMessageId = cursor.getString(INDEX_MESSAGE_ID); + mConversationId = cursor.getString(INDEX_CONVERSATION_ID); + mParticipantId = cursor.getString(INDEX_PARTICIPANT_ID); + mPartsCount = cursor.getInt(INDEX_PARTS_COUNT); + + mParts = makeParts( + cursor.getString(INDEX_PARTS_IDS), + cursor.getString(INDEX_PARTS_CONTENT_TYPES), + cursor.getString(INDEX_PARTS_CONTENT_URIS), + cursor.getString(INDEX_PARTS_WIDTHS), + cursor.getString(INDEX_PARTS_HEIGHTS), + cursor.getString(INDEX_PARTS_TEXTS), + mPartsCount, + mMessageId); + + mSentTimestamp = cursor.getLong(INDEX_SENT_TIMESTAMP); + mReceivedTimestamp = cursor.getLong(INDEX_RECEIVED_TIMESTAMP); + mSeen = (cursor.getInt(INDEX_SEEN) != 0); + mRead = (cursor.getInt(INDEX_READ) != 0); + mProtocol = cursor.getInt(INDEX_PROTOCOL); + mStatus = cursor.getInt(INDEX_STATUS); + mSmsMessageUri = cursor.getString(INDEX_SMS_MESSAGE_URI); + mSmsPriority = cursor.getInt(INDEX_SMS_PRIORITY); + mSmsMessageSize = cursor.getInt(INDEX_SMS_MESSAGE_SIZE); + mMmsSubject = cursor.getString(INDEX_MMS_SUBJECT); + mMmsExpiry = cursor.getLong(INDEX_MMS_EXPIRY); + mRawTelephonyStatus = cursor.getInt(INDEX_RAW_TELEPHONY_STATUS); + mSenderFullName = cursor.getString(INDEX_SENDER_FULL_NAME); + mSenderFirstName = cursor.getString(INDEX_SENDER_FIRST_NAME); + mSenderDisplayDestination = cursor.getString(INDEX_SENDER_DISPLAY_DESTINATION); + mSenderNormalizedDestination = cursor.getString(INDEX_SENDER_NORMALIZED_DESTINATION); + mSenderProfilePhotoUri = cursor.getString(INDEX_SENDER_PROFILE_PHOTO_URI); + mSenderContactId = cursor.getLong(INDEX_SENDER_CONTACT_ID); + mSenderContactLookupKey = cursor.getString(INDEX_SENDER_CONTACT_LOOKUP_KEY); + mSelfParticipantId = cursor.getString(INDEX_SELF_PARTICIPIANT_ID); + + if (!cursor.isFirst() && cursor.moveToPrevious()) { + mCanClusterWithPreviousMessage = canClusterWithMessage(cursor); + cursor.moveToNext(); + } else { + mCanClusterWithPreviousMessage = false; + } + if (!cursor.isLast() && cursor.moveToNext()) { + mCanClusterWithNextMessage = canClusterWithMessage(cursor); + cursor.moveToPrevious(); + } else { + mCanClusterWithNextMessage = false; + } + } + + private boolean canClusterWithMessage(final Cursor cursor) { + final String otherParticipantId = cursor.getString(INDEX_PARTICIPANT_ID); + if (!TextUtils.equals(getParticipantId(), otherParticipantId)) { + return false; + } + final int otherStatus = cursor.getInt(INDEX_STATUS); + final boolean otherIsIncoming = (otherStatus >= MessageData.BUGLE_STATUS_FIRST_INCOMING); + if (getIsIncoming() != otherIsIncoming) { + return false; + } + final long otherReceivedTimestamp = cursor.getLong(INDEX_RECEIVED_TIMESTAMP); + final long timestampDeltaMillis = Math.abs(mReceivedTimestamp - otherReceivedTimestamp); + if (timestampDeltaMillis > DateUtils.MINUTE_IN_MILLIS) { + return false; + } + final String otherSelfId = cursor.getString(INDEX_SELF_PARTICIPIANT_ID); + if (!TextUtils.equals(getSelfParticipantId(), otherSelfId)) { + return false; + } + return true; + } + + private static final Character QUOTE_CHAR = '\''; + private static final char DIVIDER = '|'; + + // statics to avoid unnecessary object allocation + private static final StringBuilder sUnquoteStringBuilder = new StringBuilder(); + private static final ArrayList sUnquoteResults = new ArrayList(); + + // this lock is used to guard access to the above statics + private static final Object sUnquoteLock = new Object(); + + private static void addResult(final ArrayList results, final StringBuilder value) { + if (value.length() > 0) { + results.add(value.toString()); + } else { + results.add(EMPTY_STRING); + } + } + + @VisibleForTesting + static String[] splitUnquotedString(final String inputString) { + if (TextUtils.isEmpty(inputString)) { + return new String[0]; + } + + return inputString.split("\\" + DIVIDER); + } + + /** + * Takes a group-concated and quoted string and decomposes it into its constituent + * parts. A quoted string starts and ends with a single quote. Actual single quotes + * within the string are escaped using a second single quote. So, for example, an + * input string with 3 constituent parts might look like this: + * + * 'now is the time'|'I can''t do it'|'foo' + * + * This would be returned as an array of 3 strings as follows: + * now is the time + * I can't do it + * foo + * + * This is achieved by walking through the inputString, character by character, + * ignoring the outer quotes and the divider and replacing any pair of consecutive + * single quotes with a single single quote. + * + * @param inputString + * @return array of constituent strings + */ + @VisibleForTesting + static String[] splitQuotedString(final String inputString) { + if (TextUtils.isEmpty(inputString)) { + return new String[0]; + } + + // this method can be called from multiple threads but it uses a static + // string builder + synchronized (sUnquoteLock) { + final int length = inputString.length(); + final ArrayList results = sUnquoteResults; + results.clear(); + + int characterPos = -1; + while (++characterPos < length) { + final char mustBeQuote = inputString.charAt(characterPos); + Assert.isTrue(QUOTE_CHAR == mustBeQuote); + while (++characterPos < length) { + final char currentChar = inputString.charAt(characterPos); + if (currentChar == QUOTE_CHAR) { + final char peekAhead = characterPos < length - 1 + ? inputString.charAt(characterPos + 1) : 0; + + if (peekAhead == QUOTE_CHAR) { + characterPos += 1; // skip the second quote + } else { + addResult(results, sUnquoteStringBuilder); + sUnquoteStringBuilder.setLength(0); + + Assert.isTrue((peekAhead == DIVIDER) || (peekAhead == (char) 0)); + characterPos += 1; // skip the divider + break; + } + } + sUnquoteStringBuilder.append(currentChar); + } + } + return results.toArray(new String[results.size()]); + } + } + + static MessagePartData makePartData( + final String partId, + final String contentType, + final String contentUriString, + final String contentWidth, + final String contentHeight, + final String text, + final String messageId) { + if (ContentType.isTextType(contentType)) { + final MessagePartData textPart = MessagePartData.createTextMessagePart(text); + textPart.updatePartId(partId); + textPart.updateMessageId(messageId); + return textPart; + } else { + final Uri contentUri = Uri.parse(contentUriString); + final int width = Integer.parseInt(contentWidth); + final int height = Integer.parseInt(contentHeight); + final MessagePartData attachmentPart = MessagePartData.createMediaMessagePart( + contentType, contentUri, width, height); + attachmentPart.updatePartId(partId); + attachmentPart.updateMessageId(messageId); + return attachmentPart; + } + } + + @VisibleForTesting + static List makeParts( + final String rawIds, + final String rawContentTypes, + final String rawContentUris, + final String rawWidths, + final String rawHeights, + final String rawTexts, + final int partsCount, + final String messageId) { + final List parts = new LinkedList(); + if (partsCount == 1) { + parts.add(makePartData( + rawIds, + rawContentTypes, + rawContentUris, + rawWidths, + rawHeights, + rawTexts, + messageId)); + } else { + unpackMessageParts( + parts, + splitUnquotedString(rawIds), + splitQuotedString(rawContentTypes), + splitQuotedString(rawContentUris), + splitUnquotedString(rawWidths), + splitUnquotedString(rawHeights), + splitQuotedString(rawTexts), + partsCount, + messageId); + } + return parts; + } + + @VisibleForTesting + static void unpackMessageParts( + final List parts, + final String[] ids, + final String[] contentTypes, + final String[] contentUris, + final String[] contentWidths, + final String[] contentHeights, + final String[] texts, + final int partsCount, + final String messageId) { + + Assert.equals(partsCount, ids.length); + Assert.equals(partsCount, contentTypes.length); + Assert.equals(partsCount, contentUris.length); + Assert.equals(partsCount, contentWidths.length); + Assert.equals(partsCount, contentHeights.length); + Assert.equals(partsCount, texts.length); + + for (int i = 0; i < partsCount; i++) { + parts.add(makePartData( + ids[i], + contentTypes[i], + contentUris[i], + contentWidths[i], + contentHeights[i], + texts[i], + messageId)); + } + + if (parts.size() != partsCount) { + LogUtil.wtf(TAG, "Only unpacked " + parts.size() + " parts from message (id=" + + messageId + "), expected " + partsCount + " parts"); + } + } + + public final String getMessageId() { + return mMessageId; + } + + public final String getConversationId() { + return mConversationId; + } + + public final String getParticipantId() { + return mParticipantId; + } + + public List getParts() { + return mParts; + } + + public boolean hasText() { + for (final MessagePartData part : mParts) { + if (part.isText()) { + return true; + } + } + return false; + } + + /** + * Get a concatenation of all text parts + * + * @return the text that is a concatenation of all text parts + */ + public String getText() { + // This is optimized for single text part case, which is the majority + + // For single text part, we just return the part without creating the StringBuilder + String firstTextPart = null; + boolean foundText = false; + // For multiple text parts, we need the StringBuilder and the separator for concatenation + StringBuilder sb = null; + String separator = null; + for (final MessagePartData part : mParts) { + if (part.isText()) { + if (!foundText) { + // First text part + firstTextPart = part.getText(); + foundText = true; + } else { + // Second and beyond + if (sb == null) { + // Need the StringBuilder and the separator starting from 2nd text part + sb = new StringBuilder(); + if (!TextUtils.isEmpty(firstTextPart)) { + sb.append(firstTextPart); + } + separator = BugleGservices.get().getString( + BugleGservicesKeys.MMS_TEXT_CONCAT_SEPARATOR, + BugleGservicesKeys.MMS_TEXT_CONCAT_SEPARATOR_DEFAULT); + } + final String partText = part.getText(); + if (!TextUtils.isEmpty(partText)) { + if (!TextUtils.isEmpty(separator) && sb.length() > 0) { + sb.append(separator); + } + sb.append(partText); + } + } + } + } + if (sb == null) { + // Only one text part + return firstTextPart; + } else { + // More than one + return sb.toString(); + } + } + + public boolean hasAttachments() { + for (final MessagePartData part : mParts) { + if (part.isAttachment()) { + return true; + } + } + return false; + } + + public List getAttachments() { + return getAttachments(null); + } + + public List getAttachments(final Predicate filter) { + if (mParts.isEmpty()) { + return Collections.emptyList(); + } + final List attachmentParts = new LinkedList<>(); + for (final MessagePartData part : mParts) { + if (part.isAttachment()) { + if (filter == null || filter.apply(part)) { + attachmentParts.add(part); + } + } + } + return attachmentParts; + } + + public final long getSentTimeStamp() { + return mSentTimestamp; + } + + public final long getReceivedTimeStamp() { + return mReceivedTimestamp; + } + + public final String getFormattedReceivedTimeStamp() { + return Dates.getMessageTimeString(mReceivedTimestamp).toString(); + } + + public final boolean getIsSeen() { + return mSeen; + } + + public final boolean getIsRead() { + return mRead; + } + + public final boolean getIsMms() { + return (mProtocol == MessageData.PROTOCOL_MMS || + mProtocol == MessageData.PROTOCOL_MMS_PUSH_NOTIFICATION); + } + + public final boolean getIsMmsNotification() { + return (mProtocol == MessageData.PROTOCOL_MMS_PUSH_NOTIFICATION); + } + + public final boolean getIsSms() { + return mProtocol == (MessageData.PROTOCOL_SMS); + } + + final int getProtocol() { + return mProtocol; + } + + public final int getStatus() { + return mStatus; + } + + public final String getSmsMessageUri() { + return mSmsMessageUri; + } + + public final int getSmsPriority() { + return mSmsPriority; + } + + public final int getSmsMessageSize() { + return mSmsMessageSize; + } + + public final String getMmsSubject() { + return mMmsSubject; + } + + public final long getMmsExpiry() { + return mMmsExpiry; + } + + public final int getRawTelephonyStatus() { + return mRawTelephonyStatus; + } + + public final String getSelfParticipantId() { + return mSelfParticipantId; + } + + public boolean getIsIncoming() { + return (mStatus >= MessageData.BUGLE_STATUS_FIRST_INCOMING); + } + + public boolean hasIncomingErrorStatus() { + return (mStatus == MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE || + mStatus == MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED); + } + + public boolean getIsSendComplete() { + return mStatus == MessageData.BUGLE_STATUS_OUTGOING_COMPLETE; + } + + public String getSenderFullName() { + return mSenderFullName; + } + + public String getSenderFirstName() { + return mSenderFirstName; + } + + public String getSenderDisplayDestination() { + return mSenderDisplayDestination; + } + + public String getSenderNormalizedDestination() { + return mSenderNormalizedDestination; + } + + public Uri getSenderProfilePhotoUri() { + return mSenderProfilePhotoUri == null ? null : Uri.parse(mSenderProfilePhotoUri); + } + + public long getSenderContactId() { + return mSenderContactId; + } + + public String getSenderDisplayName() { + if (!TextUtils.isEmpty(mSenderFullName)) { + return mSenderFullName; + } + if (!TextUtils.isEmpty(mSenderFirstName)) { + return mSenderFirstName; + } + return mSenderDisplayDestination; + } + + public String getSenderContactLookupKey() { + return mSenderContactLookupKey; + } + + public boolean getShowDownloadMessage() { + return MessageData.getShowDownloadMessage(mStatus); + } + + public boolean getShowResendMessage() { + return MessageData.getShowResendMessage(mStatus); + } + + public boolean getCanForwardMessage() { + // Even for outgoing messages, we only allow forwarding if the message has finished sending + // as media often has issues when send isn't complete + return (mStatus == MessageData.BUGLE_STATUS_OUTGOING_COMPLETE || + mStatus == MessageData.BUGLE_STATUS_INCOMING_COMPLETE); + } + + public boolean getCanCopyMessageToClipboard() { + return (hasText() && + (!getIsIncoming() || mStatus == MessageData.BUGLE_STATUS_INCOMING_COMPLETE)); + } + + public boolean getOneClickResendMessage() { + return MessageData.getOneClickResendMessage(mStatus, mRawTelephonyStatus); + } + + /** + * Get sender's lookup uri. + * This method doesn't support corp contacts. + * + * @return Lookup uri of sender's contact + */ + public Uri getSenderContactLookupUri() { + if (mSenderContactId > ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED + && !TextUtils.isEmpty(mSenderContactLookupKey)) { + return ContactsContract.Contacts.getLookupUri(mSenderContactId, + mSenderContactLookupKey); + } + return null; + } + + public boolean getCanClusterWithPreviousMessage() { + return mCanClusterWithPreviousMessage; + } + + public boolean getCanClusterWithNextMessage() { + return mCanClusterWithNextMessage; + } + + @Override + public String toString() { + return MessageData.toString(mMessageId, mParts); + } + + // Data definitions + + public static final String getConversationMessagesQuerySql() { + return CONVERSATION_MESSAGES_QUERY_SQL + + " AND " + // Inject the conversation id + + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns.CONVERSATION_ID + "=?)" + + CONVERSATION_MESSAGES_QUERY_SQL_GROUP_BY; + } + + static final String getConversationMessageIdsQuerySql() { + return CONVERSATION_MESSAGES_IDS_QUERY_SQL + + " AND " + // Inject the conversation id + + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns.CONVERSATION_ID + "=?)" + + CONVERSATION_MESSAGES_QUERY_SQL_GROUP_BY; + } + + public static final String getNotificationQuerySql() { + return CONVERSATION_MESSAGES_QUERY_SQL + + " AND " + + "(" + DatabaseHelper.MessageColumns.STATUS + " in (" + + MessageData.BUGLE_STATUS_INCOMING_COMPLETE + ", " + + MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD + ")" + + " AND " + + DatabaseHelper.MessageColumns.SEEN + " = 0)" + + ")" + + NOTIFICATION_QUERY_SQL_GROUP_BY; + } + + public static final String getWearableQuerySql() { + return CONVERSATION_MESSAGES_QUERY_SQL + + " AND " + + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns.CONVERSATION_ID + "=?" + + " AND " + + DatabaseHelper.MessageColumns.STATUS + " IN (" + + MessageData.BUGLE_STATUS_OUTGOING_DELIVERED + ", " + + MessageData.BUGLE_STATUS_OUTGOING_COMPLETE + ", " + + MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND + ", " + + MessageData.BUGLE_STATUS_OUTGOING_SENDING + ", " + + MessageData.BUGLE_STATUS_OUTGOING_RESENDING + ", " + + MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY + ", " + + MessageData.BUGLE_STATUS_INCOMING_COMPLETE + ", " + + MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD + ")" + + ")" + + NOTIFICATION_QUERY_SQL_GROUP_BY; + } + + /* + * Generate a sqlite snippet to call the quote function on the columnName argument. + * The columnName doesn't strictly have to be a column name (e.g. it could be an + * expression). + */ + private static String quote(final String columnName) { + return "quote(" + columnName + ")"; + } + + private static String makeGroupConcatString(final String column) { + return "group_concat(" + column + ", '" + DIVIDER + "')"; + } + + private static String makeIfNullString(final String column) { + return "ifnull(" + column + "," + "''" + ")"; + } + + private static String makePartsTableColumnString(final String column) { + return DatabaseHelper.PARTS_TABLE + '.' + column; + } + + private static String makeCaseWhenString(final String column, + final boolean quote, + final String asColumn) { + final String fullColumn = makeIfNullString(makePartsTableColumnString(column)); + final String groupConcatTerm = quote + ? makeGroupConcatString(quote(fullColumn)) + : makeGroupConcatString(fullColumn); + return "CASE WHEN (" + CONVERSATION_MESSAGE_VIEW_PARTS_COUNT + ">1) THEN " + groupConcatTerm + + " ELSE " + makePartsTableColumnString(column) + " END AS " + asColumn; + } + + private static final String CONVERSATION_MESSAGE_VIEW_PARTS_COUNT = + "count(" + DatabaseHelper.PARTS_TABLE + '.' + PartColumns._ID + ")"; + + private static final String EMPTY_STRING = ""; + + private static final String CONVERSATION_MESSAGES_QUERY_PROJECTION_SQL = + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns._ID + + " as " + ConversationMessageViewColumns._ID + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.CONVERSATION_ID + + " as " + ConversationMessageViewColumns.CONVERSATION_ID + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENDER_PARTICIPANT_ID + + " as " + ConversationMessageViewColumns.PARTICIPANT_ID + ", " + + + makeCaseWhenString(PartColumns._ID, false, + ConversationMessageViewColumns.PARTS_IDS) + ", " + + makeCaseWhenString(PartColumns.CONTENT_TYPE, true, + ConversationMessageViewColumns.PARTS_CONTENT_TYPES) + ", " + + makeCaseWhenString(PartColumns.CONTENT_URI, true, + ConversationMessageViewColumns.PARTS_CONTENT_URIS) + ", " + + makeCaseWhenString(PartColumns.WIDTH, false, + ConversationMessageViewColumns.PARTS_WIDTHS) + ", " + + makeCaseWhenString(PartColumns.HEIGHT, false, + ConversationMessageViewColumns.PARTS_HEIGHTS) + ", " + + makeCaseWhenString(PartColumns.TEXT, true, + ConversationMessageViewColumns.PARTS_TEXTS) + ", " + + + CONVERSATION_MESSAGE_VIEW_PARTS_COUNT + + " as " + ConversationMessageViewColumns.PARTS_COUNT + ", " + + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENT_TIMESTAMP + + " as " + ConversationMessageViewColumns.SENT_TIMESTAMP + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP + + " as " + ConversationMessageViewColumns.RECEIVED_TIMESTAMP + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SEEN + + " as " + ConversationMessageViewColumns.SEEN + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.READ + + " as " + ConversationMessageViewColumns.READ + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.PROTOCOL + + " as " + ConversationMessageViewColumns.PROTOCOL + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.STATUS + + " as " + ConversationMessageViewColumns.STATUS + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SMS_MESSAGE_URI + + " as " + ConversationMessageViewColumns.SMS_MESSAGE_URI + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SMS_PRIORITY + + " as " + ConversationMessageViewColumns.SMS_PRIORITY + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SMS_MESSAGE_SIZE + + " as " + ConversationMessageViewColumns.SMS_MESSAGE_SIZE + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.MMS_SUBJECT + + " as " + ConversationMessageViewColumns.MMS_SUBJECT + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.MMS_EXPIRY + + " as " + ConversationMessageViewColumns.MMS_EXPIRY + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RAW_TELEPHONY_STATUS + + " as " + ConversationMessageViewColumns.RAW_TELEPHONY_STATUS + ", " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SELF_PARTICIPANT_ID + + " as " + ConversationMessageViewColumns.SELF_PARTICIPANT_ID + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.FULL_NAME + + " as " + ConversationMessageViewColumns.SENDER_FULL_NAME + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.FIRST_NAME + + " as " + ConversationMessageViewColumns.SENDER_FIRST_NAME + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.DISPLAY_DESTINATION + + " as " + ConversationMessageViewColumns.SENDER_DISPLAY_DESTINATION + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.NORMALIZED_DESTINATION + + " as " + ConversationMessageViewColumns.SENDER_NORMALIZED_DESTINATION + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.PROFILE_PHOTO_URI + + " as " + ConversationMessageViewColumns.SENDER_PROFILE_PHOTO_URI + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.CONTACT_ID + + " as " + ConversationMessageViewColumns.SENDER_CONTACT_ID + ", " + + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns.LOOKUP_KEY + + " as " + ConversationMessageViewColumns.SENDER_CONTACT_LOOKUP_KEY + " "; + + private static final String CONVERSATION_MESSAGES_QUERY_FROM_WHERE_SQL = + " FROM " + DatabaseHelper.MESSAGES_TABLE + + " LEFT JOIN " + DatabaseHelper.PARTS_TABLE + + " ON (" + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns._ID + + "=" + DatabaseHelper.PARTS_TABLE + "." + PartColumns.MESSAGE_ID + ") " + + " LEFT JOIN " + DatabaseHelper.PARTICIPANTS_TABLE + + " ON (" + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.SENDER_PARTICIPANT_ID + + '=' + DatabaseHelper.PARTICIPANTS_TABLE + '.' + ParticipantColumns._ID + ")" + // Exclude draft messages from main view + + " WHERE (" + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns.STATUS + + " <> " + MessageData.BUGLE_STATUS_OUTGOING_DRAFT; + + // This query is mostly static, except for the injection of conversation id. This is for + // performance reasons, to ensure that the query uses indices and does not trigger full scans + // of the messages table. See b/17160946 for more details. + private static final String CONVERSATION_MESSAGES_QUERY_SQL = "SELECT " + + CONVERSATION_MESSAGES_QUERY_PROJECTION_SQL + + CONVERSATION_MESSAGES_QUERY_FROM_WHERE_SQL; + + private static final String CONVERSATION_MESSAGE_IDS_PROJECTION_SQL = + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns._ID + + " as " + ConversationMessageViewColumns._ID + " "; + + private static final String CONVERSATION_MESSAGES_IDS_QUERY_SQL = "SELECT " + + CONVERSATION_MESSAGE_IDS_PROJECTION_SQL + + CONVERSATION_MESSAGES_QUERY_FROM_WHERE_SQL; + + // Note that we sort DESC and ConversationData reverses the cursor. This is a performance + // issue (improvement) for large cursors. + private static final String CONVERSATION_MESSAGES_QUERY_SQL_GROUP_BY = + " GROUP BY " + DatabaseHelper.PARTS_TABLE + '.' + PartColumns.MESSAGE_ID + + " ORDER BY " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP + " DESC"; + + private static final String NOTIFICATION_QUERY_SQL_GROUP_BY = + " GROUP BY " + DatabaseHelper.PARTS_TABLE + '.' + PartColumns.MESSAGE_ID + + " ORDER BY " + + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.RECEIVED_TIMESTAMP + " DESC"; + + interface ConversationMessageViewColumns extends BaseColumns { + static final String _ID = MessageColumns._ID; + static final String CONVERSATION_ID = MessageColumns.CONVERSATION_ID; + static final String PARTICIPANT_ID = MessageColumns.SENDER_PARTICIPANT_ID; + static final String PARTS_COUNT = "parts_count"; + static final String SENT_TIMESTAMP = MessageColumns.SENT_TIMESTAMP; + static final String RECEIVED_TIMESTAMP = MessageColumns.RECEIVED_TIMESTAMP; + static final String SEEN = MessageColumns.SEEN; + static final String READ = MessageColumns.READ; + static final String PROTOCOL = MessageColumns.PROTOCOL; + static final String STATUS = MessageColumns.STATUS; + static final String SMS_MESSAGE_URI = MessageColumns.SMS_MESSAGE_URI; + static final String SMS_PRIORITY = MessageColumns.SMS_PRIORITY; + static final String SMS_MESSAGE_SIZE = MessageColumns.SMS_MESSAGE_SIZE; + static final String MMS_SUBJECT = MessageColumns.MMS_SUBJECT; + static final String MMS_EXPIRY = MessageColumns.MMS_EXPIRY; + static final String RAW_TELEPHONY_STATUS = MessageColumns.RAW_TELEPHONY_STATUS; + static final String SELF_PARTICIPANT_ID = MessageColumns.SELF_PARTICIPANT_ID; + static final String SENDER_FULL_NAME = ParticipantColumns.FULL_NAME; + static final String SENDER_FIRST_NAME = ParticipantColumns.FIRST_NAME; + static final String SENDER_DISPLAY_DESTINATION = ParticipantColumns.DISPLAY_DESTINATION; + static final String SENDER_NORMALIZED_DESTINATION = + ParticipantColumns.NORMALIZED_DESTINATION; + static final String SENDER_PROFILE_PHOTO_URI = ParticipantColumns.PROFILE_PHOTO_URI; + static final String SENDER_CONTACT_ID = ParticipantColumns.CONTACT_ID; + static final String SENDER_CONTACT_LOOKUP_KEY = ParticipantColumns.LOOKUP_KEY; + static final String PARTS_IDS = "parts_ids"; + static final String PARTS_CONTENT_TYPES = "parts_content_types"; + static final String PARTS_CONTENT_URIS = "parts_content_uris"; + static final String PARTS_WIDTHS = "parts_widths"; + static final String PARTS_HEIGHTS = "parts_heights"; + static final String PARTS_TEXTS = "parts_texts"; + } + + private static int sIndexIncrementer = 0; + + private static final int INDEX_MESSAGE_ID = sIndexIncrementer++; + private static final int INDEX_CONVERSATION_ID = sIndexIncrementer++; + private static final int INDEX_PARTICIPANT_ID = sIndexIncrementer++; + + private static final int INDEX_PARTS_IDS = sIndexIncrementer++; + private static final int INDEX_PARTS_CONTENT_TYPES = sIndexIncrementer++; + private static final int INDEX_PARTS_CONTENT_URIS = sIndexIncrementer++; + private static final int INDEX_PARTS_WIDTHS = sIndexIncrementer++; + private static final int INDEX_PARTS_HEIGHTS = sIndexIncrementer++; + private static final int INDEX_PARTS_TEXTS = sIndexIncrementer++; + + private static final int INDEX_PARTS_COUNT = sIndexIncrementer++; + + private static final int INDEX_SENT_TIMESTAMP = sIndexIncrementer++; + private static final int INDEX_RECEIVED_TIMESTAMP = sIndexIncrementer++; + private static final int INDEX_SEEN = sIndexIncrementer++; + private static final int INDEX_READ = sIndexIncrementer++; + private static final int INDEX_PROTOCOL = sIndexIncrementer++; + private static final int INDEX_STATUS = sIndexIncrementer++; + private static final int INDEX_SMS_MESSAGE_URI = sIndexIncrementer++; + private static final int INDEX_SMS_PRIORITY = sIndexIncrementer++; + private static final int INDEX_SMS_MESSAGE_SIZE = sIndexIncrementer++; + private static final int INDEX_MMS_SUBJECT = sIndexIncrementer++; + private static final int INDEX_MMS_EXPIRY = sIndexIncrementer++; + private static final int INDEX_RAW_TELEPHONY_STATUS = sIndexIncrementer++; + private static final int INDEX_SELF_PARTICIPIANT_ID = sIndexIncrementer++; + private static final int INDEX_SENDER_FULL_NAME = sIndexIncrementer++; + private static final int INDEX_SENDER_FIRST_NAME = sIndexIncrementer++; + private static final int INDEX_SENDER_DISPLAY_DESTINATION = sIndexIncrementer++; + private static final int INDEX_SENDER_NORMALIZED_DESTINATION = sIndexIncrementer++; + private static final int INDEX_SENDER_PROFILE_PHOTO_URI = sIndexIncrementer++; + private static final int INDEX_SENDER_CONTACT_ID = sIndexIncrementer++; + private static final int INDEX_SENDER_CONTACT_LOOKUP_KEY = sIndexIncrementer++; + + + private static String[] sProjection = { + ConversationMessageViewColumns._ID, + ConversationMessageViewColumns.CONVERSATION_ID, + ConversationMessageViewColumns.PARTICIPANT_ID, + + ConversationMessageViewColumns.PARTS_IDS, + ConversationMessageViewColumns.PARTS_CONTENT_TYPES, + ConversationMessageViewColumns.PARTS_CONTENT_URIS, + ConversationMessageViewColumns.PARTS_WIDTHS, + ConversationMessageViewColumns.PARTS_HEIGHTS, + ConversationMessageViewColumns.PARTS_TEXTS, + + ConversationMessageViewColumns.PARTS_COUNT, + ConversationMessageViewColumns.SENT_TIMESTAMP, + ConversationMessageViewColumns.RECEIVED_TIMESTAMP, + ConversationMessageViewColumns.SEEN, + ConversationMessageViewColumns.READ, + ConversationMessageViewColumns.PROTOCOL, + ConversationMessageViewColumns.STATUS, + ConversationMessageViewColumns.SMS_MESSAGE_URI, + ConversationMessageViewColumns.SMS_PRIORITY, + ConversationMessageViewColumns.SMS_MESSAGE_SIZE, + ConversationMessageViewColumns.MMS_SUBJECT, + ConversationMessageViewColumns.MMS_EXPIRY, + ConversationMessageViewColumns.RAW_TELEPHONY_STATUS, + ConversationMessageViewColumns.SELF_PARTICIPANT_ID, + ConversationMessageViewColumns.SENDER_FULL_NAME, + ConversationMessageViewColumns.SENDER_FIRST_NAME, + ConversationMessageViewColumns.SENDER_DISPLAY_DESTINATION, + ConversationMessageViewColumns.SENDER_NORMALIZED_DESTINATION, + ConversationMessageViewColumns.SENDER_PROFILE_PHOTO_URI, + ConversationMessageViewColumns.SENDER_CONTACT_ID, + ConversationMessageViewColumns.SENDER_CONTACT_LOOKUP_KEY, + }; + + public static String[] getProjection() { + return sProjection; + } +} diff --git a/src/com/android/messaging/datamodel/data/ConversationParticipantsData.java b/src/com/android/messaging/datamodel/data/ConversationParticipantsData.java new file mode 100644 index 0000000..0b5ef51 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ConversationParticipantsData.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.data; + +import android.database.Cursor; +import android.support.v4.util.SimpleArrayMap; + +import com.google.common.annotations.VisibleForTesting; + +import junit.framework.Assert; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.NoSuchElementException; + +/** + * A class that contains the list of all participants potentially involved in a conversation. + * Includes both the participant records for each participant referenced in conversation + * participants table (i.e. "other" phone numbers) plus all participants representing self + * (i.e. one per sim recorded in the subscription manager db). + */ +public class ConversationParticipantsData implements Iterable { + // A map from a participant id to a participant + private final SimpleArrayMap mConversationParticipantsMap; + private int mParticipantCountExcludingSelf = 0; + + public ConversationParticipantsData() { + mConversationParticipantsMap = new SimpleArrayMap(); + } + + public void bind(final Cursor cursor) { + mConversationParticipantsMap.clear(); + mParticipantCountExcludingSelf = 0; + if (cursor != null) { + while (cursor.moveToNext()) { + final ParticipantData newParticipant = ParticipantData.getFromCursor(cursor); + if (!newParticipant.isSelf()) { + mParticipantCountExcludingSelf++; + } + mConversationParticipantsMap.put(newParticipant.getId(), newParticipant); + } + } + } + + @VisibleForTesting + ParticipantData getParticipantById(final String participantId) { + return mConversationParticipantsMap.get(participantId); + } + + ArrayList getParticipantListExcludingSelf() { + final ArrayList retList = + new ArrayList(mConversationParticipantsMap.size()); + for (int i = 0; i < mConversationParticipantsMap.size(); i++) { + final ParticipantData participant = mConversationParticipantsMap.valueAt(i); + if (!participant.isSelf()) { + retList.add(participant); + } + } + return retList; + } + + /** + * For a 1:1 conversation return the other (not self) participant + */ + public ParticipantData getOtherParticipant() { + if (mParticipantCountExcludingSelf == 1) { + for (int i = 0; i < mConversationParticipantsMap.size(); i++) { + final ParticipantData participant = mConversationParticipantsMap.valueAt(i); + if (!participant.isSelf()) { + return participant; + } + } + Assert.fail(); + } + return null; + } + + public int getNumberOfParticipantsExcludingSelf() { + return mParticipantCountExcludingSelf; + } + + public boolean isLoaded() { + return !mConversationParticipantsMap.isEmpty(); + } + + @Override + public Iterator iterator() { + return new Iterator() { + private int mCurrentIndex = -1; + + @Override + public boolean hasNext() { + return mCurrentIndex < mConversationParticipantsMap.size() - 1; + } + + @Override + public ParticipantData next() { + mCurrentIndex++; + if (mCurrentIndex >= mConversationParticipantsMap.size()) { + throw new NoSuchElementException(); + } + return mConversationParticipantsMap.valueAt(mCurrentIndex); + } + + @Override + public void remove() { + throw new UnsupportedOperationException(); + } + }; + } +} diff --git a/src/com/android/messaging/datamodel/data/DraftMessageData.java b/src/com/android/messaging/datamodel/data/DraftMessageData.java new file mode 100644 index 0000000..7a7199a --- /dev/null +++ b/src/com/android/messaging/datamodel/data/DraftMessageData.java @@ -0,0 +1,855 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.data; + +import android.net.Uri; +import android.text.TextUtils; + +import com.android.messaging.datamodel.MessageTextStats; +import com.android.messaging.datamodel.action.ReadDraftDataAction; +import com.android.messaging.datamodel.action.ReadDraftDataAction.ReadDraftDataActionListener; +import com.android.messaging.datamodel.action.ReadDraftDataAction.ReadDraftDataActionMonitor; +import com.android.messaging.datamodel.action.WriteDraftMessageAction; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.sms.MmsSmsUtils; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.Assert.RunsOnMainThread; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.SafeAsyncTask; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Set; + +public class DraftMessageData extends BindableData implements ReadDraftDataActionListener { + + /** + * Interface for DraftMessageData listeners + */ + public interface DraftMessageDataListener { + @RunsOnMainThread + void onDraftChanged(DraftMessageData data, int changeFlags); + + @RunsOnMainThread + void onDraftAttachmentLimitReached(DraftMessageData data); + + @RunsOnMainThread + void onDraftAttachmentLoadFailed(); + } + + /** + * Interface for providing subscription-related data to DraftMessageData + */ + public interface DraftMessageSubscriptionDataProvider { + int getConversationSelfSubId(); + } + + // Flags sent to onDraftChanged to help the receiver limit the amount of work done + public static int ATTACHMENTS_CHANGED = 0x0001; + public static int MESSAGE_TEXT_CHANGED = 0x0002; + public static int MESSAGE_SUBJECT_CHANGED = 0x0004; + // Whether the self participant data has been loaded + public static int SELF_CHANGED = 0x0008; + public static int ALL_CHANGED = 0x00FF; + // ALL_CHANGED intentionally doesn't include WIDGET_CHANGED. ConversationFragment needs to + // be notified if the draft it is looking at is changed externally (by a desktop widget) so it + // can reload the draft. + public static int WIDGET_CHANGED = 0x0100; + + private final String mConversationId; + private ReadDraftDataActionMonitor mMonitor; + private final DraftMessageDataEventDispatcher mListeners; + private DraftMessageSubscriptionDataProvider mSubscriptionDataProvider; + + private boolean mIncludeEmailAddress; + private boolean mIsGroupConversation; + private String mMessageText; + private String mMessageSubject; + private String mSelfId; + private MessageTextStats mMessageTextStats; + private boolean mSending; + + /** Keeps track of completed attachments in the message draft. This data is persisted to db */ + private final List mAttachments; + + /** A read-only wrapper on mAttachments surfaced to the UI layer for rendering */ + private final List mReadOnlyAttachments; + + /** Keeps track of pending attachments that are being loaded. The pending attachments are + * transient, because they are not persisted to the database and are dropped once we go + * to the background (after the UI calls saveToStorage) */ + private final List mPendingAttachments; + + /** A read-only wrapper on mPendingAttachments surfaced to the UI layer for rendering */ + private final List mReadOnlyPendingAttachments; + + /** Is the current draft a cached copy of what's been saved to the database. If so, we + * may skip loading from database if we are still bound */ + private boolean mIsDraftCachedCopy; + + /** Whether we are currently asynchronously validating the draft before sending. */ + private CheckDraftForSendTask mCheckDraftForSendTask; + + public DraftMessageData(final String conversationId) { + mConversationId = conversationId; + mAttachments = new ArrayList(); + mReadOnlyAttachments = Collections.unmodifiableList(mAttachments); + mPendingAttachments = new ArrayList(); + mReadOnlyPendingAttachments = Collections.unmodifiableList(mPendingAttachments); + mListeners = new DraftMessageDataEventDispatcher(); + mMessageTextStats = new MessageTextStats(); + } + + public void addListener(final DraftMessageDataListener listener) { + mListeners.add(listener); + } + + public void setSubscriptionDataProvider(final DraftMessageSubscriptionDataProvider provider) { + mSubscriptionDataProvider = provider; + } + + public void updateFromMessageData(final MessageData message, final String bindingId) { + // New attachments have arrived - only update if the user hasn't already edited + Assert.notNull(bindingId); + // The draft is now synced with actual MessageData and no longer a cached copy. + mIsDraftCachedCopy = false; + // Do not use the loaded draft if the user began composing a message before the draft loaded + // During config changes (orientation), the text fields preserve their data, so allow them + // to be the same and still consider the draft unchanged by the user + if (isDraftEmpty() || (TextUtils.equals(mMessageText, message.getMessageText()) && + TextUtils.equals(mMessageSubject, message.getMmsSubject()) && + mAttachments.isEmpty())) { + // No need to clear as just checked it was empty or a subset + setMessageText(message.getMessageText(), false /* notify */); + setMessageSubject(message.getMmsSubject(), false /* notify */); + for (final MessagePartData part : message.getParts()) { + if (part.isAttachment() && getAttachmentCount() >= getAttachmentLimit()) { + dispatchAttachmentLimitReached(); + break; + } + + if (part instanceof PendingAttachmentData) { + // This is a pending attachment data from share intent (e.g. an shared image + // that we need to persist locally). + final PendingAttachmentData data = (PendingAttachmentData) part; + Assert.equals(PendingAttachmentData.STATE_PENDING, data.getCurrentState()); + addOnePendingAttachmentNoNotify(data, bindingId); + } else if (part.isAttachment()) { + addOneAttachmentNoNotify(part); + } + } + dispatchChanged(ALL_CHANGED); + } else { + // The user has started a new message so we throw out the draft message data if there + // is one but we also loaded the self metadata and need to let our listeners know. + dispatchChanged(SELF_CHANGED); + } + } + + /** + * Create a MessageData object containing a copy of all the parts in this DraftMessageData. + * + * @param clearLocalCopy whether we should clear out the in-memory copy of the parts. If we + * are simply pausing/resuming and not sending the message, then we can keep + * @return the MessageData for the draft, null if self id is not set + */ + public MessageData createMessageWithCurrentAttachments(final boolean clearLocalCopy) { + MessageData message = null; + if (getIsMms()) { + message = MessageData.createDraftMmsMessage(mConversationId, mSelfId, + mMessageText, mMessageSubject); + for (final MessagePartData attachment : mAttachments) { + message.addPart(attachment); + } + } else { + message = MessageData.createDraftSmsMessage(mConversationId, mSelfId, + mMessageText); + } + + if (clearLocalCopy) { + // The message now owns all the attachments and the text... + clearLocalDraftCopy(); + dispatchChanged(ALL_CHANGED); + } else { + // The draft message becomes a cached copy for UI. + mIsDraftCachedCopy = true; + } + return message; + } + + private void clearLocalDraftCopy() { + mIsDraftCachedCopy = false; + mAttachments.clear(); + setMessageText(""); + setMessageSubject(""); + } + + public String getConversationId() { + return mConversationId; + } + + public String getMessageText() { + return mMessageText; + } + + public String getMessageSubject() { + return mMessageSubject; + } + + public boolean getIsMms() { + final int selfSubId = getSelfSubId(); + return MmsSmsUtils.getRequireMmsForEmailAddress(mIncludeEmailAddress, selfSubId) || + (mIsGroupConversation && MmsUtils.groupMmsEnabled(selfSubId)) || + mMessageTextStats.getMessageLengthRequiresMms() || !mAttachments.isEmpty() || + !TextUtils.isEmpty(mMessageSubject); + } + + public boolean getIsGroupMmsConversation() { + return getIsMms() && mIsGroupConversation; + } + + public String getSelfId() { + return mSelfId; + } + + public int getNumMessagesToBeSent() { + return mMessageTextStats.getNumMessagesToBeSent(); + } + + public int getCodePointsRemainingInCurrentMessage() { + return mMessageTextStats.getCodePointsRemainingInCurrentMessage(); + } + + public int getSelfSubId() { + return mSubscriptionDataProvider == null ? ParticipantData.DEFAULT_SELF_SUB_ID : + mSubscriptionDataProvider.getConversationSelfSubId(); + } + + private void setMessageText(final String messageText, final boolean notify) { + mMessageText = messageText; + mMessageTextStats.updateMessageTextStats(getSelfSubId(), mMessageText); + if (notify) { + dispatchChanged(MESSAGE_TEXT_CHANGED); + } + } + + private void setMessageSubject(final String subject, final boolean notify) { + mMessageSubject = subject; + if (notify) { + dispatchChanged(MESSAGE_SUBJECT_CHANGED); + } + } + + public void setMessageText(final String messageText) { + setMessageText(messageText, false); + } + + public void setMessageSubject(final String subject) { + setMessageSubject(subject, false); + } + + public void addAttachments(final Collection attachments) { + // If the incoming attachments contains a single-only attachment, we need to clear + // the existing attachments. + for (final MessagePartData data : attachments) { + if (data.isSinglePartOnly()) { + // clear any existing attachments because the attachment we're adding can only + // exist by itself. + destroyAttachments(); + break; + } + } + // If the existing attachments contain a single-only attachment, we need to clear the + // existing attachments to make room for the incoming attachment. + for (final MessagePartData data : mAttachments) { + if (data.isSinglePartOnly()) { + // clear any existing attachments because the single attachment can only exist + // by itself + destroyAttachments(); + break; + } + } + // If any of the pending attachments contain a single-only attachment, we need to clear the + // existing attachments to make room for the incoming attachment. + for (final MessagePartData data : mPendingAttachments) { + if (data.isSinglePartOnly()) { + // clear any existing attachments because the single attachment can only exist + // by itself + destroyAttachments(); + break; + } + } + + boolean reachedLimit = false; + for (final MessagePartData data : attachments) { + // Don't break out of loop even if limit has been reached so we can destroy all + // of the over-limit attachments. + reachedLimit |= addOneAttachmentNoNotify(data); + } + if (reachedLimit) { + dispatchAttachmentLimitReached(); + } + dispatchChanged(ATTACHMENTS_CHANGED); + } + + public boolean containsAttachment(final Uri contentUri) { + for (final MessagePartData existingAttachment : mAttachments) { + if (existingAttachment.getContentUri().equals(contentUri)) { + return true; + } + } + + for (final PendingAttachmentData pendingAttachment : mPendingAttachments) { + if (pendingAttachment.getContentUri().equals(contentUri)) { + return true; + } + } + return false; + } + + /** + * Try to add one attachment to the attachment list, while guarding against duplicates and + * going over the limit. + * @return true if the attachment limit was reached, false otherwise + */ + private boolean addOneAttachmentNoNotify(final MessagePartData attachment) { + Assert.isTrue(attachment.isAttachment()); + final boolean reachedLimit = getAttachmentCount() >= getAttachmentLimit(); + if (reachedLimit || containsAttachment(attachment.getContentUri())) { + // Never go over the limit. Never add duplicated attachments. + attachment.destroyAsync(); + return reachedLimit; + } else { + addAttachment(attachment, null /*pendingAttachment*/); + return false; + } + } + + private void addAttachment(final MessagePartData attachment, + final PendingAttachmentData pendingAttachment) { + if (attachment != null && attachment.isSinglePartOnly()) { + // clear any existing attachments because the attachment we're adding can only + // exist by itself. + destroyAttachments(); + } + if (pendingAttachment != null && pendingAttachment.isSinglePartOnly()) { + // clear any existing attachments because the attachment we're adding can only + // exist by itself. + destroyAttachments(); + } + // If the existing attachments contain a single-only attachment, we need to clear the + // existing attachments to make room for the incoming attachment. + for (final MessagePartData data : mAttachments) { + if (data.isSinglePartOnly()) { + // clear any existing attachments because the single attachment can only exist + // by itself + destroyAttachments(); + break; + } + } + // If any of the pending attachments contain a single-only attachment, we need to clear the + // existing attachments to make room for the incoming attachment. + for (final MessagePartData data : mPendingAttachments) { + if (data.isSinglePartOnly()) { + // clear any existing attachments because the single attachment can only exist + // by itself + destroyAttachments(); + break; + } + } + if (attachment != null) { + mAttachments.add(attachment); + } else if (pendingAttachment != null) { + mPendingAttachments.add(pendingAttachment); + } + } + + public void addPendingAttachment(final PendingAttachmentData pendingAttachment, + final BindingBase binding) { + final boolean reachedLimit = addOnePendingAttachmentNoNotify(pendingAttachment, + binding.getBindingId()); + if (reachedLimit) { + dispatchAttachmentLimitReached(); + } + dispatchChanged(ATTACHMENTS_CHANGED); + } + + /** + * Try to add one pending attachment, while guarding against duplicates and + * going over the limit. + * @return true if the attachment limit was reached, false otherwise + */ + private boolean addOnePendingAttachmentNoNotify(final PendingAttachmentData pendingAttachment, + final String bindingId) { + final boolean reachedLimit = getAttachmentCount() >= getAttachmentLimit(); + if (reachedLimit || containsAttachment(pendingAttachment.getContentUri())) { + // Never go over the limit. Never add duplicated attachments. + pendingAttachment.destroyAsync(); + return reachedLimit; + } else { + Assert.isTrue(!mPendingAttachments.contains(pendingAttachment)); + Assert.equals(PendingAttachmentData.STATE_PENDING, pendingAttachment.getCurrentState()); + addAttachment(null /*attachment*/, pendingAttachment); + + pendingAttachment.loadAttachmentForDraft(this, bindingId); + return false; + } + } + + public void setSelfId(final String selfId, final boolean notify) { + LogUtil.d(LogUtil.BUGLE_TAG, "DraftMessageData: set selfId=" + selfId + + " for conversationId=" + mConversationId); + mSelfId = selfId; + if (notify) { + dispatchChanged(SELF_CHANGED); + } + } + + public boolean hasAttachments() { + return !mAttachments.isEmpty(); + } + + public boolean hasPendingAttachments() { + return !mPendingAttachments.isEmpty(); + } + + private int getAttachmentCount() { + return mAttachments.size() + mPendingAttachments.size(); + } + + private int getVideoAttachmentCount() { + int count = 0; + for (MessagePartData part : mAttachments) { + if (part.isVideo()) { + count++; + } + } + for (MessagePartData part : mPendingAttachments) { + if (part.isVideo()) { + count++; + } + } + return count; + } + + private int getAttachmentLimit() { + return BugleGservices.get().getInt( + BugleGservicesKeys.MMS_ATTACHMENT_LIMIT, + BugleGservicesKeys.MMS_ATTACHMENT_LIMIT_DEFAULT); + } + + public void removeAttachment(final MessagePartData attachment) { + for (final MessagePartData existingAttachment : mAttachments) { + if (existingAttachment.getContentUri().equals(attachment.getContentUri())) { + mAttachments.remove(existingAttachment); + existingAttachment.destroyAsync(); + dispatchChanged(ATTACHMENTS_CHANGED); + break; + } + } + } + + public void removeExistingAttachments(final Set attachmentsToRemove) { + boolean removed = false; + final Iterator iterator = mAttachments.iterator(); + while (iterator.hasNext()) { + final MessagePartData existingAttachment = iterator.next(); + if (attachmentsToRemove.contains(existingAttachment)) { + iterator.remove(); + existingAttachment.destroyAsync(); + removed = true; + } + } + + if (removed) { + dispatchChanged(ATTACHMENTS_CHANGED); + } + } + + public void removePendingAttachment(final PendingAttachmentData pendingAttachment) { + for (final PendingAttachmentData existingAttachment : mPendingAttachments) { + if (existingAttachment.getContentUri().equals(pendingAttachment.getContentUri())) { + mPendingAttachments.remove(pendingAttachment); + pendingAttachment.destroyAsync(); + dispatchChanged(ATTACHMENTS_CHANGED); + break; + } + } + } + + public void updatePendingAttachment(final MessagePartData updatedAttachment, + final PendingAttachmentData pendingAttachment) { + for (final PendingAttachmentData existingAttachment : mPendingAttachments) { + if (existingAttachment.getContentUri().equals(pendingAttachment.getContentUri())) { + mPendingAttachments.remove(pendingAttachment); + if (pendingAttachment.isSinglePartOnly()) { + updatedAttachment.setSinglePartOnly(true); + } + mAttachments.add(updatedAttachment); + dispatchChanged(ATTACHMENTS_CHANGED); + return; + } + } + + // If we are here, this means the pending attachment has been dropped before the task + // to load it was completed. In this case destroy the temporarily staged file since it + // is no longer needed. + updatedAttachment.destroyAsync(); + } + + /** + * Remove the attachments from the draft and notify any listeners. + * @param flags typically this will be ATTACHMENTS_CHANGED. When attachments are cleared in a + * widget, flags will also contain WIDGET_CHANGED. + */ + public void clearAttachments(final int flags) { + destroyAttachments(); + dispatchChanged(flags); + } + + public List getReadOnlyAttachments() { + return mReadOnlyAttachments; + } + + public List getReadOnlyPendingAttachments() { + return mReadOnlyPendingAttachments; + } + + public boolean loadFromStorage(final BindingBase binding, + final MessageData optionalIncomingDraft, boolean clearLocalDraft) { + LogUtil.d(LogUtil.BUGLE_TAG, "DraftMessageData: " + + (optionalIncomingDraft == null ? "loading" : "setting") + + " for conversationId=" + mConversationId); + if (clearLocalDraft) { + clearLocalDraftCopy(); + } + final boolean isDraftCachedCopy = mIsDraftCachedCopy; + mIsDraftCachedCopy = false; + // Before reading message from db ensure the caller is bound to us (and knows the id) + if (mMonitor == null && !isDraftCachedCopy && isBound(binding.getBindingId())) { + mMonitor = ReadDraftDataAction.readDraftData(mConversationId, + optionalIncomingDraft, binding.getBindingId(), this); + return true; + } + return false; + } + + /** + * Saves the current draft to db. This will save the draft and drop any pending attachments + * we have. The UI typically goes into the background when this is called, and instead of + * trying to persist the state of the pending attachments (the app may be killed, the activity + * may be destroyed), we simply drop the pending attachments for consistency. + */ + public void saveToStorage(final BindingBase binding) { + saveToStorageInternal(binding); + dropPendingAttachments(); + } + + private void saveToStorageInternal(final BindingBase binding) { + // Create MessageData to store to db, but don't clear the in-memory copy so UI will + // continue to display it. + // If self id is null then we'll not attempt to change the conversation's self id. + final MessageData message = createMessageWithCurrentAttachments(false /* clearLocalCopy */); + // Before writing message to db ensure the caller is bound to us (and knows the id) + if (isBound(binding.getBindingId())){ + WriteDraftMessageAction.writeDraftMessage(mConversationId, message); + } + } + + /** + * Called when we are ready to send the message. This will assemble/return the MessageData for + * sending and clear the local draft data, both from memory and from DB. This will also bind + * the message data with a self Id through which the message will be sent. + * + * @param binding the binding object from our consumer. We need to make sure we are still bound + * to that binding before saving to storage. + */ + public MessageData prepareMessageForSending(final BindingBase binding) { + // We can't send the message while there's still stuff pending. + Assert.isTrue(!hasPendingAttachments()); + mSending = true; + // Assembles the message to send and empty working draft data. + // If self id is null then message is sent with conversation's self id. + final MessageData messageToSend = + createMessageWithCurrentAttachments(true /* clearLocalCopy */); + // Note sending message will empty the draft data in DB. + mSending = false; + return messageToSend; + } + + public boolean isSending() { + return mSending; + } + + @Override // ReadDraftMessageActionListener.onReadDraftMessageSucceeded + public void onReadDraftDataSucceeded(final ReadDraftDataAction action, final Object data, + final MessageData message, final ConversationListItemData conversation) { + final String bindingId = (String) data; + + // Before passing draft message on to ui ensure the data is bound to the same bindingid + if (isBound(bindingId)) { + mSelfId = message.getSelfId(); + mIsGroupConversation = conversation.getIsGroup(); + mIncludeEmailAddress = conversation.getIncludeEmailAddress(); + updateFromMessageData(message, bindingId); + LogUtil.d(LogUtil.BUGLE_TAG, "DraftMessageData: draft loaded. " + + "conversationId=" + mConversationId + " selfId=" + mSelfId); + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "DraftMessageData: draft loaded but not bound. " + + "conversationId=" + mConversationId); + } + mMonitor = null; + } + + @Override // ReadDraftMessageActionListener.onReadDraftDataFailed + public void onReadDraftDataFailed(final ReadDraftDataAction action, final Object data) { + LogUtil.w(LogUtil.BUGLE_TAG, "DraftMessageData: draft not loaded. " + + "conversationId=" + mConversationId); + // The draft is now synced with actual MessageData and no longer a cached copy. + mIsDraftCachedCopy = false; + // Just clear the monitor - no update to draft data + mMonitor = null; + } + + /** + * Check if Bugle is default sms app + * @return + */ + public boolean getIsDefaultSmsApp() { + return PhoneUtils.getDefault().isDefaultSmsApp(); + } + + @Override //BindableData.unregisterListeners + protected void unregisterListeners() { + if (mMonitor != null) { + mMonitor.unregister(); + } + mMonitor = null; + mListeners.clear(); + } + + private void destroyAttachments() { + for (final MessagePartData attachment : mAttachments) { + attachment.destroyAsync(); + } + mAttachments.clear(); + mPendingAttachments.clear(); + } + + private void dispatchChanged(final int changeFlags) { + // No change is expected to be made to the draft if it is in cached copy state. + if (mIsDraftCachedCopy) { + return; + } + // Any change in the draft will cancel any pending draft checking task, since the + // size/status of the draft may have changed. + if (mCheckDraftForSendTask != null) { + mCheckDraftForSendTask.cancel(true /* mayInterruptIfRunning */); + mCheckDraftForSendTask = null; + } + mListeners.onDraftChanged(this, changeFlags); + } + + private void dispatchAttachmentLimitReached() { + mListeners.onDraftAttachmentLimitReached(this); + } + + /** + * Drop any pending attachments that haven't finished. This is called after the UI goes to + * the background and we persist the draft data to the database. + */ + private void dropPendingAttachments() { + mPendingAttachments.clear(); + } + + private boolean isDraftEmpty() { + return TextUtils.isEmpty(mMessageText) && mAttachments.isEmpty() && + TextUtils.isEmpty(mMessageSubject); + } + + public boolean isCheckingDraft() { + return mCheckDraftForSendTask != null && !mCheckDraftForSendTask.isCancelled(); + } + + public void checkDraftForAction(final boolean checkMessageSize, final int selfSubId, + final CheckDraftTaskCallback callback, final Binding binding) { + new CheckDraftForSendTask(checkMessageSize, selfSubId, callback, binding) + .executeOnThreadPool((Void) null); + } + + /** + * Allows us to have multiple data listeners for DraftMessageData + */ + private class DraftMessageDataEventDispatcher + extends ArrayList + implements DraftMessageDataListener { + + @Override + @RunsOnMainThread + public void onDraftChanged(DraftMessageData data, int changeFlags) { + Assert.isMainThread(); + for (final DraftMessageDataListener listener : this) { + listener.onDraftChanged(data, changeFlags); + } + } + + @Override + @RunsOnMainThread + public void onDraftAttachmentLimitReached(DraftMessageData data) { + Assert.isMainThread(); + for (final DraftMessageDataListener listener : this) { + listener.onDraftAttachmentLimitReached(data); + } + } + + @Override + @RunsOnMainThread + public void onDraftAttachmentLoadFailed() { + Assert.isMainThread(); + for (final DraftMessageDataListener listener : this) { + listener.onDraftAttachmentLoadFailed(); + } + } + } + + public interface CheckDraftTaskCallback { + void onDraftChecked(DraftMessageData data, int result); + } + + public class CheckDraftForSendTask extends SafeAsyncTask { + public static final int RESULT_PASSED = 0; + public static final int RESULT_HAS_PENDING_ATTACHMENTS = 1; + public static final int RESULT_NO_SELF_PHONE_NUMBER_IN_GROUP_MMS = 2; + public static final int RESULT_MESSAGE_OVER_LIMIT = 3; + public static final int RESULT_VIDEO_ATTACHMENT_LIMIT_EXCEEDED = 4; + public static final int RESULT_SIM_NOT_READY = 5; + private final boolean mCheckMessageSize; + private final int mSelfSubId; + private final CheckDraftTaskCallback mCallback; + private final String mBindingId; + private final List mAttachmentsCopy; + private int mPreExecuteResult = RESULT_PASSED; + + public CheckDraftForSendTask(final boolean checkMessageSize, final int selfSubId, + final CheckDraftTaskCallback callback, final Binding binding) { + mCheckMessageSize = checkMessageSize; + mSelfSubId = selfSubId; + mCallback = callback; + mBindingId = binding.getBindingId(); + // Obtain an immutable copy of the attachment list so we can operate on it in the + // background thread. + mAttachmentsCopy = new ArrayList(mAttachments); + + mCheckDraftForSendTask = this; + } + + @Override + protected void onPreExecute() { + // Perform checking work that can happen on the main thread. + if (hasPendingAttachments()) { + mPreExecuteResult = RESULT_HAS_PENDING_ATTACHMENTS; + return; + } + if (getIsGroupMmsConversation()) { + try { + if (TextUtils.isEmpty(PhoneUtils.get(mSelfSubId).getSelfRawNumber(true))) { + mPreExecuteResult = RESULT_NO_SELF_PHONE_NUMBER_IN_GROUP_MMS; + return; + } + } catch (IllegalStateException e) { + // This happens when there is no active subscription, e.g. on Nova + // when the phone switches carrier. + mPreExecuteResult = RESULT_SIM_NOT_READY; + return; + } + } + if (getVideoAttachmentCount() > MmsUtils.MAX_VIDEO_ATTACHMENT_COUNT) { + mPreExecuteResult = RESULT_VIDEO_ATTACHMENT_LIMIT_EXCEEDED; + return; + } + } + + @Override + protected Integer doInBackgroundTimed(Void... params) { + if (mPreExecuteResult != RESULT_PASSED) { + return mPreExecuteResult; + } + + if (mCheckMessageSize && getIsMessageOverLimit()) { + return RESULT_MESSAGE_OVER_LIMIT; + } + return RESULT_PASSED; + } + + @Override + protected void onPostExecute(Integer result) { + mCheckDraftForSendTask = null; + // Only call back if we are bound to the original binding. + if (isBound(mBindingId) && !isCancelled()) { + mCallback.onDraftChecked(DraftMessageData.this, result); + } else { + if (!isBound(mBindingId)) { + LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: draft not bound"); + } + if (isCancelled()) { + LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: draft is cancelled"); + } + } + } + + @Override + protected void onCancelled() { + mCheckDraftForSendTask = null; + } + + /** + * 1. Check if the draft message contains too many attachments to send + * 2. Computes the minimum size that this message could be compressed/downsampled/encoded + * before sending and check if it meets the carrier max size for sending. + * @see MessagePartData#getMinimumSizeInBytesForSending() + */ + @DoesNotRunOnMainThread + private boolean getIsMessageOverLimit() { + Assert.isNotMainThread(); + if (mAttachmentsCopy.size() > getAttachmentLimit()) { + return true; + } + + // Aggregate the size from all the attachments. + long totalSize = 0; + for (final MessagePartData attachment : mAttachmentsCopy) { + totalSize += attachment.getMinimumSizeInBytesForSending(); + } + return totalSize > MmsConfig.get(mSelfSubId).getMaxMessageSize(); + } + } + + public void onPendingAttachmentLoadFailed(PendingAttachmentData data) { + mListeners.onDraftAttachmentLoadFailed(); + } +} diff --git a/src/com/android/messaging/datamodel/data/GalleryGridItemData.java b/src/com/android/messaging/datamodel/data/GalleryGridItemData.java new file mode 100644 index 0000000..6649757 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/GalleryGridItemData.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.data; + +import android.database.Cursor; +import android.graphics.Rect; +import android.net.Uri; +import android.provider.BaseColumns; +import android.provider.MediaStore.Images.Media; +import android.text.TextUtils; + +import com.android.messaging.datamodel.media.FileImageRequestDescriptor; +import com.android.messaging.datamodel.media.ImageRequest; +import com.android.messaging.datamodel.media.UriImageRequestDescriptor; +import com.android.messaging.util.Assert; + +/** + * Provides data for GalleryGridItemView + */ +public class GalleryGridItemData { + public static final String[] IMAGE_PROJECTION = new String[] { + Media._ID, + Media.DATA, + Media.WIDTH, + Media.HEIGHT, + Media.MIME_TYPE, + Media.DATE_MODIFIED}; + + public static final String[] SPECIAL_ITEM_COLUMNS = new String[] { + BaseColumns._ID + }; + + private static final int INDEX_ID = 0; + + // For local image gallery. + private static final int INDEX_DATA_PATH = 1; + private static final int INDEX_WIDTH = 2; + private static final int INDEX_HEIGHT = 3; + private static final int INDEX_MIME_TYPE = 4; + private static final int INDEX_DATE_MODIFIED = 5; + + /** A special item's id for picking images from document picker */ + public static final String ID_DOCUMENT_PICKER_ITEM = "-1"; + + private UriImageRequestDescriptor mImageData; + private String mContentType; + private boolean mIsDocumentPickerItem; + private long mDateSeconds; + + public GalleryGridItemData() { + } + + public void bind(final Cursor cursor, final int desiredWidth, final int desiredHeight) { + mIsDocumentPickerItem = TextUtils.equals(cursor.getString(INDEX_ID), + ID_DOCUMENT_PICKER_ITEM); + if (mIsDocumentPickerItem) { + mImageData = null; + mContentType = null; + } else { + int sourceWidth = cursor.getInt(INDEX_WIDTH); + int sourceHeight = cursor.getInt(INDEX_HEIGHT); + + // Guard against bad data + if (sourceWidth <= 0) { + sourceWidth = ImageRequest.UNSPECIFIED_SIZE; + } + if (sourceHeight <= 0) { + sourceHeight = ImageRequest.UNSPECIFIED_SIZE; + } + + mContentType = cursor.getString(INDEX_MIME_TYPE); + final String dateModified = cursor.getString(INDEX_DATE_MODIFIED); + mDateSeconds = !TextUtils.isEmpty(dateModified) ? Long.parseLong(dateModified) : -1; + mImageData = new FileImageRequestDescriptor( + cursor.getString(INDEX_DATA_PATH), + desiredWidth, + desiredHeight, + sourceWidth, + sourceHeight, + true /* canUseThumbnail */, + true /* allowCompression */, + true /* isStatic */); + } + } + + public boolean isDocumentPickerItem() { + return mIsDocumentPickerItem; + } + + public Uri getImageUri() { + return mImageData.uri; + } + + public UriImageRequestDescriptor getImageRequestDescriptor() { + return mImageData; + } + + public MessagePartData constructMessagePartData(final Rect startRect) { + Assert.isTrue(!mIsDocumentPickerItem); + return new MediaPickerMessagePartData(startRect, mContentType, + mImageData.uri, mImageData.sourceWidth, mImageData.sourceHeight); + } + + /** + * @return The date in seconds. This can be negative if we could not retreive date info + */ + public long getDateSeconds() { + return mDateSeconds; + } + + public String getContentType() { + return mContentType; + } +} diff --git a/src/com/android/messaging/datamodel/data/LaunchConversationData.java b/src/com/android/messaging/datamodel/data/LaunchConversationData.java new file mode 100644 index 0000000..7eea580 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/LaunchConversationData.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.data; + +import com.android.messaging.datamodel.action.ActionMonitor; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionListener; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionMonitor; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.RunsOnMainThread; +import com.android.messaging.util.LogUtil; + +public class LaunchConversationData extends BindableData implements + GetOrCreateConversationActionListener { + public interface LaunchConversationDataListener { + void onGetOrCreateNewConversation(String conversationId); + void onGetOrCreateNewConversationFailed(); + } + + private LaunchConversationDataListener mListener; + private GetOrCreateConversationActionMonitor mMonitor; + + public LaunchConversationData(final LaunchConversationDataListener listener) { + mListener = listener; + } + + @Override + protected void unregisterListeners() { + mListener = null; + if (mMonitor != null) { + mMonitor.unregister(); + } + mMonitor = null; + } + + public void getOrCreateConversation(final BindingBase binding, + final String[] recipients) { + final String bindingId = binding.getBindingId(); + + // Start a new conversation from the list of contacts. + if (isBound(bindingId) && mMonitor == null) { + mMonitor = GetOrCreateConversationAction.getOrCreateConversation(recipients, + bindingId, this); + } + } + + @Override + @RunsOnMainThread + public void onGetOrCreateConversationSucceeded(final ActionMonitor monitor, + final Object data, final String conversationId) { + Assert.isTrue(monitor == mMonitor); + Assert.isTrue(conversationId != null); + + final String bindingId = (String) data; + if (isBound(bindingId) && mListener != null) { + mListener.onGetOrCreateNewConversation(conversationId); + } + + mMonitor = null; + } + + @Override + @RunsOnMainThread + public void onGetOrCreateConversationFailed(final ActionMonitor monitor, + final Object data) { + Assert.isTrue(monitor == mMonitor); + final String bindingId = (String) data; + if (isBound(bindingId) && mListener != null) { + mListener.onGetOrCreateNewConversationFailed(); + } + LogUtil.e(LogUtil.BUGLE_TAG, "onGetOrCreateConversationFailed"); + mMonitor = null; + } +} diff --git a/src/com/android/messaging/datamodel/data/MediaPickerData.java b/src/com/android/messaging/datamodel/data/MediaPickerData.java new file mode 100644 index 0000000..b0c8bf7 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/MediaPickerData.java @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.data; + +import android.app.LoaderManager; +import android.content.Context; +import android.content.Loader; +import android.database.Cursor; +import android.os.Bundle; +import android.support.annotation.Nullable; + +import com.android.messaging.datamodel.BoundCursorLoader; +import com.android.messaging.datamodel.GalleryBoundCursorLoader; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.BuglePrefsKeys; +import com.android.messaging.util.LogUtil; + +/** + * Services data needs for MediaPicker. + */ +public class MediaPickerData extends BindableData { + public interface MediaPickerDataListener { + void onMediaPickerDataUpdated(MediaPickerData mediaPickerData, Object data, int loaderId); + } + + private static final String BINDING_ID = "bindingId"; + private final Context mContext; + private LoaderManager mLoaderManager; + private final GalleryLoaderCallbacks mGalleryLoaderCallbacks; + private MediaPickerDataListener mListener; + + public MediaPickerData(final Context context) { + mContext = context; + mGalleryLoaderCallbacks = new GalleryLoaderCallbacks(); + } + + public static final int GALLERY_IMAGE_LOADER = 1; + + /** + * A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times. + */ + private class GalleryLoaderCallbacks implements LoaderManager.LoaderCallbacks { + @Override + public Loader onCreateLoader(final int id, final Bundle args) { + final String bindingId = args.getString(BINDING_ID); + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + switch (id) { + case GALLERY_IMAGE_LOADER: + return new GalleryBoundCursorLoader(bindingId, mContext); + + default: + Assert.fail("Unknown loader id for gallery picker!"); + break; + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding the media picker"); + } + return null; + } + + /** + * {@inheritDoc} + */ + @Override + public void onLoadFinished(final Loader loader, final Cursor data) { + final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; + if (isBound(cursorLoader.getBindingId())) { + switch (loader.getId()) { + case GALLERY_IMAGE_LOADER: + mListener.onMediaPickerDataUpdated(MediaPickerData.this, data, + GALLERY_IMAGE_LOADER); + break; + + default: + Assert.fail("Unknown loader id for gallery picker!"); + break; + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Loader finished after unbinding the media picker"); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void onLoaderReset(final Loader loader) { + final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; + if (isBound(cursorLoader.getBindingId())) { + switch (loader.getId()) { + case GALLERY_IMAGE_LOADER: + mListener.onMediaPickerDataUpdated(MediaPickerData.this, null, + GALLERY_IMAGE_LOADER); + break; + + default: + Assert.fail("Unknown loader id for media picker!"); + break; + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Loader reset after unbinding the media picker"); + } + } + } + + + + public void startLoader(final int loaderId, final BindingBase binding, + @Nullable Bundle args, final MediaPickerDataListener listener) { + if (args == null) { + args = new Bundle(); + } + args.putString(BINDING_ID, binding.getBindingId()); + if (loaderId == GALLERY_IMAGE_LOADER) { + mLoaderManager.initLoader(loaderId, args, mGalleryLoaderCallbacks).forceLoad(); + } else { + Assert.fail("Unsupported loader id for media picker!"); + } + mListener = listener; + } + + public void destroyLoader(final int loaderId) { + mLoaderManager.destroyLoader(loaderId); + } + + public void init(final LoaderManager loaderManager) { + mLoaderManager = loaderManager; + } + + @Override + protected void unregisterListeners() { + // This could be null if we bind but the caller doesn't init the BindableData + if (mLoaderManager != null) { + mLoaderManager.destroyLoader(GALLERY_IMAGE_LOADER); + mLoaderManager = null; + } + } + + /** + * Gets the last selected chooser index, or -1 if no selection has been saved. + */ + public int getSelectedChooserIndex() { + return BuglePrefs.getApplicationPrefs().getInt( + BuglePrefsKeys.SELECTED_MEDIA_PICKER_CHOOSER_INDEX, + BuglePrefsKeys.SELECTED_MEDIA_PICKER_CHOOSER_INDEX_DEFAULT); + } + + /** + * Saves the selected media chooser index. + * @param selectedIndex the selected media chooser index. + */ + public void saveSelectedChooserIndex(final int selectedIndex) { + BuglePrefs.getApplicationPrefs().putInt(BuglePrefsKeys.SELECTED_MEDIA_PICKER_CHOOSER_INDEX, + selectedIndex); + } + +} \ No newline at end of file diff --git a/src/com/android/messaging/datamodel/data/MediaPickerMessagePartData.java b/src/com/android/messaging/datamodel/data/MediaPickerMessagePartData.java new file mode 100644 index 0000000..7de9166 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/MediaPickerMessagePartData.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.data; + +import android.graphics.Rect; +import android.net.Uri; + +public class MediaPickerMessagePartData extends MessagePartData { + private final Rect mStartRect; + + public MediaPickerMessagePartData(final Rect startRect, final String contentType, + final Uri contentUri, final int width, final int height) { + this(startRect, null /* messageText */, contentType, contentUri, width, height); + } + + public MediaPickerMessagePartData(final Rect startRect, final String messageText, + final String contentType, final Uri contentUri, final int width, final int height) { + this(startRect, messageText, contentType, contentUri, width, height, + false /*onlySingleAttachment*/); + } + + public MediaPickerMessagePartData(final Rect startRect, final String contentType, + final Uri contentUri, final int width, final int height, + final boolean onlySingleAttachment) { + this(startRect, null /* messageText */, contentType, contentUri, width, height, + onlySingleAttachment); + } + + public MediaPickerMessagePartData(final Rect startRect, final String messageText, + final String contentType, final Uri contentUri, final int width, final int height, + final boolean onlySingleAttachment) { + super(messageText, contentType, contentUri, width, height, onlySingleAttachment); + mStartRect = startRect; + } + + /** + * @return The starting rect to animate the attachment preview from in order to perform a smooth + * transition + */ + public Rect getStartRect() { + return mStartRect; + } + + /** + * Modify the start rect of the attachment. + */ + public void setStartRect(final Rect startRect) { + mStartRect.set(startRect); + } +} diff --git a/src/com/android/messaging/datamodel/data/MessageData.java b/src/com/android/messaging/datamodel/data/MessageData.java new file mode 100644 index 0000000..a3698a9 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/MessageData.java @@ -0,0 +1,922 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.data; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteStatement; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.Dates; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.OsUtil; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class MessageData implements Parcelable { + private static final String[] sProjection = { + MessageColumns._ID, + MessageColumns.CONVERSATION_ID, + MessageColumns.SENDER_PARTICIPANT_ID, + MessageColumns.SELF_PARTICIPANT_ID, + MessageColumns.SENT_TIMESTAMP, + MessageColumns.RECEIVED_TIMESTAMP, + MessageColumns.SEEN, + MessageColumns.READ, + MessageColumns.PROTOCOL, + MessageColumns.STATUS, + MessageColumns.SMS_MESSAGE_URI, + MessageColumns.SMS_PRIORITY, + MessageColumns.SMS_MESSAGE_SIZE, + MessageColumns.MMS_SUBJECT, + MessageColumns.MMS_TRANSACTION_ID, + MessageColumns.MMS_CONTENT_LOCATION, + MessageColumns.MMS_EXPIRY, + MessageColumns.RAW_TELEPHONY_STATUS, + MessageColumns.RETRY_START_TIMESTAMP, + }; + + private static final int INDEX_ID = 0; + private static final int INDEX_CONVERSATION_ID = 1; + private static final int INDEX_PARTICIPANT_ID = 2; + private static final int INDEX_SELF_ID = 3; + private static final int INDEX_SENT_TIMESTAMP = 4; + private static final int INDEX_RECEIVED_TIMESTAMP = 5; + private static final int INDEX_SEEN = 6; + private static final int INDEX_READ = 7; + private static final int INDEX_PROTOCOL = 8; + private static final int INDEX_BUGLE_STATUS = 9; + private static final int INDEX_SMS_MESSAGE_URI = 10; + private static final int INDEX_SMS_PRIORITY = 11; + private static final int INDEX_SMS_MESSAGE_SIZE = 12; + private static final int INDEX_MMS_SUBJECT = 13; + private static final int INDEX_MMS_TRANSACTION_ID = 14; + private static final int INDEX_MMS_CONTENT_LOCATION = 15; + private static final int INDEX_MMS_EXPIRY = 16; + private static final int INDEX_RAW_TELEPHONY_STATUS = 17; + private static final int INDEX_RETRY_START_TIMESTAMP = 18; + + // SQL statement to insert a "complete" message row (columns based on the projection above). + private static final String INSERT_MESSAGE_SQL = + "INSERT INTO " + DatabaseHelper.MESSAGES_TABLE + " ( " + + TextUtils.join(", ", Arrays.copyOfRange(sProjection, 1, + INDEX_RETRY_START_TIMESTAMP + 1)) + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + + private String mMessageId; + private String mConversationId; + private String mParticipantId; + private String mSelfId; + private long mSentTimestamp; + private long mReceivedTimestamp; + private boolean mSeen; + private boolean mRead; + private int mProtocol; + private Uri mSmsMessageUri; + private int mSmsPriority; + private long mSmsMessageSize; + private String mMmsSubject; + private String mMmsTransactionId; + private String mMmsContentLocation; + private long mMmsExpiry; + private int mRawStatus; + private int mStatus; + private final ArrayList mParts; + private long mRetryStartTimestamp; + + // PROTOCOL Values + public static final int PROTOCOL_UNKNOWN = -1; // Unknown type + public static final int PROTOCOL_SMS = 0; // SMS message + public static final int PROTOCOL_MMS = 1; // MMS message + public static final int PROTOCOL_MMS_PUSH_NOTIFICATION = 2; // MMS WAP push notification + + // Bugle STATUS Values + public static final int BUGLE_STATUS_UNKNOWN = 0; + + // Outgoing + public static final int BUGLE_STATUS_OUTGOING_COMPLETE = 1; + public static final int BUGLE_STATUS_OUTGOING_DELIVERED = 2; + // Transitions to either YET_TO_SEND or SEND_AFTER_PROCESSING depending attachments. + public static final int BUGLE_STATUS_OUTGOING_DRAFT = 3; + public static final int BUGLE_STATUS_OUTGOING_YET_TO_SEND = 4; + public static final int BUGLE_STATUS_OUTGOING_SENDING = 5; + public static final int BUGLE_STATUS_OUTGOING_RESENDING = 6; + public static final int BUGLE_STATUS_OUTGOING_AWAITING_RETRY = 7; + public static final int BUGLE_STATUS_OUTGOING_FAILED = 8; + public static final int BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER = 9; + + // Incoming + public static final int BUGLE_STATUS_INCOMING_COMPLETE = 100; + public static final int BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD = 101; + public static final int BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD = 102; + public static final int BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING = 103; + public static final int BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD = 104; + public static final int BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING = 105; + public static final int BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED = 106; + public static final int BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE = 107; + + public static final String getStatusDescription(int status) { + switch (status) { + case BUGLE_STATUS_UNKNOWN: + return "UNKNOWN"; + case BUGLE_STATUS_OUTGOING_COMPLETE: + return "OUTGOING_COMPLETE"; + case BUGLE_STATUS_OUTGOING_DELIVERED: + return "OUTGOING_DELIVERED"; + case BUGLE_STATUS_OUTGOING_DRAFT: + return "OUTGOING_DRAFT"; + case BUGLE_STATUS_OUTGOING_YET_TO_SEND: + return "OUTGOING_YET_TO_SEND"; + case BUGLE_STATUS_OUTGOING_SENDING: + return "OUTGOING_SENDING"; + case BUGLE_STATUS_OUTGOING_RESENDING: + return "OUTGOING_RESENDING"; + case BUGLE_STATUS_OUTGOING_AWAITING_RETRY: + return "OUTGOING_AWAITING_RETRY"; + case BUGLE_STATUS_OUTGOING_FAILED: + return "OUTGOING_FAILED"; + case BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER: + return "OUTGOING_FAILED_EMERGENCY_NUMBER"; + case BUGLE_STATUS_INCOMING_COMPLETE: + return "INCOMING_COMPLETE"; + case BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD: + return "INCOMING_YET_TO_MANUAL_DOWNLOAD"; + case BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD: + return "INCOMING_RETRYING_MANUAL_DOWNLOAD"; + case BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING: + return "INCOMING_MANUAL_DOWNLOADING"; + case BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD: + return "INCOMING_RETRYING_AUTO_DOWNLOAD"; + case BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING: + return "INCOMING_AUTO_DOWNLOADING"; + case BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED: + return "INCOMING_DOWNLOAD_FAILED"; + case BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE: + return "INCOMING_EXPIRED_OR_NOT_AVAILABLE"; + default: + return String.valueOf(status) + " (check MessageData)"; + } + } + + // All incoming messages expect to have status >= BUGLE_STATUS_FIRST_INCOMING + public static final int BUGLE_STATUS_FIRST_INCOMING = BUGLE_STATUS_INCOMING_COMPLETE; + + // Detailed MMS failures. Most of the values are defined in PduHeaders. However, a few are + // defined here instead. These are never returned in the MMS HTTP response, but are used + // internally. The values here must not conflict with any of the existing PduHeader values. + public static final int RAW_TELEPHONY_STATUS_UNDEFINED = MmsUtils.PDU_HEADER_VALUE_UNDEFINED; + public static final int RAW_TELEPHONY_STATUS_MESSAGE_TOO_BIG = 10000; + + // Unknown result code for MMS sending/downloading. This is used as the default value + // for result code returned from platform MMS API. + public static final int UNKNOWN_RESULT_CODE = 0; + + /** + * Create an "empty" message + */ + public MessageData() { + mParts = new ArrayList(); + } + + public static String[] getProjection() { + return sProjection; + } + + /** + * Create a draft message for a particular conversation based on supplied content + */ + public static MessageData createDraftMessage(final String conversationId, + final String selfId, final MessageData content) { + final MessageData message = new MessageData(); + message.mStatus = BUGLE_STATUS_OUTGOING_DRAFT; + message.mProtocol = PROTOCOL_UNKNOWN; + message.mConversationId = conversationId; + message.mParticipantId = selfId; + message.mReceivedTimestamp = System.currentTimeMillis(); + if (content == null) { + message.mParts.add(MessagePartData.createTextMessagePart("")); + } else { + if (!TextUtils.isEmpty(content.mParticipantId)) { + message.mParticipantId = content.mParticipantId; + } + if (!TextUtils.isEmpty(content.mMmsSubject)) { + message.mMmsSubject = content.mMmsSubject; + } + for (final MessagePartData part : content.getParts()) { + message.mParts.add(part); + } + } + message.mSelfId = selfId; + return message; + } + + /** + * Create a draft sms message for a particular conversation + */ + public static MessageData createDraftSmsMessage(final String conversationId, + final String selfId, final String messageText) { + final MessageData message = new MessageData(); + message.mStatus = BUGLE_STATUS_OUTGOING_DRAFT; + message.mProtocol = PROTOCOL_SMS; + message.mConversationId = conversationId; + message.mParticipantId = selfId; + message.mSelfId = selfId; + message.mParts.add(MessagePartData.createTextMessagePart(messageText)); + message.mReceivedTimestamp = System.currentTimeMillis(); + return message; + } + + /** + * Create a draft mms message for a particular conversation + */ + public static MessageData createDraftMmsMessage(final String conversationId, + final String selfId, final String messageText, final String subjectText) { + final MessageData message = new MessageData(); + message.mStatus = BUGLE_STATUS_OUTGOING_DRAFT; + message.mProtocol = PROTOCOL_MMS; + message.mConversationId = conversationId; + message.mParticipantId = selfId; + message.mSelfId = selfId; + message.mMmsSubject = subjectText; + message.mReceivedTimestamp = System.currentTimeMillis(); + if (!TextUtils.isEmpty(messageText)) { + message.mParts.add(MessagePartData.createTextMessagePart(messageText)); + } + return message; + } + + /** + * Create a message received from a particular number in a particular conversation + */ + public static MessageData createReceivedSmsMessage(final Uri uri, final String conversationId, + final String participantId, final String selfId, final String messageText, + final String subject, final long sent, final long recieved, + final boolean seen, final boolean read) { + final MessageData message = new MessageData(); + message.mSmsMessageUri = uri; + message.mConversationId = conversationId; + message.mParticipantId = participantId; + message.mSelfId = selfId; + message.mProtocol = PROTOCOL_SMS; + message.mStatus = BUGLE_STATUS_INCOMING_COMPLETE; + message.mMmsSubject = subject; + message.mReceivedTimestamp = recieved; + message.mSentTimestamp = sent; + message.mParts.add(MessagePartData.createTextMessagePart(messageText)); + message.mSeen = seen; + message.mRead = read; + return message; + } + + /** + * Create a message not yet associated with a particular conversation + */ + public static MessageData createSharedMessage(final String messageText) { + final MessageData message = new MessageData(); + message.mStatus = BUGLE_STATUS_OUTGOING_DRAFT; + if (!TextUtils.isEmpty(messageText)) { + message.mParts.add(MessagePartData.createTextMessagePart(messageText)); + } + return message; + } + + /** + * Create a message from Sms table fields + */ + public static MessageData createSmsMessage(final String messageUri, final String participantId, + final String selfId, final String conversationId, final int bugleStatus, + final boolean seen, final boolean read, final long sent, + final long recieved, final String messageText) { + final MessageData message = new MessageData(); + message.mParticipantId = participantId; + message.mSelfId = selfId; + message.mConversationId = conversationId; + message.mSentTimestamp = sent; + message.mReceivedTimestamp = recieved; + message.mSeen = seen; + message.mRead = read; + message.mProtocol = PROTOCOL_SMS; + message.mStatus = bugleStatus; + message.mSmsMessageUri = Uri.parse(messageUri); + message.mParts.add(MessagePartData.createTextMessagePart(messageText)); + return message; + } + + /** + * Create a message from Mms table fields + */ + public static MessageData createMmsMessage(final String messageUri, final String participantId, + final String selfId, final String conversationId, final boolean isNotification, + final int bugleStatus, final String contentLocation, final String transactionId, + final int smsPriority, final String subject, final boolean seen, final boolean read, + final long size, final int rawStatus, final long expiry, final long sent, + final long received) { + final MessageData message = new MessageData(); + message.mParticipantId = participantId; + message.mSelfId = selfId; + message.mConversationId = conversationId; + message.mSentTimestamp = sent; + message.mReceivedTimestamp = received; + message.mMmsContentLocation = contentLocation; + message.mMmsTransactionId = transactionId; + message.mSeen = seen; + message.mRead = read; + message.mStatus = bugleStatus; + message.mProtocol = (isNotification ? PROTOCOL_MMS_PUSH_NOTIFICATION : PROTOCOL_MMS); + message.mSmsMessageUri = Uri.parse(messageUri); + message.mSmsPriority = smsPriority; + message.mSmsMessageSize = size; + message.mMmsSubject = subject; + message.mMmsExpiry = expiry; + message.mRawStatus = rawStatus; + if (bugleStatus == BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD || + bugleStatus == BUGLE_STATUS_OUTGOING_RESENDING) { + // Set the retry start timestamp if this message is already in process of retrying + // Either as autodownload is starting or sending already in progress (MMS update) + message.mRetryStartTimestamp = received; + } + return message; + } + + public void addPart(final MessagePartData part) { + if (part instanceof PendingAttachmentData) { + // Pending attachments may only be added to shared message data that's not associated + // with any particular conversation, in order to store shared images. + Assert.isTrue(mConversationId == null); + } + mParts.add(part); + } + + public Iterable getParts() { + return mParts; + } + + public void bind(final Cursor cursor) { + mMessageId = cursor.getString(INDEX_ID); + mConversationId = cursor.getString(INDEX_CONVERSATION_ID); + mParticipantId = cursor.getString(INDEX_PARTICIPANT_ID); + mSelfId = cursor.getString(INDEX_SELF_ID); + mSentTimestamp = cursor.getLong(INDEX_SENT_TIMESTAMP); + mReceivedTimestamp = cursor.getLong(INDEX_RECEIVED_TIMESTAMP); + mSeen = (cursor.getInt(INDEX_SEEN) != 0); + mRead = (cursor.getInt(INDEX_READ) != 0); + mProtocol = cursor.getInt(INDEX_PROTOCOL); + mStatus = cursor.getInt(INDEX_BUGLE_STATUS); + final String smsMessageUri = cursor.getString(INDEX_SMS_MESSAGE_URI); + mSmsMessageUri = (smsMessageUri == null) ? null : Uri.parse(smsMessageUri); + mSmsPriority = cursor.getInt(INDEX_SMS_PRIORITY); + mSmsMessageSize = cursor.getLong(INDEX_SMS_MESSAGE_SIZE); + mMmsExpiry = cursor.getLong(INDEX_MMS_EXPIRY); + mRawStatus = cursor.getInt(INDEX_RAW_TELEPHONY_STATUS); + mMmsSubject = cursor.getString(INDEX_MMS_SUBJECT); + mMmsTransactionId = cursor.getString(INDEX_MMS_TRANSACTION_ID); + mMmsContentLocation = cursor.getString(INDEX_MMS_CONTENT_LOCATION); + mRetryStartTimestamp = cursor.getLong(INDEX_RETRY_START_TIMESTAMP); + } + + /** + * Bind to the draft message data for a conversation. The conversation's self id is used as + * the draft's self id. + */ + public void bindDraft(final Cursor cursor, final String conversationSelfId) { + bind(cursor); + mSelfId = conversationSelfId; + } + + protected static String getParticipantId(final Cursor cursor) { + return cursor.getString(INDEX_PARTICIPANT_ID); + } + + public void populate(final ContentValues values) { + values.put(MessageColumns.CONVERSATION_ID, mConversationId); + values.put(MessageColumns.SENDER_PARTICIPANT_ID, mParticipantId); + values.put(MessageColumns.SELF_PARTICIPANT_ID, mSelfId); + values.put(MessageColumns.SENT_TIMESTAMP, mSentTimestamp); + values.put(MessageColumns.RECEIVED_TIMESTAMP, mReceivedTimestamp); + values.put(MessageColumns.SEEN, mSeen ? 1 : 0); + values.put(MessageColumns.READ, mRead ? 1 : 0); + values.put(MessageColumns.PROTOCOL, mProtocol); + values.put(MessageColumns.STATUS, mStatus); + final String smsMessageUri = ((mSmsMessageUri == null) ? null : mSmsMessageUri.toString()); + values.put(MessageColumns.SMS_MESSAGE_URI, smsMessageUri); + values.put(MessageColumns.SMS_PRIORITY, mSmsPriority); + values.put(MessageColumns.SMS_MESSAGE_SIZE, mSmsMessageSize); + values.put(MessageColumns.MMS_EXPIRY, mMmsExpiry); + values.put(MessageColumns.MMS_SUBJECT, mMmsSubject); + values.put(MessageColumns.MMS_TRANSACTION_ID, mMmsTransactionId); + values.put(MessageColumns.MMS_CONTENT_LOCATION, mMmsContentLocation); + values.put(MessageColumns.RAW_TELEPHONY_STATUS, mRawStatus); + values.put(MessageColumns.RETRY_START_TIMESTAMP, mRetryStartTimestamp); + } + + /** + * Note this is not thread safe so callers need to make sure they own the wrapper + statements + * while they call this and use the returned value. + */ + public SQLiteStatement getInsertStatement(final DatabaseWrapper db) { + final SQLiteStatement insert = db.getStatementInTransaction( + DatabaseWrapper.INDEX_INSERT_MESSAGE, INSERT_MESSAGE_SQL); + insert.clearBindings(); + insert.bindString(INDEX_CONVERSATION_ID, mConversationId); + insert.bindString(INDEX_PARTICIPANT_ID, mParticipantId); + insert.bindString(INDEX_SELF_ID, mSelfId); + insert.bindLong(INDEX_SENT_TIMESTAMP, mSentTimestamp); + insert.bindLong(INDEX_RECEIVED_TIMESTAMP, mReceivedTimestamp); + insert.bindLong(INDEX_SEEN, mSeen ? 1 : 0); + insert.bindLong(INDEX_READ, mRead ? 1 : 0); + insert.bindLong(INDEX_PROTOCOL, mProtocol); + insert.bindLong(INDEX_BUGLE_STATUS, mStatus); + if (mSmsMessageUri != null) { + insert.bindString(INDEX_SMS_MESSAGE_URI, mSmsMessageUri.toString()); + } + insert.bindLong(INDEX_SMS_PRIORITY, mSmsPriority); + insert.bindLong(INDEX_SMS_MESSAGE_SIZE, mSmsMessageSize); + insert.bindLong(INDEX_MMS_EXPIRY, mMmsExpiry); + if (mMmsSubject != null) { + insert.bindString(INDEX_MMS_SUBJECT, mMmsSubject); + } + if (mMmsTransactionId != null) { + insert.bindString(INDEX_MMS_TRANSACTION_ID, mMmsTransactionId); + } + if (mMmsContentLocation != null) { + insert.bindString(INDEX_MMS_CONTENT_LOCATION, mMmsContentLocation); + } + insert.bindLong(INDEX_RAW_TELEPHONY_STATUS, mRawStatus); + insert.bindLong(INDEX_RETRY_START_TIMESTAMP, mRetryStartTimestamp); + return insert; + } + + public final String getMessageId() { + return mMessageId; + } + + public final String getConversationId() { + return mConversationId; + } + + public final String getParticipantId() { + return mParticipantId; + } + + public final String getSelfId() { + return mSelfId; + } + + public final long getSentTimeStamp() { + return mSentTimestamp; + } + + public final long getReceivedTimeStamp() { + return mReceivedTimestamp; + } + + public final String getFormattedReceivedTimeStamp() { + return Dates.getMessageTimeString(mReceivedTimestamp).toString(); + } + + public final int getProtocol() { + return mProtocol; + } + + public final int getStatus() { + return mStatus; + } + + public final Uri getSmsMessageUri() { + return mSmsMessageUri; + } + + public final int getSmsPriority() { + return mSmsPriority; + } + + public final long getSmsMessageSize() { + return mSmsMessageSize; + } + + public final String getMmsSubject() { + return mMmsSubject; + } + + public final void setMmsSubject(final String subject) { + mMmsSubject = subject; + } + + public final String getMmsContentLocation() { + return mMmsContentLocation; + } + + public final String getMmsTransactionId() { + return mMmsTransactionId; + } + + public final boolean getMessageSeen() { + return mSeen; + } + + /** + * For incoming MMS messages this returns the retrieve-status value + * For sent MMS messages this returns the response-status value + * See PduHeaders.java for possible values + * Otherwise (SMS etc) this is RAW_TELEPHONY_STATUS_UNDEFINED + */ + public final int getRawTelephonyStatus() { + return mRawStatus; + } + + public final void setMessageSeen(final boolean hasSeen) { + mSeen = hasSeen; + } + + public final boolean getInResendWindow(final long now) { + final long maxAgeToResend = BugleGservices.get().getLong( + BugleGservicesKeys.MESSAGE_RESEND_TIMEOUT_MS, + BugleGservicesKeys.MESSAGE_RESEND_TIMEOUT_MS_DEFAULT); + final long age = now - mRetryStartTimestamp; + return age < maxAgeToResend; + } + + public final boolean getInDownloadWindow(final long now) { + final long maxAgeToRedownload = BugleGservices.get().getLong( + BugleGservicesKeys.MESSAGE_DOWNLOAD_TIMEOUT_MS, + BugleGservicesKeys.MESSAGE_DOWNLOAD_TIMEOUT_MS_DEFAULT); + final long age = now - mRetryStartTimestamp; + return age < maxAgeToRedownload; + } + + static boolean getShowDownloadMessage(final int status) { + if (OsUtil.isSecondaryUser()) { + // Secondary users can't download mms's. Mms's are downloaded by bugle running as the + // primary user. + return false; + } + // Should show option for manual download iff status is manual download or failed + return (status == BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED || + status == BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD || + // If debug is enabled, allow to download an expired or unavailable message. + (DebugUtils.isDebugEnabled() + && status == BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE)); + } + + public boolean canDownloadMessage() { + if (OsUtil.isSecondaryUser()) { + // Secondary users can't download mms's. Mms's are downloaded by bugle running as the + // primary user. + return false; + } + // Can download iff status is retrying auto/manual downloading + return (mStatus == BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD || + mStatus == BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD); + } + + public boolean canRedownloadMessage() { + if (OsUtil.isSecondaryUser()) { + // Secondary users can't download mms's. Mms's are downloaded by bugle running as the + // primary user. + return false; + } + // Can redownload iff status is manual download not started or download failed + return (mStatus == BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED || + mStatus == BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD || + // If debug is enabled, allow to download an expired or unavailable message. + (DebugUtils.isDebugEnabled() + && mStatus == BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE)); + } + + static boolean getShowResendMessage(final int status) { + // Should show option to resend iff status is failed + return (status == BUGLE_STATUS_OUTGOING_FAILED); + } + + static boolean getOneClickResendMessage(final int status, final int rawStatus) { + // Should show option to resend iff status is failed + return (status == BUGLE_STATUS_OUTGOING_FAILED + && rawStatus == RAW_TELEPHONY_STATUS_UNDEFINED); + } + + public boolean canResendMessage() { + // Manual retry allowed only from failed + return (mStatus == BUGLE_STATUS_OUTGOING_FAILED); + } + + public boolean canSendMessage() { + // Sending messages must be in yet_to_send or awaiting_retry state + return (mStatus == BUGLE_STATUS_OUTGOING_YET_TO_SEND || + mStatus == BUGLE_STATUS_OUTGOING_AWAITING_RETRY); + } + + public final boolean getYetToSend() { + return (mStatus == BUGLE_STATUS_OUTGOING_YET_TO_SEND); + } + + public final boolean getIsMms() { + return mProtocol == MessageData.PROTOCOL_MMS + || mProtocol == MessageData.PROTOCOL_MMS_PUSH_NOTIFICATION; + } + + public static final boolean getIsMmsNotification(final int protocol) { + return (protocol == MessageData.PROTOCOL_MMS_PUSH_NOTIFICATION); + } + + public final boolean getIsMmsNotification() { + return getIsMmsNotification(mProtocol); + } + + public static final boolean getIsSms(final int protocol) { + return protocol == (MessageData.PROTOCOL_SMS); + } + + public final boolean getIsSms() { + return getIsSms(mProtocol); + } + + public static boolean getIsIncoming(final int status) { + return (status >= MessageData.BUGLE_STATUS_FIRST_INCOMING); + } + + public boolean getIsIncoming() { + return getIsIncoming(mStatus); + } + + public long getRetryStartTimestamp() { + return mRetryStartTimestamp; + } + + public final String getMessageText() { + final String separator = System.getProperty("line.separator"); + final StringBuilder text = new StringBuilder(); + for (final MessagePartData part : mParts) { + if (!part.isAttachment() && !TextUtils.isEmpty(part.getText())) { + if (text.length() > 0) { + text.append(separator); + } + text.append(part.getText()); + } + } + return text.toString(); + } + + /** + * Takes all captions from attachments and adds them as a prefix to the first text part or + * appends a text part + */ + public final void consolidateText() { + final String separator = System.getProperty("line.separator"); + final StringBuilder captionText = new StringBuilder(); + MessagePartData firstTextPart = null; + int firstTextPartIndex = -1; + for (int i = 0; i < mParts.size(); i++) { + final MessagePartData part = mParts.get(i); + if (firstTextPart == null && !part.isAttachment()) { + firstTextPart = part; + firstTextPartIndex = i; + } + if (part.isAttachment() && !TextUtils.isEmpty(part.getText())) { + if (captionText.length() > 0) { + captionText.append(separator); + } + captionText.append(part.getText()); + } + } + + if (captionText.length() == 0) { + // Nothing to consolidate + return; + } + + if (firstTextPart == null) { + addPart(MessagePartData.createTextMessagePart(captionText.toString())); + } else { + final String partText = firstTextPart.getText(); + if (partText.length() > 0) { + captionText.append(separator); + captionText.append(partText); + } + mParts.set(firstTextPartIndex, + MessagePartData.createTextMessagePart(captionText.toString())); + } + } + + public final MessagePartData getFirstAttachment() { + for (final MessagePartData part : mParts) { + if (part.isAttachment()) { + return part; + } + } + return null; + } + + /** + * Updates the messageId for this message. + * Can be used to reset the messageId prior to persisting (which will assign a new messageId) + * or can be called on a message that does not yet have a valid messageId to set it. + */ + public void updateMessageId(final String messageId) { + Assert.isTrue(TextUtils.isEmpty(messageId) || TextUtils.isEmpty(mMessageId)); + mMessageId = messageId; + + // TODO : This should probably also call updateMessageId on the message parts. We + // may also want to make messages effectively immutable once they have a valid message id. + } + + public final void updateSendingMessage(final String conversationId, final Uri messageUri, + final long timestamp) { + mConversationId = conversationId; + mSmsMessageUri = messageUri; + mRead = true; + mSeen = true; + mReceivedTimestamp = timestamp; + mSentTimestamp = timestamp; + mStatus = BUGLE_STATUS_OUTGOING_YET_TO_SEND; + mRetryStartTimestamp = timestamp; + } + + public final void markMessageManualResend(final long timestamp) { + // Manual send updates timestamp and transitions back to initial sending status. + mReceivedTimestamp = timestamp; + mSentTimestamp = timestamp; + mStatus = BUGLE_STATUS_OUTGOING_SENDING; + } + + public final void markMessageSending(final long timestamp) { + // Initial send + mStatus = BUGLE_STATUS_OUTGOING_SENDING; + mSentTimestamp = timestamp; + } + + public final void markMessageResending(final long timestamp) { + // Auto resend of message + mStatus = BUGLE_STATUS_OUTGOING_RESENDING; + mSentTimestamp = timestamp; + } + + public final void markMessageSent(final long timestamp) { + mSentTimestamp = timestamp; + mStatus = BUGLE_STATUS_OUTGOING_COMPLETE; + } + + public final void markMessageFailed(final long timestamp) { + mSentTimestamp = timestamp; + mStatus = BUGLE_STATUS_OUTGOING_FAILED; + } + + public final void markMessageFailedEmergencyNumber(final long timestamp) { + mSentTimestamp = timestamp; + mStatus = BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER; + } + + public final void markMessageNotSent(final long timestamp) { + mSentTimestamp = timestamp; + mStatus = BUGLE_STATUS_OUTGOING_AWAITING_RETRY; + } + + public final void updateSizesForImageParts() { + for (final MessagePartData part : getParts()) { + part.decodeAndSaveSizeIfImage(false /* saveToStorage */); + } + } + + public final void setRetryStartTimestamp(final long timestamp) { + mRetryStartTimestamp = timestamp; + } + + public final void setRawTelephonyStatus(final int rawStatus) { + mRawStatus = rawStatus; + } + + public boolean hasContent() { + return !TextUtils.isEmpty(mMmsSubject) || + getFirstAttachment() != null || + !TextUtils.isEmpty(getMessageText()); + } + + public final void bindSelfId(final String selfId) { + mSelfId = selfId; + } + + public final void bindParticipantId(final String participantId) { + mParticipantId = participantId; + } + + protected MessageData(final Parcel in) { + mMessageId = in.readString(); + mConversationId = in.readString(); + mParticipantId = in.readString(); + mSelfId = in.readString(); + mSentTimestamp = in.readLong(); + mReceivedTimestamp = in.readLong(); + mSeen = (in.readInt() != 0); + mRead = (in.readInt() != 0); + mProtocol = in.readInt(); + mStatus = in.readInt(); + final String smsMessageUri = in.readString(); + mSmsMessageUri = (smsMessageUri == null ? null : Uri.parse(smsMessageUri)); + mSmsPriority = in.readInt(); + mSmsMessageSize = in.readLong(); + mMmsExpiry = in.readLong(); + mMmsSubject = in.readString(); + mMmsTransactionId = in.readString(); + mMmsContentLocation = in.readString(); + mRawStatus = in.readInt(); + mRetryStartTimestamp = in.readLong(); + + // Read parts + mParts = new ArrayList(); + final int partCount = in.readInt(); + for (int i = 0; i < partCount; i++) { + mParts.add((MessagePartData) in.readParcelable(MessagePartData.class.getClassLoader())); + } + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeString(mMessageId); + dest.writeString(mConversationId); + dest.writeString(mParticipantId); + dest.writeString(mSelfId); + dest.writeLong(mSentTimestamp); + dest.writeLong(mReceivedTimestamp); + dest.writeInt(mRead ? 1 : 0); + dest.writeInt(mSeen ? 1 : 0); + dest.writeInt(mProtocol); + dest.writeInt(mStatus); + final String smsMessageUri = (mSmsMessageUri == null) ? null : mSmsMessageUri.toString(); + dest.writeString(smsMessageUri); + dest.writeInt(mSmsPriority); + dest.writeLong(mSmsMessageSize); + dest.writeLong(mMmsExpiry); + dest.writeString(mMmsSubject); + dest.writeString(mMmsTransactionId); + dest.writeString(mMmsContentLocation); + dest.writeInt(mRawStatus); + dest.writeLong(mRetryStartTimestamp); + + // Write parts + dest.writeInt(mParts.size()); + for (final MessagePartData messagePartData : mParts) { + dest.writeParcelable(messagePartData, flags); + } + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public MessageData createFromParcel(final Parcel in) { + return new MessageData(in); + } + + @Override + public MessageData[] newArray(final int size) { + return new MessageData[size]; + } + }; + + @Override + public String toString() { + return toString(mMessageId, mParts); + } + + public static String toString(String messageId, List parts) { + StringBuilder sb = new StringBuilder(); + if (messageId != null) { + sb.append(messageId); + sb.append(": "); + } + for (MessagePartData part : parts) { + sb.append(part.toString()); + sb.append(" "); + } + return sb.toString(); + } +} diff --git a/src/com/android/messaging/datamodel/data/MessagePartData.java b/src/com/android/messaging/datamodel/data/MessagePartData.java new file mode 100644 index 0000000..fffaca8 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/MessagePartData.java @@ -0,0 +1,534 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.data; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteStatement; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.PartColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.action.UpdateMessagePartSizeAction; +import com.android.messaging.datamodel.media.ImageRequest; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.GifTranscoder; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.UriUtil; + +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + +/** + * Represents a single message part. Messages consist of one or more parts which may contain + * either text or media. + */ +public class MessagePartData implements Parcelable { + public static final int UNSPECIFIED_SIZE = MessagingContentProvider.UNSPECIFIED_SIZE; + public static final String[] ACCEPTABLE_IMAGE_TYPES = + new String[] { ContentType.IMAGE_JPEG, ContentType.IMAGE_JPG, ContentType.IMAGE_PNG, + ContentType.IMAGE_GIF }; + + private static final String[] sProjection = { + PartColumns._ID, + PartColumns.MESSAGE_ID, + PartColumns.TEXT, + PartColumns.CONTENT_URI, + PartColumns.CONTENT_TYPE, + PartColumns.WIDTH, + PartColumns.HEIGHT, + }; + + private static final int INDEX_ID = 0; + private static final int INDEX_MESSAGE_ID = 1; + private static final int INDEX_TEXT = 2; + private static final int INDEX_CONTENT_URI = 3; + private static final int INDEX_CONTENT_TYPE = 4; + private static final int INDEX_WIDTH = 5; + private static final int INDEX_HEIGHT = 6; + // This isn't part of the projection + private static final int INDEX_CONVERSATION_ID = 7; + + // SQL statement to insert a "complete" message part row (columns based on projection above). + private static final String INSERT_MESSAGE_PART_SQL = + "INSERT INTO " + DatabaseHelper.PARTS_TABLE + " ( " + + TextUtils.join(",", Arrays.copyOfRange(sProjection, 1, INDEX_CONVERSATION_ID)) + + ", " + PartColumns.CONVERSATION_ID + + ") VALUES (?, ?, ?, ?, ?, ?, ?)"; + + // Used for stuff that's ignored or arbitrarily compressed. + private static final long NO_MINIMUM_SIZE = 0; + + private String mPartId; + private String mMessageId; + private String mText; + private Uri mContentUri; + private String mContentType; + private int mWidth; + private int mHeight; + // This kind of part can only be attached once and with no other attachment + private boolean mSinglePartOnly; + + /** Transient data: true if destroy was already called */ + private boolean mDestroyed; + + /** + * Create an "empty" message part + */ + protected MessagePartData() { + this(null, null, UNSPECIFIED_SIZE, UNSPECIFIED_SIZE); + } + + /** + * Create a populated text message part + */ + protected MessagePartData(final String messageText) { + this(null, messageText, ContentType.TEXT_PLAIN, null, UNSPECIFIED_SIZE, UNSPECIFIED_SIZE, + false /*singlePartOnly*/); + } + + /** + * Create a populated attachment message part + */ + protected MessagePartData(final String contentType, final Uri contentUri, + final int width, final int height) { + this(null, null, contentType, contentUri, width, height, false /*singlePartOnly*/); + } + + /** + * Create a populated attachment message part, with additional caption text + */ + protected MessagePartData(final String messageText, final String contentType, + final Uri contentUri, final int width, final int height) { + this(null, messageText, contentType, contentUri, width, height, false /*singlePartOnly*/); + } + + /** + * Create a populated attachment message part, with additional caption text, single part only + */ + protected MessagePartData(final String messageText, final String contentType, + final Uri contentUri, final int width, final int height, final boolean singlePartOnly) { + this(null, messageText, contentType, contentUri, width, height, singlePartOnly); + } + + /** + * Create a populated message part + */ + private MessagePartData(final String messageId, final String messageText, + final String contentType, final Uri contentUri, final int width, final int height, + final boolean singlePartOnly) { + mMessageId = messageId; + mText = messageText; + mContentType = contentType; + mContentUri = contentUri; + mWidth = width; + mHeight = height; + mSinglePartOnly = singlePartOnly; + } + + /** + * Create a "text" message part + */ + public static MessagePartData createTextMessagePart(final String messageText) { + return new MessagePartData(messageText); + } + + /** + * Create a "media" message part + */ + public static MessagePartData createMediaMessagePart(final String contentType, + final Uri contentUri, final int width, final int height) { + return new MessagePartData(contentType, contentUri, width, height); + } + + /** + * Create a "media" message part with caption + */ + public static MessagePartData createMediaMessagePart(final String caption, + final String contentType, final Uri contentUri, final int width, final int height) { + return new MessagePartData(null, caption, contentType, contentUri, width, height, + false /*singlePartOnly*/ + ); + } + + /** + * Create an empty "text" message part + */ + public static MessagePartData createEmptyMessagePart() { + return new MessagePartData(""); + } + + /** + * Creates a new message part reading from the cursor + */ + public static MessagePartData createFromCursor(final Cursor cursor) { + final MessagePartData part = new MessagePartData(); + part.bind(cursor); + return part; + } + + public static String[] getProjection() { + return sProjection; + } + + /** + * Updates the part id. + * Can be used to reset the partId just prior to persisting (which will assign a new partId) + * or can be called on a part that does not yet have a valid part id to set it. + */ + public void updatePartId(final String partId) { + Assert.isTrue(TextUtils.isEmpty(partId) || TextUtils.isEmpty(mPartId)); + mPartId = partId; + } + + /** + * Updates the messageId for the part. + * Can be used to reset the messageId prior to persisting (which will assign a new messageId) + * or can be called on a part that does not yet have a valid messageId to set it. + */ + public void updateMessageId(final String messageId) { + Assert.isTrue(TextUtils.isEmpty(messageId) || TextUtils.isEmpty(mMessageId)); + mMessageId = messageId; + } + + protected static String getMessageId(final Cursor cursor) { + return cursor.getString(INDEX_MESSAGE_ID); + } + + protected void bind(final Cursor cursor) { + mPartId = cursor.getString(INDEX_ID); + mMessageId = cursor.getString(INDEX_MESSAGE_ID); + mText = cursor.getString(INDEX_TEXT); + mContentUri = UriUtil.uriFromString(cursor.getString(INDEX_CONTENT_URI)); + mContentType = cursor.getString(INDEX_CONTENT_TYPE); + mWidth = cursor.getInt(INDEX_WIDTH); + mHeight = cursor.getInt(INDEX_HEIGHT); + } + + public final void populate(final ContentValues values) { + // Must have a valid messageId on a part + Assert.isTrue(!TextUtils.isEmpty(mMessageId)); + values.put(PartColumns.MESSAGE_ID, mMessageId); + values.put(PartColumns.TEXT, mText); + values.put(PartColumns.CONTENT_URI, UriUtil.stringFromUri(mContentUri)); + values.put(PartColumns.CONTENT_TYPE, mContentType); + if (mWidth != UNSPECIFIED_SIZE) { + values.put(PartColumns.WIDTH, mWidth); + } + if (mHeight != UNSPECIFIED_SIZE) { + values.put(PartColumns.HEIGHT, mHeight); + } + } + + /** + * Note this is not thread safe so callers need to make sure they own the wrapper + statements + * while they call this and use the returned value. + */ + public SQLiteStatement getInsertStatement(final DatabaseWrapper db, + final String conversationId) { + final SQLiteStatement insert = db.getStatementInTransaction( + DatabaseWrapper.INDEX_INSERT_MESSAGE_PART, INSERT_MESSAGE_PART_SQL); + insert.clearBindings(); + insert.bindString(INDEX_MESSAGE_ID, mMessageId); + if (mText != null) { + insert.bindString(INDEX_TEXT, mText); + } + if (mContentUri != null) { + insert.bindString(INDEX_CONTENT_URI, mContentUri.toString()); + } + if (mContentType != null) { + insert.bindString(INDEX_CONTENT_TYPE, mContentType); + } + insert.bindLong(INDEX_WIDTH, mWidth); + insert.bindLong(INDEX_HEIGHT, mHeight); + insert.bindString(INDEX_CONVERSATION_ID, conversationId); + return insert; + } + + public final String getPartId() { + return mPartId; + } + + public final String getMessageId() { + return mMessageId; + } + + public final String getText() { + return mText; + } + + public final Uri getContentUri() { + return mContentUri; + } + + public boolean isAttachment() { + return mContentUri != null; + } + + public boolean isText() { + return ContentType.isTextType(mContentType); + } + + public boolean isImage() { + return ContentType.isImageType(mContentType); + } + + public boolean isMedia() { + return ContentType.isMediaType(mContentType); + } + + public boolean isVCard() { + return ContentType.isVCardType(mContentType); + } + + public boolean isAudio() { + return ContentType.isAudioType(mContentType); + } + + public boolean isVideo() { + return ContentType.isVideoType(mContentType); + } + + public final String getContentType() { + return mContentType; + } + + public final int getWidth() { + return mWidth; + } + + public final int getHeight() { + return mHeight; + } + + /** + * + * @return true if this part can only exist by itself, with no other attachments + */ + public boolean getSinglePartOnly() { + return mSinglePartOnly; + } + + @Override + public int describeContents() { + return 0; + } + + protected MessagePartData(final Parcel in) { + mMessageId = in.readString(); + mText = in.readString(); + mContentUri = UriUtil.uriFromString(in.readString()); + mContentType = in.readString(); + mWidth = in.readInt(); + mHeight = in.readInt(); + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + Assert.isTrue(!mDestroyed); + dest.writeString(mMessageId); + dest.writeString(mText); + dest.writeString(UriUtil.stringFromUri(mContentUri)); + dest.writeString(mContentType); + dest.writeInt(mWidth); + dest.writeInt(mHeight); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (!(o instanceof MessagePartData)) { + return false; + } + + MessagePartData lhs = (MessagePartData) o; + return mWidth == lhs.mWidth && mHeight == lhs.mHeight && + TextUtils.equals(mMessageId, lhs.mMessageId) && + TextUtils.equals(mText, lhs.mText) && + TextUtils.equals(mContentType, lhs.mContentType) && + (mContentUri == null ? lhs.mContentUri == null + : mContentUri.equals(lhs.mContentUri)); + } + + @Override public int hashCode() { + int result = 17; + result = 31 * result + mWidth; + result = 31 * result + mHeight; + result = 31 * result + (mMessageId == null ? 0 : mMessageId.hashCode()); + result = 31 * result + (mText == null ? 0 : mText.hashCode()); + result = 31 * result + (mContentType == null ? 0 : mContentType.hashCode()); + result = 31 * result + (mContentUri == null ? 0 : mContentUri.hashCode()); + return result; + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public MessagePartData createFromParcel(final Parcel in) { + return new MessagePartData(in); + } + + @Override + public MessagePartData[] newArray(final int size) { + return new MessagePartData[size]; + } + }; + + protected Uri shouldDestroy() { + // We should never double-destroy. + Assert.isTrue(!mDestroyed); + mDestroyed = true; + Uri contentUri = mContentUri; + mContentUri = null; + mContentType = null; + // Only destroy the image if it's staged in our scratch space. + if (!MediaScratchFileProvider.isMediaScratchSpaceUri(contentUri)) { + contentUri = null; + } + return contentUri; + } + + /** + * If application owns content associated with this part delete it (on background thread) + */ + public void destroyAsync() { + final Uri contentUri = shouldDestroy(); + if (contentUri != null) { + SafeAsyncTask.executeOnThreadPool(new Runnable() { + @Override + public void run() { + Factory.get().getApplicationContext().getContentResolver().delete( + contentUri, null, null); + } + }); + } + } + + /** + * If application owns content associated with this part delete it + */ + public void destroySync() { + final Uri contentUri = shouldDestroy(); + if (contentUri != null) { + Factory.get().getApplicationContext().getContentResolver().delete( + contentUri, null, null); + } + } + + /** + * If this is an image part, decode the image header and potentially save the size to the db. + */ + public void decodeAndSaveSizeIfImage(final boolean saveToStorage) { + if (isImage()) { + final Rect imageSize = ImageUtils.decodeImageBounds( + Factory.get().getApplicationContext(), mContentUri); + if (imageSize.width() != ImageRequest.UNSPECIFIED_SIZE && + imageSize.height() != ImageRequest.UNSPECIFIED_SIZE) { + mWidth = imageSize.width(); + mHeight = imageSize.height(); + if (saveToStorage) { + UpdateMessagePartSizeAction.updateSize(mPartId, mWidth, mHeight); + } + } + } + } + + /** + * Computes the minimum size that this MessagePartData could be compressed/downsampled/encoded + * before sending to meet the maximum message size imposed by the carriers. This is used to + * determine right before sending a message whether a message could possibly be sent. If not + * then the user is given a chance to unselect some/all of the attachments. + * + * TODO: computing the minimum size could be expensive. Should we cache the + * computed value in db to be retrieved later? + * + * @return the carrier-independent minimum size, in bytes. + */ + @DoesNotRunOnMainThread + public long getMinimumSizeInBytesForSending() { + Assert.isNotMainThread(); + if (!isAttachment()) { + // No limit is imposed on non-attachment part (i.e. plain text), so treat it as zero. + return NO_MINIMUM_SIZE; + } else if (isImage()) { + // GIFs are resized by the native transcoder (exposed by GifTranscoder). + if (ImageUtils.isGif(mContentType, mContentUri)) { + final long originalImageSize = UriUtil.getContentSize(mContentUri); + // Wish we could save the size here, but we don't have a part id yet + decodeAndSaveSizeIfImage(false /* saveToStorage */); + return GifTranscoder.canBeTranscoded(mWidth, mHeight) ? + GifTranscoder.estimateFileSizeAfterTranscode(originalImageSize) + : originalImageSize; + } + // Other images should be arbitrarily resized by ImageResizer before sending. + return MmsUtils.MIN_IMAGE_BYTE_SIZE; + } else if (isAudio()) { + // Audios are already recorded with the lowest sampling settings (AMR_NB), so just + // return the file size as the minimum size. + return UriUtil.getContentSize(mContentUri); + } else if (isVideo()) { + final int mediaDurationMs = UriUtil.getMediaDurationMs(mContentUri); + return MmsUtils.MIN_VIDEO_BYTES_PER_SECOND * mediaDurationMs + / TimeUnit.SECONDS.toMillis(1); + } else if (isVCard()) { + // We can't compress vCards. + return UriUtil.getContentSize(mContentUri); + } else { + // This is some unknown media type that we don't know how to handle. Log an error + // and try sending it anyway. + LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "Unknown attachment type " + getContentType()); + return NO_MINIMUM_SIZE; + } + } + + @Override + public String toString() { + if (isText()) { + return LogUtil.sanitizePII(getText()); + } else { + return getContentType() + " (" + getContentUri() + ")"; + } + } + + /** + * + * @return true if this part can only exist by itself, with no other attachments + */ + public boolean isSinglePartOnly() { + return mSinglePartOnly; + } + + public void setSinglePartOnly(final boolean isSinglePartOnly) { + mSinglePartOnly = isSinglePartOnly; + } +} diff --git a/src/com/android/messaging/datamodel/data/ParticipantData.java b/src/com/android/messaging/datamodel/data/ParticipantData.java new file mode 100644 index 0000000..521c354 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ParticipantData.java @@ -0,0 +1,569 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.data; + +import android.content.ContentValues; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Color; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.v7.mms.MmsManager; +import android.telephony.SubscriptionInfo; +import android.text.TextUtils; + +import com.android.ex.chips.RecipientEntry; +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.sms.MmsSmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.TextUtil; + +/** + * A class that encapsulates all of the data for a specific participant in a conversation. + */ +public class ParticipantData implements Parcelable { + // We always use -1 as default/invalid sub id although system may give us anything negative + public static final int DEFAULT_SELF_SUB_ID = MmsManager.DEFAULT_SUB_ID; + + // This needs to be something apart from valid or DEFAULT_SELF_SUB_ID + public static final int OTHER_THAN_SELF_SUB_ID = DEFAULT_SELF_SUB_ID - 1; + + // Active slot ids are non-negative. Using -1 to designate to inactive self participants. + public static final int INVALID_SLOT_ID = -1; + + // TODO: may make sense to move this to common place? + public static final long PARTICIPANT_CONTACT_ID_NOT_RESOLVED = -1; + public static final long PARTICIPANT_CONTACT_ID_NOT_FOUND = -2; + + public static class ParticipantsQuery { + public static final String[] PROJECTION = new String[] { + ParticipantColumns._ID, + ParticipantColumns.SUB_ID, + ParticipantColumns.SIM_SLOT_ID, + ParticipantColumns.NORMALIZED_DESTINATION, + ParticipantColumns.SEND_DESTINATION, + ParticipantColumns.DISPLAY_DESTINATION, + ParticipantColumns.FULL_NAME, + ParticipantColumns.FIRST_NAME, + ParticipantColumns.PROFILE_PHOTO_URI, + ParticipantColumns.CONTACT_ID, + ParticipantColumns.LOOKUP_KEY, + ParticipantColumns.BLOCKED, + ParticipantColumns.SUBSCRIPTION_COLOR, + ParticipantColumns.SUBSCRIPTION_NAME, + ParticipantColumns.CONTACT_DESTINATION, + }; + + public static final int INDEX_ID = 0; + public static final int INDEX_SUB_ID = 1; + public static final int INDEX_SIM_SLOT_ID = 2; + public static final int INDEX_NORMALIZED_DESTINATION = 3; + public static final int INDEX_SEND_DESTINATION = 4; + public static final int INDEX_DISPLAY_DESTINATION = 5; + public static final int INDEX_FULL_NAME = 6; + public static final int INDEX_FIRST_NAME = 7; + public static final int INDEX_PROFILE_PHOTO_URI = 8; + public static final int INDEX_CONTACT_ID = 9; + public static final int INDEX_LOOKUP_KEY = 10; + public static final int INDEX_BLOCKED = 11; + public static final int INDEX_SUBSCRIPTION_COLOR = 12; + public static final int INDEX_SUBSCRIPTION_NAME = 13; + public static final int INDEX_CONTACT_DESTINATION = 14; + } + + /** + * @return The MMS unknown sender participant entity + */ + public static String getUnknownSenderDestination() { + // This is a hard coded string rather than a localized one because we don't want it to + // change when you change locale. + return "\u02BCUNKNOWN_SENDER!\u02BC"; + } + + private String mParticipantId; + private int mSubId; + private int mSlotId; + private String mNormalizedDestination; + private String mSendDestination; + private String mDisplayDestination; + private String mContactDestination; + private String mFullName; + private String mFirstName; + private String mProfilePhotoUri; + private long mContactId; + private String mLookupKey; + private int mSubscriptionColor; + private String mSubscriptionName; + private boolean mIsEmailAddress; + private boolean mBlocked; + + // Don't call constructor directly + private ParticipantData() { + } + + public static ParticipantData getFromCursor(final Cursor cursor) { + final ParticipantData pd = new ParticipantData(); + pd.mParticipantId = cursor.getString(ParticipantsQuery.INDEX_ID); + pd.mSubId = cursor.getInt(ParticipantsQuery.INDEX_SUB_ID); + pd.mSlotId = cursor.getInt(ParticipantsQuery.INDEX_SIM_SLOT_ID); + pd.mNormalizedDestination = cursor.getString( + ParticipantsQuery.INDEX_NORMALIZED_DESTINATION); + pd.mSendDestination = cursor.getString(ParticipantsQuery.INDEX_SEND_DESTINATION); + pd.mDisplayDestination = cursor.getString(ParticipantsQuery.INDEX_DISPLAY_DESTINATION); + pd.mContactDestination = cursor.getString(ParticipantsQuery.INDEX_CONTACT_DESTINATION); + pd.mFullName = cursor.getString(ParticipantsQuery.INDEX_FULL_NAME); + pd.mFirstName = cursor.getString(ParticipantsQuery.INDEX_FIRST_NAME); + pd.mProfilePhotoUri = cursor.getString(ParticipantsQuery.INDEX_PROFILE_PHOTO_URI); + pd.mContactId = cursor.getLong(ParticipantsQuery.INDEX_CONTACT_ID); + pd.mLookupKey = cursor.getString(ParticipantsQuery.INDEX_LOOKUP_KEY); + pd.mIsEmailAddress = MmsSmsUtils.isEmailAddress(pd.mSendDestination); + pd.mBlocked = cursor.getInt(ParticipantsQuery.INDEX_BLOCKED) != 0; + pd.mSubscriptionColor = cursor.getInt(ParticipantsQuery.INDEX_SUBSCRIPTION_COLOR); + pd.mSubscriptionName = cursor.getString(ParticipantsQuery.INDEX_SUBSCRIPTION_NAME); + pd.maybeSetupUnknownSender(); + return pd; + } + + public static ParticipantData getFromId(final DatabaseWrapper dbWrapper, + final String participantId) { + Cursor cursor = null; + try { + cursor = dbWrapper.query(DatabaseHelper.PARTICIPANTS_TABLE, + ParticipantsQuery.PROJECTION, + ParticipantColumns._ID + " =?", + new String[] { participantId }, null, null, null); + + if (cursor.moveToFirst()) { + return ParticipantData.getFromCursor(cursor); + } else { + return null; + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + public static ParticipantData getFromRecipientEntry(final RecipientEntry recipientEntry) { + final ParticipantData pd = new ParticipantData(); + pd.mParticipantId = null; + pd.mSubId = OTHER_THAN_SELF_SUB_ID; + pd.mSlotId = INVALID_SLOT_ID; + pd.mSendDestination = TextUtil.replaceUnicodeDigits(recipientEntry.getDestination()); + pd.mIsEmailAddress = MmsSmsUtils.isEmailAddress(pd.mSendDestination); + pd.mNormalizedDestination = pd.mIsEmailAddress ? + pd.mSendDestination : + PhoneUtils.getDefault().getCanonicalBySystemLocale(pd.mSendDestination); + pd.mDisplayDestination = pd.mIsEmailAddress ? + pd.mNormalizedDestination : + PhoneUtils.getDefault().formatForDisplay(pd.mNormalizedDestination); + pd.mFullName = recipientEntry.getDisplayName(); + pd.mFirstName = null; + pd.mProfilePhotoUri = (recipientEntry.getPhotoThumbnailUri() == null) ? null : + recipientEntry.getPhotoThumbnailUri().toString(); + pd.mContactId = recipientEntry.getContactId(); + if (pd.mContactId < 0) { + // ParticipantData only supports real contact ids (>=0) based on faith that the contacts + // provider will continue to only use non-negative ids. The UI uses contactId < 0 for + // special handling. We convert those to 'not resolved' + pd.mContactId = PARTICIPANT_CONTACT_ID_NOT_RESOLVED; + } + pd.mLookupKey = recipientEntry.getLookupKey(); + pd.mBlocked = false; + pd.mSubscriptionColor = Color.TRANSPARENT; + pd.mSubscriptionName = null; + pd.maybeSetupUnknownSender(); + return pd; + } + + // Shared code for getFromRawPhoneBySystemLocale and getFromRawPhoneBySimLocale + private static ParticipantData getFromRawPhone(final String phoneNumber) { + Assert.isTrue(phoneNumber != null); + final ParticipantData pd = new ParticipantData(); + pd.mParticipantId = null; + pd.mSubId = OTHER_THAN_SELF_SUB_ID; + pd.mSlotId = INVALID_SLOT_ID; + pd.mSendDestination = TextUtil.replaceUnicodeDigits(phoneNumber); + pd.mIsEmailAddress = MmsSmsUtils.isEmailAddress(pd.mSendDestination); + pd.mFullName = null; + pd.mFirstName = null; + pd.mProfilePhotoUri = null; + pd.mContactId = PARTICIPANT_CONTACT_ID_NOT_RESOLVED; + pd.mLookupKey = null; + pd.mBlocked = false; + pd.mSubscriptionColor = Color.TRANSPARENT; + pd.mSubscriptionName = null; + return pd; + } + + /** + * Get an instance from a raw phone number and using system locale to normalize it. + * + * Use this when creating a participant that is for displaying UI and not associated + * with a specific SIM. For example, when creating a conversation using user entered + * phone number. + * + * @param phoneNumber The raw phone number + * @return instance + */ + public static ParticipantData getFromRawPhoneBySystemLocale(final String phoneNumber) { + final ParticipantData pd = getFromRawPhone(phoneNumber); + pd.mNormalizedDestination = pd.mIsEmailAddress ? + pd.mSendDestination : + PhoneUtils.getDefault().getCanonicalBySystemLocale(pd.mSendDestination); + pd.mDisplayDestination = pd.mIsEmailAddress ? + pd.mNormalizedDestination : + PhoneUtils.getDefault().formatForDisplay(pd.mNormalizedDestination); + pd.maybeSetupUnknownSender(); + return pd; + } + + /** + * Get an instance from a raw phone number and using SIM or system locale to normalize it. + * + * Use this when creating a participant that is associated with a specific SIM. For example, + * the sender of a received message or the recipient of a sending message that is already + * targeted at a specific SIM. + * + * @param phoneNumber The raw phone number + * @return instance + */ + public static ParticipantData getFromRawPhoneBySimLocale( + final String phoneNumber, final int subId) { + final ParticipantData pd = getFromRawPhone(phoneNumber); + pd.mNormalizedDestination = pd.mIsEmailAddress ? + pd.mSendDestination : + PhoneUtils.get(subId).getCanonicalBySimLocale(pd.mSendDestination); + pd.mDisplayDestination = pd.mIsEmailAddress ? + pd.mNormalizedDestination : + PhoneUtils.getDefault().formatForDisplay(pd.mNormalizedDestination); + pd.maybeSetupUnknownSender(); + return pd; + } + + public static ParticipantData getSelfParticipant(final int subId) { + Assert.isTrue(subId != OTHER_THAN_SELF_SUB_ID); + final ParticipantData pd = new ParticipantData(); + pd.mParticipantId = null; + pd.mSubId = subId; + pd.mSlotId = INVALID_SLOT_ID; + pd.mIsEmailAddress = false; + pd.mSendDestination = null; + pd.mNormalizedDestination = null; + pd.mDisplayDestination = null; + pd.mFullName = null; + pd.mFirstName = null; + pd.mProfilePhotoUri = null; + pd.mContactId = PARTICIPANT_CONTACT_ID_NOT_RESOLVED; + pd.mLookupKey = null; + pd.mBlocked = false; + pd.mSubscriptionColor = Color.TRANSPARENT; + pd.mSubscriptionName = null; + return pd; + } + + private void maybeSetupUnknownSender() { + if (isUnknownSender()) { + // Because your locale may change, we setup the display string for the unknown sender + // on the fly rather than relying on the version in the database. + final Resources resources = Factory.get().getApplicationContext().getResources(); + mDisplayDestination = resources.getString(R.string.unknown_sender); + mFullName = mDisplayDestination; + } + } + + public String getNormalizedDestination() { + return mNormalizedDestination; + } + + public String getSendDestination() { + return mSendDestination; + } + + public String getDisplayDestination() { + return mDisplayDestination; + } + + public String getContactDestination() { + return mContactDestination; + } + + public String getFullName() { + return mFullName; + } + + public String getFirstName() { + return mFirstName; + } + + public String getDisplayName(final boolean preferFullName) { + if (preferFullName) { + // Prefer full name over first name + if (!TextUtils.isEmpty(mFullName)) { + return mFullName; + } + if (!TextUtils.isEmpty(mFirstName)) { + return mFirstName; + } + } else { + // Prefer first name over full name + if (!TextUtils.isEmpty(mFirstName)) { + return mFirstName; + } + if (!TextUtils.isEmpty(mFullName)) { + return mFullName; + } + } + + // Fallback to the display destination + if (!TextUtils.isEmpty(mDisplayDestination)) { + return mDisplayDestination; + } + + return Factory.get().getApplicationContext().getResources().getString( + R.string.unknown_sender); + } + + public String getProfilePhotoUri() { + return mProfilePhotoUri; + } + + public long getContactId() { + return mContactId; + } + + public String getLookupKey() { + return mLookupKey; + } + + public boolean updatePhoneNumberForSelfIfChanged() { + final String phoneNumber = + PhoneUtils.get(mSubId).getCanonicalForSelf(true/*allowOverride*/); + boolean changed = false; + if (isSelf() && !TextUtils.equals(phoneNumber, mNormalizedDestination)) { + mNormalizedDestination = phoneNumber; + mSendDestination = phoneNumber; + mDisplayDestination = mIsEmailAddress ? + phoneNumber : + PhoneUtils.getDefault().formatForDisplay(phoneNumber); + changed = true; + } + return changed; + } + + public boolean updateSubscriptionInfoForSelfIfChanged(final SubscriptionInfo subscriptionInfo) { + boolean changed = false; + if (isSelf()) { + if (subscriptionInfo == null) { + // The subscription is inactive. Check if the participant is still active. + if (isActiveSubscription()) { + mSlotId = INVALID_SLOT_ID; + mSubscriptionColor = Color.TRANSPARENT; + mSubscriptionName = ""; + changed = true; + } + } else { + final int slotId = subscriptionInfo.getSimSlotIndex(); + final int color = subscriptionInfo.getIconTint(); + final CharSequence name = subscriptionInfo.getDisplayName(); + if (mSlotId != slotId || mSubscriptionColor != color || mSubscriptionName != name) { + mSlotId = slotId; + mSubscriptionColor = color; + mSubscriptionName = name.toString(); + changed = true; + } + } + } + return changed; + } + + public void setFullName(final String fullName) { + mFullName = fullName; + } + + public void setFirstName(final String firstName) { + mFirstName = firstName; + } + + public void setProfilePhotoUri(final String profilePhotoUri) { + mProfilePhotoUri = profilePhotoUri; + } + + public void setContactId(final long contactId) { + mContactId = contactId; + } + + public void setLookupKey(final String lookupKey) { + mLookupKey = lookupKey; + } + + public void setSendDestination(final String destination) { + mSendDestination = destination; + } + + public void setContactDestination(final String destination) { + mContactDestination = destination; + } + + public int getSubId() { + return mSubId; + } + + /** + * @return whether this sub is active. Note that {@link ParticipantData#DEFAULT_SELF_SUB_ID} is + * is considered as active if there is any active SIM. + */ + public boolean isActiveSubscription() { + return mSlotId != INVALID_SLOT_ID; + } + + public boolean isDefaultSelf() { + return mSubId == ParticipantData.DEFAULT_SELF_SUB_ID; + } + + public int getSlotId() { + return mSlotId; + } + + /** + * Slot IDs in the subscription manager is zero-based, but we want to show it + * as 1-based in UI. + */ + public int getDisplaySlotId() { + return getSlotId() + 1; + } + + public int getSubscriptionColor() { + Assert.isTrue(isActiveSubscription()); + // Force the alpha channel to 0xff to ensure the returned color is solid. + return mSubscriptionColor | 0xff000000; + } + + public String getSubscriptionName() { + Assert.isTrue(isActiveSubscription()); + return mSubscriptionName; + } + + public String getId() { + return mParticipantId; + } + + public boolean isSelf() { + return (mSubId != OTHER_THAN_SELF_SUB_ID); + } + + public boolean isEmail() { + return mIsEmailAddress; + } + + public boolean isContactIdResolved() { + return (mContactId != PARTICIPANT_CONTACT_ID_NOT_RESOLVED); + } + + public boolean isBlocked() { + return mBlocked; + } + + public boolean isUnknownSender() { + final String unknownSender = ParticipantData.getUnknownSenderDestination(); + return (TextUtils.equals(mSendDestination, unknownSender)); + } + + public ContentValues toContentValues() { + final ContentValues values = new ContentValues(); + values.put(ParticipantColumns.SUB_ID, mSubId); + values.put(ParticipantColumns.SIM_SLOT_ID, mSlotId); + values.put(DatabaseHelper.ParticipantColumns.SEND_DESTINATION, mSendDestination); + + if (!isUnknownSender()) { + values.put(DatabaseHelper.ParticipantColumns.DISPLAY_DESTINATION, mDisplayDestination); + values.put(DatabaseHelper.ParticipantColumns.NORMALIZED_DESTINATION, + mNormalizedDestination); + values.put(ParticipantColumns.FULL_NAME, mFullName); + values.put(ParticipantColumns.FIRST_NAME, mFirstName); + } + + values.put(ParticipantColumns.PROFILE_PHOTO_URI, mProfilePhotoUri); + values.put(ParticipantColumns.CONTACT_ID, mContactId); + values.put(ParticipantColumns.LOOKUP_KEY, mLookupKey); + values.put(ParticipantColumns.BLOCKED, mBlocked); + values.put(ParticipantColumns.SUBSCRIPTION_COLOR, mSubscriptionColor); + values.put(ParticipantColumns.SUBSCRIPTION_NAME, mSubscriptionName); + return values; + } + + public ParticipantData(final Parcel in) { + mParticipantId = in.readString(); + mSubId = in.readInt(); + mSlotId = in.readInt(); + mNormalizedDestination = in.readString(); + mSendDestination = in.readString(); + mDisplayDestination = in.readString(); + mFullName = in.readString(); + mFirstName = in.readString(); + mProfilePhotoUri = in.readString(); + mContactId = in.readLong(); + mLookupKey = in.readString(); + mIsEmailAddress = in.readInt() != 0; + mBlocked = in.readInt() != 0; + mSubscriptionColor = in.readInt(); + mSubscriptionName = in.readString(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeString(mParticipantId); + dest.writeInt(mSubId); + dest.writeInt(mSlotId); + dest.writeString(mNormalizedDestination); + dest.writeString(mSendDestination); + dest.writeString(mDisplayDestination); + dest.writeString(mFullName); + dest.writeString(mFirstName); + dest.writeString(mProfilePhotoUri); + dest.writeLong(mContactId); + dest.writeString(mLookupKey); + dest.writeInt(mIsEmailAddress ? 1 : 0); + dest.writeInt(mBlocked ? 1 : 0); + dest.writeInt(mSubscriptionColor); + dest.writeString(mSubscriptionName); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public ParticipantData createFromParcel(final Parcel in) { + return new ParticipantData(in); + } + + @Override + public ParticipantData[] newArray(final int size) { + return new ParticipantData[size]; + } + }; +} diff --git a/src/com/android/messaging/datamodel/data/ParticipantListItemData.java b/src/com/android/messaging/datamodel/data/ParticipantListItemData.java new file mode 100644 index 0000000..f6c9b5f --- /dev/null +++ b/src/com/android/messaging/datamodel/data/ParticipantListItemData.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.data; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.text.TextUtils; + +import com.android.messaging.datamodel.action.BugleActionToasts; +import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction; +import com.android.messaging.util.AvatarUriUtil; + +/** + * Helps visualize a ParticipantData in a PersonItemView + */ +public class ParticipantListItemData extends PersonItemData { + private final Uri mAvatarUri; + private final String mDisplayName; + private final String mDetails; + private final long mContactId; + private final String mLookupKey; + private final String mNormalizedDestination; + + /** + * Constructor. Takes necessary info from the incoming ParticipantData. + */ + public ParticipantListItemData(final ParticipantData participant) { + mAvatarUri = AvatarUriUtil.createAvatarUri(participant); + mContactId = participant.getContactId(); + mLookupKey = participant.getLookupKey(); + mNormalizedDestination = participant.getNormalizedDestination(); + if (TextUtils.isEmpty(participant.getFullName())) { + mDisplayName = participant.getSendDestination(); + mDetails = null; + } else { + mDisplayName = participant.getFullName(); + mDetails = (participant.isUnknownSender()) ? null : participant.getSendDestination(); + } + } + + @Override + public Uri getAvatarUri() { + return mAvatarUri; + } + + @Override + public String getDisplayName() { + return mDisplayName; + } + + @Override + public String getDetails() { + return mDetails; + } + + @Override + public Intent getClickIntent() { + return null; + } + + @Override + public long getContactId() { + return mContactId; + } + + @Override + public String getLookupKey() { + return mLookupKey; + } + + @Override + public String getNormalizedDestination() { + return mNormalizedDestination; + } + + public void unblock(final Context context) { + UpdateDestinationBlockedAction.updateDestinationBlocked( + mNormalizedDestination, false, null, + BugleActionToasts.makeUpdateDestinationBlockedActionListener(context)); + } +} diff --git a/src/com/android/messaging/datamodel/data/PendingAttachmentData.java b/src/com/android/messaging/datamodel/data/PendingAttachmentData.java new file mode 100644 index 0000000..5e079f8 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/PendingAttachmentData.java @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.data; + +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.NonNull; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.UriUtil; + +/** + * Represents a "pending" message part that acts as a placeholder for the actual attachment being + * loaded. It handles the task to load and persist the attachment from a Uri to local scratch + * folder. This item is not persisted to the database. + */ +public class PendingAttachmentData extends MessagePartData { + /** The pending state. This is the initial state where we haven't started loading yet */ + public static final int STATE_PENDING = 0; + + /** The state for when we are currently loading the attachment to the scratch space */ + public static final int STATE_LOADING = 1; + + /** The attachment has been successfully loaded and no longer pending */ + public static final int STATE_LOADED = 2; + + /** The attachment failed to load */ + public static final int STATE_FAILED = 3; + + private static final int LOAD_MEDIA_TIME_LIMIT_MILLIS = 60 * 1000; // 60s + + /** The current state of the pending attachment. Refer to the STATE_* states above */ + private int mCurrentState; + + /** + * Create a new instance of PendingAttachmentData with an output Uri. + * @param sourceUri the source Uri of the attachment. The Uri maybe temporary or remote, + * so we need to persist it to local storage. + */ + protected PendingAttachmentData(final String caption, final String contentType, + @NonNull final Uri sourceUri, final int width, final int height, + final boolean onlySingleAttachment) { + super(caption, contentType, sourceUri, width, height, onlySingleAttachment); + mCurrentState = STATE_PENDING; + } + + /** + * Creates a pending attachment data that is able to load from the given source uri and + * persist the media resource locally in the scratch folder. + */ + public static PendingAttachmentData createPendingAttachmentData(final String contentType, + final Uri sourceUri) { + return createPendingAttachmentData(null, contentType, sourceUri, UNSPECIFIED_SIZE, + UNSPECIFIED_SIZE); + } + + public static PendingAttachmentData createPendingAttachmentData(final String caption, + final String contentType, final Uri sourceUri, final int width, final int height) { + Assert.isTrue(ContentType.isMediaType(contentType)); + return new PendingAttachmentData(caption, contentType, sourceUri, width, height, + false /*onlySingleAttachment*/); + } + + public static PendingAttachmentData createPendingAttachmentData(final String caption, + final String contentType, final Uri sourceUri, final int width, final int height, + final boolean onlySingleAttachment) { + Assert.isTrue(ContentType.isMediaType(contentType)); + return new PendingAttachmentData(caption, contentType, sourceUri, width, height, + onlySingleAttachment); + } + + public int getCurrentState() { + return mCurrentState; + } + + public void loadAttachmentForDraft(final DraftMessageData draftMessageData, + final String bindingId) { + if (mCurrentState != STATE_PENDING) { + return; + } + mCurrentState = STATE_LOADING; + + // Kick off a SafeAsyncTask to load the content of the media and persist it locally. + // Note: we need to persist the media locally even if it's not remote, because we + // want to be able to resend the media in case the message failed to send. + new SafeAsyncTask(LOAD_MEDIA_TIME_LIMIT_MILLIS, + true /* cancelExecutionOnTimeout */) { + @Override + protected MessagePartData doInBackgroundTimed(final Void... params) { + final Uri contentUri = getContentUri(); + final Uri persistedUri = UriUtil.persistContentToScratchSpace(contentUri); + if (persistedUri != null) { + return MessagePartData.createMediaMessagePart( + getText(), + getContentType(), + persistedUri, + getWidth(), + getHeight()); + } + return null; + } + + @Override + protected void onCancelled() { + LogUtil.w(LogUtil.BUGLE_TAG, "Timeout while retrieving media"); + mCurrentState = STATE_FAILED; + if (draftMessageData.isBound(bindingId)) { + draftMessageData.removePendingAttachment(PendingAttachmentData.this); + } + } + + @Override + protected void onPostExecute(final MessagePartData attachment) { + if (attachment != null) { + mCurrentState = STATE_LOADED; + if (draftMessageData.isBound(bindingId)) { + draftMessageData.updatePendingAttachment(attachment, + PendingAttachmentData.this); + } else { + // The draft message data is no longer bound, drop the loaded attachment. + attachment.destroyAsync(); + } + } else { + // Media load failed. We already logged in doInBackground() so don't need to + // do that again. + mCurrentState = STATE_FAILED; + if (draftMessageData.isBound(bindingId)) { + draftMessageData.onPendingAttachmentLoadFailed(PendingAttachmentData.this); + draftMessageData.removePendingAttachment(PendingAttachmentData.this); + } + } + } + }.executeOnThreadPool(); + } + + protected PendingAttachmentData(final Parcel in) { + super(in); + mCurrentState = in.readInt(); + } + + @Override + public void writeToParcel(final Parcel out, final int flags) { + super.writeToParcel(out, flags); + out.writeInt(mCurrentState); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public PendingAttachmentData createFromParcel(final Parcel in) { + return new PendingAttachmentData(in); + } + + @Override + public PendingAttachmentData[] newArray(final int size) { + return new PendingAttachmentData[size]; + } + }; +} diff --git a/src/com/android/messaging/datamodel/data/PeopleAndOptionsData.java b/src/com/android/messaging/datamodel/data/PeopleAndOptionsData.java new file mode 100644 index 0000000..650a037 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/PeopleAndOptionsData.java @@ -0,0 +1,210 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.data; + +import android.app.LoaderManager; +import android.content.Context; +import android.content.Loader; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; + +import com.android.messaging.datamodel.BoundCursorLoader; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.action.BugleActionToasts; +import com.android.messaging.datamodel.action.UpdateConversationOptionsAction; +import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +import java.util.List; + +/** + * Services data needs for PeopleAndOptionsFragment. + */ +public class PeopleAndOptionsData extends BindableData implements + LoaderManager.LoaderCallbacks { + public interface PeopleAndOptionsDataListener { + void onOptionsCursorUpdated(PeopleAndOptionsData data, Cursor cursor); + void onParticipantsListLoaded(PeopleAndOptionsData data, + List participants); + } + + private static final String BINDING_ID = "bindingId"; + private final Context mContext; + private final String mConversationId; + private final ConversationParticipantsData mParticipantData; + private LoaderManager mLoaderManager; + private PeopleAndOptionsDataListener mListener; + + public PeopleAndOptionsData(final String conversationId, final Context context, + final PeopleAndOptionsDataListener listener) { + mListener = listener; + mContext = context; + mConversationId = conversationId; + mParticipantData = new ConversationParticipantsData(); + } + + private static final int CONVERSATION_OPTIONS_LOADER = 1; + private static final int PARTICIPANT_LOADER = 2; + + @Override + public Loader onCreateLoader(final int id, final Bundle args) { + final String bindingId = args.getString(BINDING_ID); + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + switch (id) { + case CONVERSATION_OPTIONS_LOADER: { + final Uri uri = + MessagingContentProvider.buildConversationMetadataUri(mConversationId); + return new BoundCursorLoader(bindingId, mContext, uri, + PeopleOptionsItemData.PROJECTION, null, null, null); + } + + case PARTICIPANT_LOADER: { + final Uri uri = + MessagingContentProvider + .buildConversationParticipantsUri(mConversationId); + return new BoundCursorLoader(bindingId, mContext, uri, + ParticipantData.ParticipantsQuery.PROJECTION, null, null, null); + } + + default: + Assert.fail("Unknown loader id for PeopleAndOptionsFragment!"); + break; + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding PeopleAndOptionsFragment"); + } + return null; + } + + /** + * {@inheritDoc} + */ + @Override + public void onLoadFinished(final Loader loader, final Cursor data) { + final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; + if (isBound(cursorLoader.getBindingId())) { + switch (loader.getId()) { + case CONVERSATION_OPTIONS_LOADER: + mListener.onOptionsCursorUpdated(this, data); + break; + + case PARTICIPANT_LOADER: + mParticipantData.bind(data); + mListener.onParticipantsListLoaded(this, + mParticipantData.getParticipantListExcludingSelf()); + break; + + default: + Assert.fail("Unknown loader id for PeopleAndOptionsFragment!"); + break; + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, + "Loader finished after unbinding PeopleAndOptionsFragment"); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void onLoaderReset(final Loader loader) { + final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; + if (isBound(cursorLoader.getBindingId())) { + switch (loader.getId()) { + case CONVERSATION_OPTIONS_LOADER: + mListener.onOptionsCursorUpdated(this, null); + break; + + case PARTICIPANT_LOADER: + mParticipantData.bind(null); + break; + + default: + Assert.fail("Unknown loader id for PeopleAndOptionsFragment!"); + break; + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Loader reset after unbinding PeopleAndOptionsFragment"); + } + } + + public void init(final LoaderManager loaderManager, + final BindingBase binding) { + final Bundle args = new Bundle(); + args.putString(BINDING_ID, binding.getBindingId()); + mLoaderManager = loaderManager; + mLoaderManager.initLoader(CONVERSATION_OPTIONS_LOADER, args, this); + mLoaderManager.initLoader(PARTICIPANT_LOADER, args, this); + } + + @Override + protected void unregisterListeners() { + mListener = null; + + // This could be null if we bind but the caller doesn't init the BindableData + if (mLoaderManager != null) { + mLoaderManager.destroyLoader(CONVERSATION_OPTIONS_LOADER); + mLoaderManager.destroyLoader(PARTICIPANT_LOADER); + mLoaderManager = null; + } + } + + public void enableConversationNotifications(final BindingBase binding, + final boolean enable) { + final String bindingId = binding.getBindingId(); + if (isBound(bindingId)) { + UpdateConversationOptionsAction.enableConversationNotifications( + mConversationId, enable); + } + } + + public void setConversationNotificationSound(final BindingBase binding, + final String ringtoneUri) { + final String bindingId = binding.getBindingId(); + if (isBound(bindingId)) { + UpdateConversationOptionsAction.setConversationNotificationSound(mConversationId, + ringtoneUri); + } + } + + public void enableConversationNotificationVibration( + final BindingBase binding, final boolean enable) { + final String bindingId = binding.getBindingId(); + if (isBound(bindingId)) { + UpdateConversationOptionsAction.enableVibrationForConversationNotification( + mConversationId, enable); + } + } + + public void setDestinationBlocked(final BindingBase binding, + final boolean blocked) { + final String bindingId = binding.getBindingId(); + final ParticipantData participantData = mParticipantData.getOtherParticipant(); + if (isBound(bindingId) && participantData != null) { + UpdateDestinationBlockedAction.updateDestinationBlocked( + participantData.getNormalizedDestination(), + blocked, mConversationId, + BugleActionToasts.makeUpdateDestinationBlockedActionListener(mContext)); + } + } +} diff --git a/src/com/android/messaging/datamodel/data/PeopleOptionsItemData.java b/src/com/android/messaging/datamodel/data/PeopleOptionsItemData.java new file mode 100644 index 0000000..5af6a30 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/PeopleOptionsItemData.java @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.data; + +import android.content.Context; +import android.database.Cursor; +import android.media.Ringtone; +import android.media.RingtoneManager; +import android.net.Uri; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ConversationListItemData.ConversationListViewColumns; +import com.android.messaging.util.Assert; +import com.android.messaging.util.RingtoneUtil; + +public class PeopleOptionsItemData { + public static final String[] PROJECTION = { + ConversationListViewColumns.NOTIFICATION_ENABLED, + ConversationListViewColumns.NOTIFICATION_SOUND_URI, + ConversationListViewColumns.NOTIFICATION_VIBRATION, + }; + + // Column index for query projection. + private static final int INDEX_NOTIFICATION_ENABLED = 0; + private static final int INDEX_NOTIFICATION_SOUND_URI = 1; + private static final int INDEX_NOTIFICATION_VIBRATION = 2; + + // Identification for each setting that's surfaced to the UI layer. + public static final int SETTING_NOTIFICATION_ENABLED = 0; + public static final int SETTING_NOTIFICATION_SOUND_URI = 1; + public static final int SETTING_NOTIFICATION_VIBRATION = 2; + public static final int SETTING_BLOCKED = 3; + public static final int SETTINGS_COUNT = 4; + + // Type of UI switch to show for the toggle button. + public static final int TOGGLE_TYPE_CHECKBOX = 0; + public static final int TOGGLE_TYPE_SWITCH = 1; + + private String mTitle; + private String mSubtitle; + private Uri mRingtoneUri; + private boolean mCheckable; + private boolean mChecked; + private boolean mEnabled; + private int mItemId; + private ParticipantData mOtherParticipant; + + private final Context mContext; + + public PeopleOptionsItemData(final Context context) { + mContext = context; + } + + /** + * Bind to a specific setting column on conversation metadata cursor. (Note + * that it binds to columns because it treats individual columns of the cursor as + * separate options to display for the conversation, e.g. notification settings). + */ + public void bind( + final Cursor cursor, final ParticipantData otherParticipant, final int settingType) { + mSubtitle = null; + mRingtoneUri = null; + mCheckable = true; + mEnabled = true; + mItemId = settingType; + mOtherParticipant = otherParticipant; + + final boolean notificationEnabled = cursor.getInt(INDEX_NOTIFICATION_ENABLED) == 1; + switch (settingType) { + case SETTING_NOTIFICATION_ENABLED: + mTitle = mContext.getString(R.string.notifications_enabled_conversation_pref_title); + mChecked = notificationEnabled; + break; + + case SETTING_NOTIFICATION_SOUND_URI: + mTitle = mContext.getString(R.string.notification_sound_pref_title); + final String ringtoneString = cursor.getString(INDEX_NOTIFICATION_SOUND_URI); + Uri ringtoneUri = RingtoneUtil.getNotificationRingtoneUri(ringtoneString); + + mSubtitle = mContext.getString(R.string.silent_ringtone); + if (ringtoneUri != null) { + final Ringtone ringtone = RingtoneManager.getRingtone(mContext, ringtoneUri); + if (ringtone != null) { + mSubtitle = ringtone.getTitle(mContext); + } + } + mCheckable = false; + mRingtoneUri = ringtoneUri; + mEnabled = notificationEnabled; + break; + + case SETTING_NOTIFICATION_VIBRATION: + mTitle = mContext.getString(R.string.notification_vibrate_pref_title); + mChecked = cursor.getInt(INDEX_NOTIFICATION_VIBRATION) == 1; + mEnabled = notificationEnabled; + break; + + case SETTING_BLOCKED: + Assert.notNull(otherParticipant); + final int resourceId = otherParticipant.isBlocked() ? + R.string.unblock_contact_title : R.string.block_contact_title; + mTitle = mContext.getString(resourceId, otherParticipant.getDisplayDestination()); + mCheckable = false; + break; + + default: + Assert.fail("Unsupported conversation option type!"); + } + } + + public String getTitle() { + return mTitle; + } + + public String getSubtitle() { + return mSubtitle; + } + + public boolean getCheckable() { + return mCheckable; + } + + public boolean getChecked() { + return mChecked; + } + + public boolean getEnabled() { + return mEnabled; + } + + public int getItemId() { + return mItemId; + } + + public Uri getRingtoneUri() { + return mRingtoneUri; + } + + public ParticipantData getOtherParticipant() { + return mOtherParticipant; + } +} diff --git a/src/com/android/messaging/datamodel/data/PersonItemData.java b/src/com/android/messaging/datamodel/data/PersonItemData.java new file mode 100644 index 0000000..a0a1ce8 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/PersonItemData.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.data; + +import android.content.Intent; +import android.net.Uri; + +import com.android.messaging.datamodel.binding.BindableData; + +/** + * Bridges between any particpant/contact related data and data displayed in the PersonItemView. + */ +public abstract class PersonItemData extends BindableData { + /** + * The UI component that listens for data change and update accordingly. + */ + public interface PersonItemDataListener { + void onPersonDataUpdated(PersonItemData data); + void onPersonDataFailed(PersonItemData data, Exception exception); + } + + private PersonItemDataListener mListener; + + public abstract Uri getAvatarUri(); + public abstract String getDisplayName(); + public abstract String getDetails(); + public abstract Intent getClickIntent(); + public abstract long getContactId(); + public abstract String getLookupKey(); + public abstract String getNormalizedDestination(); + + public void setListener(final PersonItemDataListener listener) { + if (isBound()) { + mListener = listener; + } + } + + protected void notifyDataUpdated() { + if (isBound() && mListener != null) { + mListener.onPersonDataUpdated(this); + } + } + + protected void notifyDataFailed(final Exception exception) { + if (isBound() && mListener != null) { + mListener.onPersonDataFailed(this, exception); + } + } + + @Override + protected void unregisterListeners() { + mListener = null; + } +} diff --git a/src/com/android/messaging/datamodel/data/SelfParticipantsData.java b/src/com/android/messaging/datamodel/data/SelfParticipantsData.java new file mode 100644 index 0000000..43302ed --- /dev/null +++ b/src/com/android/messaging/datamodel/data/SelfParticipantsData.java @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.data; + +import android.database.Cursor; +import android.support.v4.util.ArrayMap; + +import java.util.ArrayList; +import java.util.List; + +import com.android.messaging.util.OsUtil; + +/** + * A class that contains the list of all self participants potentially involved in a conversation. + * This class contains both active/inactive self entries when there is multi-SIM support. + */ +public class SelfParticipantsData { + /** + * The map from self participant ids to self-participant data entries in the participants table. + * This includes both active, inactive and default (with subId == + * {@link ParticipantData#DEFAULT_SELF_SUB_ID}) subscriptions. + */ + private final ArrayMap mSelfParticipantMap; + + public SelfParticipantsData() { + mSelfParticipantMap = new ArrayMap(); + } + + public void bind(final Cursor cursor) { + mSelfParticipantMap.clear(); + if (cursor != null) { + while (cursor.moveToNext()) { + final ParticipantData newParticipant = ParticipantData.getFromCursor(cursor); + mSelfParticipantMap.put(newParticipant.getId(), newParticipant); + } + } + } + + /** + * Gets the list of self participants for all subscriptions. + * @param activeOnly if set, returns active self entries only (i.e. those with SIMs plugged in). + */ + public List getSelfParticipants(final boolean activeOnly) { + List list = new ArrayList(); + for (final ParticipantData self : mSelfParticipantMap.values()) { + if (!activeOnly || self.isActiveSubscription()) { + list.add(self); + } + } + return list; + } + + /** + * Gets the self participant corresponding to the given self id. + */ + ParticipantData getSelfParticipantById(final String selfId) { + return mSelfParticipantMap.get(selfId); + } + + /** + * Returns if a given self id represents the default self. + */ + boolean isDefaultSelf(final String selfId) { + if (!OsUtil.isAtLeastL_MR1()) { + return true; + } + final ParticipantData self = getSelfParticipantById(selfId); + return self == null ? false : self.getSubId() == ParticipantData.DEFAULT_SELF_SUB_ID; + } + + public int getSelfParticipantsCountExcludingDefault(final boolean activeOnly) { + int count = 0; + for (final ParticipantData self : mSelfParticipantMap.values()) { + if (!self.isDefaultSelf() && (!activeOnly || self.isActiveSubscription())) { + count++; + } + } + return count; + } + + public ParticipantData getDefaultSelfParticipant() { + for (final ParticipantData self : mSelfParticipantMap.values()) { + if (self.isDefaultSelf()) { + return self; + } + } + return null; + } + + boolean isLoaded() { + return !mSelfParticipantMap.isEmpty(); + } +} diff --git a/src/com/android/messaging/datamodel/data/SettingsData.java b/src/com/android/messaging/datamodel/data/SettingsData.java new file mode 100644 index 0000000..7474619 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/SettingsData.java @@ -0,0 +1,223 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.data; + +import android.app.LoaderManager; +import android.content.Context; +import android.content.Loader; +import android.database.Cursor; +import android.os.Bundle; +import android.text.TextUtils; + +import com.android.messaging.R; +import com.android.messaging.datamodel.BoundCursorLoader; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; + +import java.util.ArrayList; +import java.util.List; + +/** + * Services SettingsFragment's data needs for loading active self participants to display + * the list of active subscriptions. + */ +public class SettingsData extends BindableData implements + LoaderManager.LoaderCallbacks { + public interface SettingsDataListener { + void onSelfParticipantDataLoaded(SettingsData data); + } + + public static class SettingsItem { + public static final int TYPE_GENERAL_SETTINGS = 1; + public static final int TYPE_PER_SUBSCRIPTION_SETTINGS = 2; + + private final String mDisplayName; + private final String mDisplayDetail; + private final String mActivityTitle; + private final int mType; + private final int mSubId; + + private SettingsItem(final String displayName, final String displayDetail, + final String activityTitle, final int type, final int subId) { + mDisplayName = displayName; + mDisplayDetail = displayDetail; + mActivityTitle = activityTitle; + mType = type; + mSubId = subId; + } + + public String getDisplayName() { + return mDisplayName; + } + + public String getDisplayDetail() { + return mDisplayDetail; + } + + public int getType() { + return mType; + } + + public int getSubId() { + return mSubId; + } + + public String getActivityTitle() { + return mActivityTitle; + } + + public static SettingsItem fromSelfParticipant(final Context context, + final ParticipantData self) { + Assert.isTrue(self.isSelf()); + Assert.isTrue(self.isActiveSubscription()); + final String displayDetail = TextUtils.isEmpty(self.getDisplayDestination()) ? + context.getString(R.string.sim_settings_unknown_number) : + self.getDisplayDestination(); + final String displayName = context.getString(R.string.sim_specific_settings, + self.getSubscriptionName()); + return new SettingsItem(displayName, displayDetail, displayName, + TYPE_PER_SUBSCRIPTION_SETTINGS, self.getSubId()); + } + + public static SettingsItem createGeneralSettingsItem(final Context context) { + return new SettingsItem(context.getString(R.string.general_settings), + null, context.getString(R.string.general_settings_activity_title), + TYPE_GENERAL_SETTINGS, -1); + } + + public static SettingsItem createDefaultMmsSettingsItem(final Context context, + final int subId) { + return new SettingsItem(context.getString(R.string.advanced_settings), + null, context.getString(R.string.advanced_settings_activity_title), + TYPE_PER_SUBSCRIPTION_SETTINGS, subId); + } + } + + private static final String BINDING_ID = "bindingId"; + private final Context mContext; + private final SelfParticipantsData mSelfParticipantsData; + private LoaderManager mLoaderManager; + private SettingsDataListener mListener; + + public SettingsData(final Context context, + final SettingsDataListener listener) { + mListener = listener; + mContext = context; + mSelfParticipantsData = new SelfParticipantsData(); + } + + private static final int SELF_PARTICIPANT_LOADER = 1; + + @Override + public Loader onCreateLoader(final int id, final Bundle args) { + Assert.equals(SELF_PARTICIPANT_LOADER, id); + Loader loader = null; + + final String bindingId = args.getString(BINDING_ID); + // Check if data still bound to the requesting ui element + if (isBound(bindingId)) { + loader = new BoundCursorLoader(bindingId, mContext, + MessagingContentProvider.PARTICIPANTS_URI, + ParticipantData.ParticipantsQuery.PROJECTION, + ParticipantColumns.SUB_ID + " <> ?", + new String[] { String.valueOf(ParticipantData.OTHER_THAN_SELF_SUB_ID) }, + null); + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Creating self loader after unbinding"); + } + return loader; + } + + @Override + public void onLoadFinished(final Loader generic, final Cursor data) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + mSelfParticipantsData.bind(data); + mListener.onSelfParticipantDataLoaded(this); + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Self loader finished after unbinding"); + } + } + + @Override + public void onLoaderReset(final Loader generic) { + final BoundCursorLoader loader = (BoundCursorLoader) generic; + + // Check if data still bound to the requesting ui element + if (isBound(loader.getBindingId())) { + mSelfParticipantsData.bind(null); + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Self loader reset after unbinding"); + } + } + + public void init(final LoaderManager loaderManager, + final BindingBase binding) { + final Bundle args = new Bundle(); + args.putString(BINDING_ID, binding.getBindingId()); + mLoaderManager = loaderManager; + mLoaderManager.initLoader(SELF_PARTICIPANT_LOADER, args, this); + } + + @Override + protected void unregisterListeners() { + mListener = null; + + // This could be null if we bind but the caller doesn't init the BindableData + if (mLoaderManager != null) { + mLoaderManager.destroyLoader(SELF_PARTICIPANT_LOADER); + mLoaderManager = null; + } + } + + public List getSettingsItems() { + final List selfs = mSelfParticipantsData.getSelfParticipants(true); + final List settingsItems = new ArrayList(); + // First goes the general settings, followed by per-subscription settings. + settingsItems.add(SettingsItem.createGeneralSettingsItem(mContext)); + // For per-subscription settings, show the actual SIM name with phone number if the + // platorm is at least L-MR1 and there are multiple active SIMs. + final int activeSubCountExcludingDefault = + mSelfParticipantsData.getSelfParticipantsCountExcludingDefault(true); + if (OsUtil.isAtLeastL_MR1() && activeSubCountExcludingDefault > 0) { + for (ParticipantData self : selfs) { + if (!self.isDefaultSelf()) { + if (activeSubCountExcludingDefault > 1) { + settingsItems.add(SettingsItem.fromSelfParticipant(mContext, self)); + } else { + // This is the only active non-default SIM. + settingsItems.add(SettingsItem.createDefaultMmsSettingsItem(mContext, + self.getSubId())); + break; + } + } + } + } else { + // Either pre-L-MR1, or there's no active SIM, so show the default MMS settings. + settingsItems.add(SettingsItem.createDefaultMmsSettingsItem(mContext, + ParticipantData.DEFAULT_SELF_SUB_ID)); + } + return settingsItems; + } +} diff --git a/src/com/android/messaging/datamodel/data/SubscriptionListData.java b/src/com/android/messaging/datamodel/data/SubscriptionListData.java new file mode 100644 index 0000000..b5d4e4b --- /dev/null +++ b/src/com/android/messaging/datamodel/data/SubscriptionListData.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.data; + +import android.content.Context; +import android.net.Uri; +import android.text.TextUtils; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * This is a UI facing data model component that holds a list of + * {@link SubscriptionListData.SubscriptionListEntry}'s, one for each *active* subscriptions. + * + * This is used to: + * 1) Show a list of SIMs in the SIM Selector + * 2) Show the currently selected SIM in the compose message view + * 3) Show SIM indicators on conversation message views + * + * It builds on top of SelfParticipantsData and performs additional logic such as determining + * the set of icons to use for the individual Subs. + */ +public class SubscriptionListData { + /** + * Represents a single sub that backs UI. + */ + public static class SubscriptionListEntry { + public final String selfParticipantId; + public final Uri iconUri; + public final Uri selectedIconUri; + public final String displayName; + public final int displayColor; + public final String displayDestination; + + private SubscriptionListEntry(final String selfParticipantId, final Uri iconUri, + final Uri selectedIconUri, final String displayName, final int displayColor, + final String displayDestination) { + this.selfParticipantId = selfParticipantId; + this.iconUri = iconUri; + this.selectedIconUri = selectedIconUri; + this.displayName = displayName; + this.displayColor = displayColor; + this.displayDestination = displayDestination; + } + + static SubscriptionListEntry fromSelfParticipantData( + final ParticipantData selfParticipantData, final Context context) { + Assert.isTrue(selfParticipantData.isSelf()); + Assert.isTrue(selfParticipantData.isActiveSubscription()); + final int slotId = selfParticipantData.getDisplaySlotId(); + final String iconIdentifier = String.format(Locale.getDefault(), "%d", slotId); + final String subscriptionName = selfParticipantData.getSubscriptionName(); + final String displayName = TextUtils.isEmpty(subscriptionName) ? + context.getString(R.string.sim_slot_identifier, slotId) : subscriptionName; + return new SubscriptionListEntry(selfParticipantData.getId(), + AvatarUriUtil.createAvatarUri(selfParticipantData, iconIdentifier, + false /* selected */, false /* incoming */), + AvatarUriUtil.createAvatarUri(selfParticipantData, iconIdentifier, + true /* selected */, false /* incoming */), + displayName, selfParticipantData.getSubscriptionColor(), + selfParticipantData.getDisplayDestination()); + } + } + + private final List mEntriesExcludingDefault; + private SubscriptionListEntry mDefaultEntry; + private final Context mContext; + + public SubscriptionListData(final Context context) { + mEntriesExcludingDefault = new ArrayList(); + mContext = context; + } + + public void bind(final List subs) { + mEntriesExcludingDefault.clear(); + mDefaultEntry = null; + for (final ParticipantData sub : subs) { + final SubscriptionListEntry entry = + SubscriptionListEntry.fromSelfParticipantData(sub, mContext); + if (!sub.isDefaultSelf()) { + mEntriesExcludingDefault.add(entry); + } else { + mDefaultEntry = entry; + } + } + } + + public List getActiveSubscriptionEntriesExcludingDefault() { + return mEntriesExcludingDefault; + } + + public SubscriptionListEntry getActiveSubscriptionEntryBySelfId(final String selfId, + final boolean excludeDefault) { + if (mDefaultEntry != null && TextUtils.equals(mDefaultEntry.selfParticipantId, selfId)) { + return excludeDefault ? null : mDefaultEntry; + } + + for (final SubscriptionListEntry entry : mEntriesExcludingDefault) { + if (TextUtils.equals(entry.selfParticipantId, selfId)) { + return entry; + } + } + return null; + } + + public boolean hasData() { + return !mEntriesExcludingDefault.isEmpty() || mDefaultEntry != null; + } +} diff --git a/src/com/android/messaging/datamodel/data/VCardContactItemData.java b/src/com/android/messaging/datamodel/data/VCardContactItemData.java new file mode 100644 index 0000000..8abf493 --- /dev/null +++ b/src/com/android/messaging/datamodel/data/VCardContactItemData.java @@ -0,0 +1,185 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.data; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.media.BindableMediaRequest; +import com.android.messaging.datamodel.media.MediaRequest; +import com.android.messaging.datamodel.media.MediaResourceManager; +import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener; +import com.android.messaging.datamodel.media.VCardRequestDescriptor; +import com.android.messaging.datamodel.media.VCardResource; +import com.android.messaging.datamodel.media.VCardResourceEntry; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ContactUtil; + +import java.util.List; + +/** + * Data class for visualizing and loading data for a VCard contact. + */ +public class VCardContactItemData extends PersonItemData + implements MediaResourceLoadListener { + private final Context mContext; + private final Uri mVCardUri; + private String mDetails; + private final Binding> mBinding = + BindingBase.createBinding(this); + private VCardResource mVCardResource; + + private static final Uri sDefaultAvatarUri = + AvatarUriUtil.createAvatarUri(null, null, null, null); + + /** + * Constructor. This parses data from the given MessagePartData describing the vcard + */ + public VCardContactItemData(final Context context, final MessagePartData messagePartData) { + this(context, messagePartData.getContentUri()); + Assert.isTrue(messagePartData.isVCard()); + } + + /** + * Constructor. This parses data from the given VCard Uri + */ + public VCardContactItemData(final Context context, final Uri vCardUri) { + mContext = context; + mDetails = mContext.getString(R.string.loading_vcard); + mVCardUri = vCardUri; + } + + @Override + public Uri getAvatarUri() { + if (hasValidVCard()) { + final List vcards = mVCardResource.getVCards(); + Assert.isTrue(vcards.size() > 0); + if (vcards.size() == 1) { + return vcards.get(0).getAvatarUri(); + } + } + return sDefaultAvatarUri; + } + + @Override + public String getDisplayName() { + if (hasValidVCard()) { + final List vcards = mVCardResource.getVCards(); + Assert.isTrue(vcards.size() > 0); + if (vcards.size() == 1) { + return vcards.get(0).getDisplayName(); + } else { + return mContext.getResources().getQuantityString( + R.plurals.vcard_multiple_display_name, vcards.size(), vcards.size()); + } + } + return null; + } + + @Override + public String getDetails() { + return mDetails; + } + + @Override + public Intent getClickIntent() { + return null; + } + + @Override + public long getContactId() { + return ContactUtil.INVALID_CONTACT_ID; + } + + @Override + public String getLookupKey() { + return null; + } + + @Override + public String getNormalizedDestination() { + return null; + } + + public VCardResource getVCardResource() { + return hasValidVCard() ? mVCardResource : null; + } + + public Uri getVCardUri() { + return hasValidVCard() ? mVCardUri : null; + } + + public boolean hasValidVCard() { + return isBound() && mVCardResource != null; + } + + @Override + public void bind(final String bindingId) { + super.bind(bindingId); + + // Bind and request the VCard from media resource manager. + mBinding.bind(new VCardRequestDescriptor(mVCardUri).buildAsyncMediaRequest(mContext, this)); + MediaResourceManager.get().requestMediaResourceAsync(mBinding.getData()); + } + + @Override + public void unbind(final String bindingId) { + super.unbind(bindingId); + mBinding.unbind(); + if (mVCardResource != null) { + mVCardResource.release(); + mVCardResource = null; + } + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + + if (!(o instanceof VCardContactItemData)) { + return false; + } + + final VCardContactItemData lhs = (VCardContactItemData) o; + return mVCardUri.equals(lhs.mVCardUri); + } + + @Override + public void onMediaResourceLoaded(final MediaRequest request, + final VCardResource resource, final boolean isCached) { + Assert.isTrue(mVCardResource == null); + mBinding.ensureBound(); + mDetails = mContext.getString(R.string.vcard_tap_hint); + mVCardResource = resource; + mVCardResource.addRef(); + notifyDataUpdated(); + } + + @Override + public void onMediaResourceLoadError(final MediaRequest request, + final Exception exception) { + mBinding.ensureBound(); + mDetails = mContext.getString(R.string.failed_loading_vcard); + notifyDataFailed(exception); + } +} diff --git a/src/com/android/messaging/datamodel/media/AsyncMediaRequestWrapper.java b/src/com/android/messaging/datamodel/media/AsyncMediaRequestWrapper.java new file mode 100644 index 0000000..380d93c --- /dev/null +++ b/src/com/android/messaging/datamodel/media/AsyncMediaRequestWrapper.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener; + +import java.util.List; + +/** + * A mix-in style class that wraps around a normal, threading-agnostic MediaRequest object with + * functionalities offered by {@link BindableMediaRequest} to allow for async processing. + */ +class AsyncMediaRequestWrapper extends BindableMediaRequest { + + /** + * Create a new async media request wrapper instance given the listener. + */ + public static AsyncMediaRequestWrapper + createWith(final MediaRequest wrappedRequest, + final MediaResourceLoadListener listener) { + return new AsyncMediaRequestWrapper(listener, wrappedRequest); + } + + private final MediaRequest mWrappedRequest; + + private AsyncMediaRequestWrapper(final MediaResourceLoadListener listener, + final MediaRequest wrappedRequest) { + super(listener); + mWrappedRequest = wrappedRequest; + } + + @Override + public String getKey() { + return mWrappedRequest.getKey(); + } + + @Override + public MediaCache getMediaCache() { + return mWrappedRequest.getMediaCache(); + } + + @Override + public int getRequestType() { + return mWrappedRequest.getRequestType(); + } + + @Override + public T loadMediaBlocking(List> chainedTask) throws Exception { + return mWrappedRequest.loadMediaBlocking(chainedTask); + } + + @Override + public int getCacheId() { + return mWrappedRequest.getCacheId(); + } + + @Override + public MediaRequestDescriptor getDescriptor() { + return mWrappedRequest.getDescriptor(); + } +} \ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/AvatarGroupRequestDescriptor.java b/src/com/android/messaging/datamodel/media/AvatarGroupRequestDescriptor.java new file mode 100644 index 0000000..719b296 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/AvatarGroupRequestDescriptor.java @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.graphics.RectF; +import android.net.Uri; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class AvatarGroupRequestDescriptor extends CompositeImageRequestDescriptor { + private static final int MAX_GROUP_SIZE = 4; + + public AvatarGroupRequestDescriptor(final Uri uri, final int desiredWidth, + final int desiredHeight) { + this(convertToDescriptor(uri, desiredWidth, desiredHeight), desiredWidth, desiredHeight); + } + + public AvatarGroupRequestDescriptor(final List descriptors, + final int desiredWidth, final int desiredHeight) { + super(descriptors, desiredWidth, desiredHeight); + Assert.isTrue(descriptors.size() <= MAX_GROUP_SIZE); + } + + private static List convertToDescriptor(final Uri uri, + final int desiredWidth, final int desiredHeight) { + final List participantUriStrings = AvatarUriUtil.getGroupParticipantUris(uri); + final List avatarDescriptors = + new ArrayList(participantUriStrings.size()); + for (final String uriString : participantUriStrings) { + final AvatarRequestDescriptor descriptor = new AvatarRequestDescriptor( + Uri.parse(uriString), desiredWidth, desiredHeight); + avatarDescriptors.add(descriptor); + } + return avatarDescriptors; + } + + @Override + public CompositeImageRequest buildBatchImageRequest(final Context context) { + return new CompositeImageRequest(context, this); + } + + @Override + public List getChildRequestTargetRects() { + return Arrays.asList(generateDestRectArray()); + } + + /** + * Generates an array of {@link RectF} which represents where each of the individual avatar + * should be located in the final group avatar image. The location of each avatar depends on + * the size of the group and the size of the overall group avatar size. + */ + private RectF[] generateDestRectArray() { + final int groupSize = mDescriptors.size(); + final float width = desiredWidth; + final float height = desiredHeight; + final float halfWidth = width / 2F; + final float halfHeight = height / 2F; + final RectF[] destArray = new RectF[groupSize]; + switch (groupSize) { + case 2: + /** + * +-------+ + * | 0 | | + * +-------+ + * | | 1 | + * +-------+ + * + * We want two circles which touches in the center. To get this we know that the + * diagonal of the overall group avatar is squareRoot(2) * w We also know that the + * two circles touches the at the center of the overall group avatar and the + * distance from the center of the circle to the corner of the group avatar is + * radius * squareRoot(2). Therefore, the following emerges. + * + * w * squareRoot(2) = 2 (radius + radius * squareRoot(2)) + * Solving for radius we get: + * d = 2 * radius = ( squareRoot(2) / (squareRoot(2) + 1)) * w + * d = (2 - squareRoot(2)) * w + */ + final float diameter = (float) ((2 - Math.sqrt(2)) * width); + destArray[0] = new RectF(0, 0, diameter, diameter); + destArray[1] = new RectF(width - diameter, height - diameter, width, height); + break; + case 3: + /** + * +-------+ + * | | 0 | | + * +-------+ + * | 1 | 2 | + * +-------+ + * i0 + * |\ + * a | \ c + * --- i2 + * b + * + * a = radius * squareRoot(3) due to the triangle being a 30-60-90 right triangle. + * b = radius of circle + * c = 2 * radius of circle + * + * All three of the images are circles and therefore image zero will not touch + * image one or image two. Move image zero down so it touches image one and image + * two. This can be done by keeping image zero in the center and moving it down + * slightly. The amount to move down can be calculated by solving a right triangle. + * We know that the center x of image two to the center x of image zero is the + * radius of the circle, this is the length of edge b. Also we know that the + * distance from image zero to image two's center is 2 * radius, edge c. From this + * we know that the distance from center y of image two to center y of image one, + * edge a, is equal to radius * squareRoot(3) due to this triangle being a 30-60-90 + * right triangle. + */ + final float quarterWidth = width / 4F; + final float threeQuarterWidth = 3 * quarterWidth; + final float radius = height / 4F; + final float imageTwoCenterY = height - radius; + final float lengthOfEdgeA = (float) (radius * Math.sqrt(3)); + final float imageZeroCenterY = imageTwoCenterY - lengthOfEdgeA; + final float imageZeroTop = imageZeroCenterY - radius; + final float imageZeroBottom = imageZeroCenterY + radius; + destArray[0] = new RectF( + quarterWidth, imageZeroTop, threeQuarterWidth, imageZeroBottom); + destArray[1] = new RectF(0, halfHeight, halfWidth, height); + destArray[2] = new RectF(halfWidth, halfHeight, width, height); + break; + default: + /** + * +-------+ + * | 0 | 1 | + * +-------+ + * | 2 | 3 | + * +-------+ + */ + destArray[0] = new RectF(0, 0, halfWidth, halfHeight); + destArray[1] = new RectF(halfWidth, 0, width, halfHeight); + destArray[2] = new RectF(0, halfHeight, halfWidth, height); + destArray[3] = new RectF(halfWidth, halfHeight, width, height); + break; + } + return destArray; + } +} diff --git a/src/com/android/messaging/datamodel/media/AvatarRequest.java b/src/com/android/messaging/datamodel/media/AvatarRequest.java new file mode 100644 index 0000000..22d5ccc --- /dev/null +++ b/src/com/android/messaging/datamodel/media/AvatarRequest.java @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Typeface; +import android.graphics.drawable.BitmapDrawable; +import android.media.ExifInterface; +import android.net.Uri; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.UriUtil; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +public class AvatarRequest extends UriImageRequest { + private static Bitmap sDefaultPersonBitmap; + private static Bitmap sDefaultPersonBitmapLarge; + + public AvatarRequest(final Context context, + final AvatarRequestDescriptor descriptor) { + super(context, descriptor); + } + + @Override + protected InputStream getInputStreamForResource() throws FileNotFoundException { + if (UriUtil.isLocalResourceUri(mDescriptor.uri)) { + return super.getInputStreamForResource(); + } else { + final Uri primaryUri = AvatarUriUtil.getPrimaryUri(mDescriptor.uri); + Assert.isTrue(UriUtil.isLocalResourceUri(primaryUri)); + return mContext.getContentResolver().openInputStream(primaryUri); + } + } + + /** + * We can load multiple types of images for avatars depending on the uri. The uri should be + * built by {@link com.android.messaging.util.AvatarUriUtil} which will decide on + * what uri to build based on the available profile photo and name. Here we will check if the + * image is a local resource (ie profile photo uri), if the resource isn't a local one we will + * generate a tile with the first letter of the name. + */ + @Override + protected ImageResource loadMediaInternal(List> chainedTasks) + throws IOException { + Assert.isNotMainThread(); + String avatarType = AvatarUriUtil.getAvatarType(mDescriptor.uri); + Bitmap bitmap = null; + int orientation = ExifInterface.ORIENTATION_NORMAL; + final boolean isLocalResourceUri = UriUtil.isLocalResourceUri(mDescriptor.uri) || + AvatarUriUtil.TYPE_LOCAL_RESOURCE_URI.equals(avatarType); + if (isLocalResourceUri) { + try { + ImageResource imageResource = super.loadMediaInternal(chainedTasks); + bitmap = imageResource.getBitmap(); + orientation = imageResource.mOrientation; + } catch (Exception ex) { + // If we encountered any exceptions trying to load the local avatar resource, + // fall back to generated avatar. + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "AvatarRequest: failed to load local avatar " + + "resource, switching to fallback rendering", ex); + } + } + + final int width = mDescriptor.desiredWidth; + final int height = mDescriptor.desiredHeight; + // Check to see if we already got the bitmap. If not get a fallback avatar + if (bitmap == null) { + Uri generatedUri = mDescriptor.uri; + if (isLocalResourceUri) { + // If we are here, we just failed to load the local resource. Use the fallback Uri + // if possible. + generatedUri = AvatarUriUtil.getFallbackUri(mDescriptor.uri); + if (generatedUri == null) { + // No fallback Uri was provided, use the default avatar. + generatedUri = AvatarUriUtil.DEFAULT_BACKGROUND_AVATAR; + } + } + + avatarType = AvatarUriUtil.getAvatarType(generatedUri); + if (AvatarUriUtil.TYPE_LETTER_TILE_URI.equals(avatarType)) { + final String name = AvatarUriUtil.getName(generatedUri); + bitmap = renderLetterTile(name, width, height); + } else { + bitmap = renderDefaultAvatar(width, height); + } + } + return new DecodedImageResource(getKey(), bitmap, orientation); + } + + private Bitmap renderDefaultAvatar(final int width, final int height) { + final Bitmap bitmap = getBitmapPool().createOrReuseBitmap(width, height, + getBackgroundColor()); + final Canvas canvas = new Canvas(bitmap); + + if (sDefaultPersonBitmap == null) { + final BitmapDrawable defaultPerson = (BitmapDrawable) mContext.getResources() + .getDrawable(R.drawable.ic_person_light); + sDefaultPersonBitmap = defaultPerson.getBitmap(); + } + if (sDefaultPersonBitmapLarge == null) { + final BitmapDrawable largeDefaultPerson = (BitmapDrawable) mContext.getResources() + .getDrawable(R.drawable.ic_person_light_large); + sDefaultPersonBitmapLarge = largeDefaultPerson.getBitmap(); + } + + Bitmap defaultPerson = null; + if (mDescriptor.isWearBackground) { + final BitmapDrawable wearDefaultPerson = (BitmapDrawable) mContext.getResources() + .getDrawable(R.drawable.ic_person_wear); + defaultPerson = wearDefaultPerson.getBitmap(); + } else { + final boolean isLargeDefault = (width > sDefaultPersonBitmap.getWidth()) || + (height > sDefaultPersonBitmap.getHeight()); + defaultPerson = + isLargeDefault ? sDefaultPersonBitmapLarge : sDefaultPersonBitmap; + } + + final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + final Matrix matrix = new Matrix(); + final RectF source = new RectF(0, 0, defaultPerson.getWidth(), defaultPerson.getHeight()); + final RectF dest = new RectF(0, 0, width, height); + matrix.setRectToRect(source, dest, Matrix.ScaleToFit.FILL); + + canvas.drawBitmap(defaultPerson, matrix, paint); + + return bitmap; + } + + private Bitmap renderLetterTile(final String name, final int width, final int height) { + final float halfWidth = width / 2; + final float halfHeight = height / 2; + final int minOfWidthAndHeight = Math.min(width, height); + final Bitmap bitmap = getBitmapPool().createOrReuseBitmap(width, height, + getBackgroundColor()); + final Resources resources = mContext.getResources(); + final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + paint.setTypeface(Typeface.create("sans-serif-thin", Typeface.NORMAL)); + paint.setColor(resources.getColor(R.color.letter_tile_font_color)); + final float letterToTileRatio = resources.getFraction(R.dimen.letter_to_tile_ratio, 1, 1); + paint.setTextSize(letterToTileRatio * minOfWidthAndHeight); + + final String firstCharString = name.substring(0, 1).toUpperCase(); + final Rect textBound = new Rect(); + paint.getTextBounds(firstCharString, 0, 1, textBound); + + final Canvas canvas = new Canvas(bitmap); + final float xOffset = halfWidth - textBound.centerX(); + final float yOffset = halfHeight - textBound.centerY(); + canvas.drawText(firstCharString, xOffset, yOffset, paint); + + return bitmap; + } + + private int getBackgroundColor() { + return mContext.getResources().getColor(R.color.primary_color); + } + + @Override + public int getCacheId() { + return BugleMediaCacheManager.AVATAR_IMAGE_CACHE; + } +} diff --git a/src/com/android/messaging/datamodel/media/AvatarRequestDescriptor.java b/src/com/android/messaging/datamodel/media/AvatarRequestDescriptor.java new file mode 100644 index 0000000..9afa9ad --- /dev/null +++ b/src/com/android/messaging/datamodel/media/AvatarRequestDescriptor.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.net.Uri; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.UriUtil; + +public class AvatarRequestDescriptor extends UriImageRequestDescriptor { + final boolean isWearBackground; + + public AvatarRequestDescriptor(final Uri uri, final int desiredWidth, + final int desiredHeight) { + this(uri, desiredWidth, desiredHeight, true /* cropToCircle */); + } + + public AvatarRequestDescriptor(final Uri uri, final int desiredWidth, + final int desiredHeight, final boolean cropToCircle) { + this(uri, desiredWidth, desiredHeight, cropToCircle, false /* isWearBackground */); + } + + public AvatarRequestDescriptor(final Uri uri, final int desiredWidth, + final int desiredHeight, boolean cropToCircle, boolean isWearBackground) { + super(uri, desiredWidth, desiredHeight, false /* allowCompression */, true /* isStatic */, + cropToCircle, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + Assert.isTrue(uri == null || UriUtil.isLocalResourceUri(uri) || + AvatarUriUtil.isAvatarUri(uri)); + this.isWearBackground = isWearBackground; + } + + @Override + public MediaRequest buildSyncMediaRequest(final Context context) { + final String avatarType = uri == null ? null : AvatarUriUtil.getAvatarType(uri); + if (AvatarUriUtil.TYPE_SIM_SELECTOR_URI.equals(avatarType)) { + return new SimSelectorAvatarRequest(context, this); + } else { + return new AvatarRequest(context, this); + } + } +} diff --git a/src/com/android/messaging/datamodel/media/BindableMediaRequest.java b/src/com/android/messaging/datamodel/media/BindableMediaRequest.java new file mode 100644 index 0000000..36521d5 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/BindableMediaRequest.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import com.android.messaging.datamodel.binding.BindableOnceData; +import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener; + +/** + * The {@link MediaRequest} interface is threading-model-blind, allowing the implementations to + * be processed synchronously or asynchronously. + * This is a {@link MediaRequest} implementation that includes functionalities such as binding and + * event callbacks for multi-threaded media request processing. + */ +public abstract class BindableMediaRequest + extends BindableOnceData + implements MediaRequest, MediaResourceLoadListener { + private MediaResourceLoadListener mListener; + + public BindableMediaRequest(final MediaResourceLoadListener listener) { + mListener = listener; + } + + /** + * Delegates the media resource callback to the listener. Performs binding check to ensure + * the listener is still bound to this request. + */ + @Override + public void onMediaResourceLoaded(final MediaRequest request, final T resource, + final boolean cached) { + if (isBound() && mListener != null) { + mListener.onMediaResourceLoaded(request, resource, cached); + } + } + + /** + * Delegates the media resource callback to the listener. Performs binding check to ensure + * the listener is still bound to this request. + */ + @Override + public void onMediaResourceLoadError(final MediaRequest request, final Exception exception) { + if (isBound() && mListener != null) { + mListener.onMediaResourceLoadError(request, exception); + } + } + + @Override + protected void unregisterListeners() { + mListener = null; + } +} diff --git a/src/com/android/messaging/datamodel/media/BugleMediaCacheManager.java b/src/com/android/messaging/datamodel/media/BugleMediaCacheManager.java new file mode 100644 index 0000000..c41ba60 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/BugleMediaCacheManager.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import com.android.messaging.util.Assert; + +/** + * An implementation of {@link MediaCacheManager} that creates caches specific to Bugle's needs. + * + * To create a new type of cache, add to the list of cache ids and create a new MediaCache<> + * for your cache id / media resource type in createMediaCacheById(). + */ +public class BugleMediaCacheManager extends MediaCacheManager { + // List of available cache ids. + public static final int DEFAULT_IMAGE_CACHE = 1; + public static final int AVATAR_IMAGE_CACHE = 2; + public static final int VCARD_CACHE = 3; + + // VCard cache size - we compute the size by count, not by bytes. + private static final int VCARD_CACHE_SIZE = 5; + private static final int SHARED_IMAGE_CACHE_SIZE = 1024 * 10; // 10MB + + @Override + protected MediaCache createMediaCacheById(final int id) { + switch (id) { + case DEFAULT_IMAGE_CACHE: + return new PoolableImageCache(SHARED_IMAGE_CACHE_SIZE, id, "DefaultImageCache"); + + case AVATAR_IMAGE_CACHE: + return new PoolableImageCache(id, "AvatarImageCache"); + + case VCARD_CACHE: + return new MediaCache(VCARD_CACHE_SIZE, id, "VCardCache"); + + default: + Assert.fail("BugleMediaCacheManager: unsupported cache id " + id); + break; + } + return null; + } +} diff --git a/src/com/android/messaging/datamodel/media/CompositeImageRequest.java b/src/com/android/messaging/datamodel/media/CompositeImageRequest.java new file mode 100644 index 0000000..66f1bff --- /dev/null +++ b/src/com/android/messaging/datamodel/media/CompositeImageRequest.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.RectF; +import android.media.ExifInterface; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.ImageUtils; + +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.util.List; + +/** + * Requests a composite image resource. The composite image resource is constructed by first + * sequentially requesting a number of sub image resources specified by + * {@link CompositeImageRequestDescriptor#getChildRequestDescriptors()}. After this, the + * individual sub images are composed into the final image onto their respective target rects + * returned by {@link CompositeImageRequestDescriptor#getChildRequestTargetRects()}. + */ +public class CompositeImageRequest + extends ImageRequest { + private final Bitmap mBitmap; + private final Canvas mCanvas; + private final Paint mPaint; + + public CompositeImageRequest(final Context context, final D descriptor) { + super(context, descriptor); + mBitmap = getBitmapPool().createOrReuseBitmap( + mDescriptor.desiredWidth, mDescriptor.desiredHeight); + mCanvas = new Canvas(mBitmap); + mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + } + + @Override + protected ImageResource loadMediaInternal(List> chainedTask) { + final List descriptors = + mDescriptor.getChildRequestDescriptors(); + final List targetRects = mDescriptor.getChildRequestTargetRects(); + Assert.equals(descriptors.size(), targetRects.size()); + Assert.isTrue(descriptors.size() > 1); + + for (int i = 0; i < descriptors.size(); i++) { + final MediaRequest request = + descriptors.get(i).buildSyncMediaRequest(mContext); + // Synchronously request the child image. + final ImageResource resource = + MediaResourceManager.get().requestMediaResourceSync(request); + if (resource != null) { + try { + final RectF avatarDestOnGroup = targetRects.get(i); + + // Draw the bitmap into a smaller size with a circle mask. + final Bitmap resourceBitmap = resource.getBitmap(); + final RectF resourceRect = new RectF( + 0, 0, resourceBitmap.getWidth(), resourceBitmap.getHeight()); + final Bitmap smallCircleBitmap = getBitmapPool().createOrReuseBitmap( + Math.round(avatarDestOnGroup.width()), + Math.round(avatarDestOnGroup.height())); + final RectF smallCircleRect = new RectF( + 0, 0, smallCircleBitmap.getWidth(), smallCircleBitmap.getHeight()); + final Canvas smallCircleCanvas = new Canvas(smallCircleBitmap); + ImageUtils.drawBitmapWithCircleOnCanvas(resource.getBitmap(), smallCircleCanvas, + resourceRect, smallCircleRect, null /* bitmapPaint */, + false /* fillBackground */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + final Matrix matrix = new Matrix(); + matrix.setRectToRect(smallCircleRect, avatarDestOnGroup, + Matrix.ScaleToFit.FILL); + mCanvas.drawBitmap(smallCircleBitmap, matrix, mPaint); + } finally { + resource.release(); + } + } + } + + return new DecodedImageResource(getKey(), mBitmap, ExifInterface.ORIENTATION_NORMAL); + } + + @Override + public int getCacheId() { + return BugleMediaCacheManager.AVATAR_IMAGE_CACHE; + } + + @Override + protected InputStream getInputStreamForResource() throws FileNotFoundException { + throw new IllegalStateException("Composite image request doesn't support input stream!"); + } +} diff --git a/src/com/android/messaging/datamodel/media/CompositeImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/CompositeImageRequestDescriptor.java new file mode 100644 index 0000000..071130e --- /dev/null +++ b/src/com/android/messaging/datamodel/media/CompositeImageRequestDescriptor.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.graphics.RectF; + +import com.google.common.base.Joiner; + +import java.util.List; + +public abstract class CompositeImageRequestDescriptor extends ImageRequestDescriptor { + protected final List mDescriptors; + private final String mKey; + + public CompositeImageRequestDescriptor(final List descriptors, + final int desiredWidth, final int desiredHeight) { + super(desiredWidth, desiredHeight); + mDescriptors = descriptors; + + final String[] keyParts = new String[descriptors.size()]; + for (int i = 0; i < descriptors.size(); i++) { + keyParts[i] = descriptors.get(i).getKey(); + } + mKey = Joiner.on(",").skipNulls().join(keyParts); + } + + /** + * Gets a key that uniquely identify all the underlying image resource to be loaded (e.g. Uri or + * file path). + */ + @Override + public String getKey() { + return mKey; + } + + public List getChildRequestDescriptors(){ + return mDescriptors; + } + + public abstract List getChildRequestTargetRects(); + public abstract CompositeImageRequest buildBatchImageRequest(final Context context); + + @Override + public MediaRequest buildSyncMediaRequest(final Context context) { + return buildBatchImageRequest(context); + } +} diff --git a/src/com/android/messaging/datamodel/media/CustomVCardEntry.java b/src/com/android/messaging/datamodel/media/CustomVCardEntry.java new file mode 100644 index 0000000..aee9fdc --- /dev/null +++ b/src/com/android/messaging/datamodel/media/CustomVCardEntry.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.accounts.Account; +import android.support.v4.util.ArrayMap; + +import com.android.vcard.VCardEntry; +import com.android.vcard.VCardProperty; + +import java.util.Map; + +/** + * Class which extends VCardEntry to add support for unknown properties. Currently there is a TODO + * to add this in the VCardEntry code, but we have to extend it to add the needed support + */ +public class CustomVCardEntry extends VCardEntry { + // List of properties keyed by their name for easy lookup + private final Map mAllProperties; + + public CustomVCardEntry(int vCardType, Account account) { + super(vCardType, account); + mAllProperties = new ArrayMap(); + } + + @Override + public void addProperty(VCardProperty property) { + super.addProperty(property); + mAllProperties.put(property.getName(), property); + } + + public VCardProperty getProperty(String name) { + return mAllProperties.get(name); + } +} diff --git a/src/com/android/messaging/datamodel/media/CustomVCardEntryConstructor.java b/src/com/android/messaging/datamodel/media/CustomVCardEntryConstructor.java new file mode 100644 index 0000000..06b10a3 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/CustomVCardEntryConstructor.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.accounts.Account; +import com.android.vcard.VCardConfig; +import com.android.vcard.VCardInterpreter; +import com.android.vcard.VCardProperty; + +import java.util.ArrayList; +import java.util.List; + +public class CustomVCardEntryConstructor implements VCardInterpreter { + + public interface EntryHandler { + /** + * Called when the parsing started. + */ + public void onStart(); + + /** + * The method called when one vCard entry is created. Children come before their parent in + * nested vCard files. + * + * e.g. + * In the following vCard, the entry for "entry2" comes before one for "entry1". + * + * BEGIN:VCARD + * N:entry1 + * BEGIN:VCARD + * N:entry2 + * END:VCARD + * END:VCARD + * + */ + public void onEntryCreated(final CustomVCardEntry entry); + + /** + * Called when the parsing ended. + * Able to be use this method for showing performance log, etc. + */ + public void onEnd(); + } + + /** + * Represents current stack of VCardEntry. Used to support nested vCard (vCard 2.1). + */ + private final List mEntryStack = new ArrayList(); + private CustomVCardEntry mCurrentEntry; + + private final int mVCardType; + private final Account mAccount; + + private final List mEntryHandlers = new ArrayList(); + + public CustomVCardEntryConstructor() { + this(VCardConfig.VCARD_TYPE_V21_GENERIC, null); + } + + public CustomVCardEntryConstructor(final int vcardType) { + this(vcardType, null); + } + + public CustomVCardEntryConstructor(final int vcardType, final Account account) { + mVCardType = vcardType; + mAccount = account; + } + + public void addEntryHandler(EntryHandler entryHandler) { + mEntryHandlers.add(entryHandler); + } + + @Override + public void onVCardStarted() { + for (EntryHandler entryHandler : mEntryHandlers) { + entryHandler.onStart(); + } + } + + @Override + public void onVCardEnded() { + for (EntryHandler entryHandler : mEntryHandlers) { + entryHandler.onEnd(); + } + } + + public void clear() { + mCurrentEntry = null; + mEntryStack.clear(); + } + + @Override + public void onEntryStarted() { + mCurrentEntry = new CustomVCardEntry(mVCardType, mAccount); + mEntryStack.add(mCurrentEntry); + } + + @Override + public void onEntryEnded() { + mCurrentEntry.consolidateFields(); + for (EntryHandler entryHandler : mEntryHandlers) { + entryHandler.onEntryCreated(mCurrentEntry); + } + + final int size = mEntryStack.size(); + if (size > 1) { + CustomVCardEntry parent = mEntryStack.get(size - 2); + parent.addChild(mCurrentEntry); + mCurrentEntry = parent; + } else { + mCurrentEntry = null; + } + mEntryStack.remove(size - 1); + } + + @Override + public void onPropertyCreated(VCardProperty property) { + mCurrentEntry.addProperty(property); + } +} \ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/DecodedImageResource.java b/src/com/android/messaging/datamodel/media/DecodedImageResource.java new file mode 100644 index 0000000..3627ba4 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/DecodedImageResource.java @@ -0,0 +1,254 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; + +import com.android.messaging.ui.OrientedBitmapDrawable; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; + +import java.util.List; + + +/** + * Container class for holding a bitmap resource used by the MediaResourceManager. This resource + * can both be cached (albeit not very storage-efficiently) and directly used by the UI. + */ +public class DecodedImageResource extends ImageResource { + private static final int BITMAP_QUALITY = 100; + private static final int COMPRESS_QUALITY = 50; + + private Bitmap mBitmap; + private final int mOrientation; + private boolean mCacheable = true; + + public DecodedImageResource(final String key, final Bitmap bitmap, int orientation) { + super(key, orientation); + mBitmap = bitmap; + mOrientation = orientation; + } + + /** + * Gets the contained bitmap. + */ + @Override + public Bitmap getBitmap() { + acquireLock(); + try { + return mBitmap; + } finally { + releaseLock(); + } + } + + /** + * Attempt to reuse the bitmap in the image resource and repurpose it for something else. + * After this, the image resource will relinquish ownership on the bitmap resource so that + * it doesn't try to recycle it when getting closed. + */ + @Override + public Bitmap reuseBitmap() { + acquireLock(); + try { + assertSingularRefCount(); + final Bitmap retBitmap = mBitmap; + mBitmap = null; + return retBitmap; + } finally { + releaseLock(); + } + } + + @Override + public boolean supportsBitmapReuse() { + return true; + } + + @Override + public byte[] getBytes() { + acquireLock(); + try { + return ImageUtils.bitmapToBytes(mBitmap, BITMAP_QUALITY); + } catch (final Exception e) { + LogUtil.e(LogUtil.BUGLE_TAG, "Error trying to get the bitmap bytes " + e); + } finally { + releaseLock(); + } + return null; + } + + /** + * Gets the orientation of the image as one of the ExifInterface.ORIENTATION_* constants + */ + @Override + public int getOrientation() { + return mOrientation; + } + + @Override + public int getMediaSize() { + acquireLock(); + try { + Assert.notNull(mBitmap); + if (OsUtil.isAtLeastKLP()) { + return mBitmap.getAllocationByteCount(); + } else { + return mBitmap.getRowBytes() * mBitmap.getHeight(); + } + } finally { + releaseLock(); + } + } + + @Override + protected void close() { + acquireLock(); + try { + if (mBitmap != null) { + mBitmap.recycle(); + mBitmap = null; + } + } finally { + releaseLock(); + } + } + + @Override + public Drawable getDrawable(Resources resources) { + acquireLock(); + try { + Assert.notNull(mBitmap); + return OrientedBitmapDrawable.create(getOrientation(), resources, mBitmap); + } finally { + releaseLock(); + } + } + + @Override + boolean isCacheable() { + return mCacheable; + } + + public void setCacheable(final boolean cacheable) { + mCacheable = cacheable; + } + + @SuppressWarnings("unchecked") + @Override + MediaRequest getMediaEncodingRequest( + final MediaRequest originalRequest) { + Assert.isFalse(isEncoded()); + if (getBitmap().hasAlpha()) { + // We can't compress images with alpha, as JPEG encoding doesn't support this. + return null; + } + return new EncodeImageRequest((MediaRequest) originalRequest); + } + + /** + * A MediaRequest that encodes the contained image resource. + */ + private class EncodeImageRequest implements MediaRequest { + private final MediaRequest mOriginalImageRequest; + + public EncodeImageRequest(MediaRequest originalImageRequest) { + mOriginalImageRequest = originalImageRequest; + // Hold a ref onto the encoded resource before the request finishes. + DecodedImageResource.this.addRef(); + } + + @Override + public String getKey() { + return DecodedImageResource.this.getKey(); + } + + @Override + @DoesNotRunOnMainThread + public ImageResource loadMediaBlocking(List> chainedRequests) + throws Exception { + Assert.isNotMainThread(); + acquireLock(); + Bitmap scaledBitmap = null; + try { + Bitmap bitmap = getBitmap(); + Assert.isFalse(bitmap.hasAlpha()); + final int bitmapWidth = bitmap.getWidth(); + final int bitmapHeight = bitmap.getHeight(); + // The original bitmap was loaded using sub-sampling which was fast in terms of + // loading speed, but not optimized for caching, encoding and rendering (since + // bitmap resizing to fit the UI image views happens on the UI thread and should + // be avoided if possible). Therefore, try to resize the bitmap to the exact desired + // size before compressing it. + if (bitmapWidth > 0 && bitmapHeight > 0 && + mOriginalImageRequest instanceof ImageRequest) { + final ImageRequestDescriptor descriptor = + ((ImageRequest) mOriginalImageRequest).getDescriptor(); + final float targetScale = Math.max( + (float) descriptor.desiredWidth / bitmapWidth, + (float) descriptor.desiredHeight / bitmapHeight); + final int targetWidth = (int) (bitmapWidth * targetScale); + final int targetHeight = (int) (bitmapHeight * targetScale); + // Only try to scale down the image to the desired size. + if (targetScale < 1.0f && targetWidth > 0 && targetHeight > 0 && + targetWidth != bitmapWidth && targetHeight != bitmapHeight) { + scaledBitmap = bitmap = + Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, false); + } + } + byte[] encodedBytes = ImageUtils.bitmapToBytes(bitmap, COMPRESS_QUALITY); + return new EncodedImageResource(getKey(), encodedBytes, getOrientation()); + } catch (Exception ex) { + // Something went wrong during bitmap compression, fall back to just using the + // original bitmap. + LogUtil.e(LogUtil.BUGLE_IMAGE_TAG, "Error compressing bitmap", ex); + return DecodedImageResource.this; + } finally { + if (scaledBitmap != null && scaledBitmap != getBitmap()) { + scaledBitmap.recycle(); + scaledBitmap = null; + } + releaseLock(); + release(); + } + } + + @Override + public MediaCache getMediaCache() { + return mOriginalImageRequest.getMediaCache(); + } + + @Override + public int getCacheId() { + return mOriginalImageRequest.getCacheId(); + } + + @Override + public int getRequestType() { + return REQUEST_ENCODE_MEDIA; + } + + @Override + public MediaRequestDescriptor getDescriptor() { + return mOriginalImageRequest.getDescriptor(); + } + } +} diff --git a/src/com/android/messaging/datamodel/media/EncodedImageResource.java b/src/com/android/messaging/datamodel/media/EncodedImageResource.java new file mode 100644 index 0000000..0bc94e5 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/EncodedImageResource.java @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.Drawable; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; + +import java.util.Arrays; +import java.util.List; + +/** + * A cache-facing image resource that's much more compact than the raw Bitmap objects stored in + * {@link com.android.messaging.datamodel.media.DecodedImageResource}. + * + * This resource is created from a regular Bitmap-based ImageResource before being pushed to + * {@link com.android.messaging.datamodel.media.MediaCache}, if the image request + * allows for resource encoding/compression. + * + * During resource retrieval on cache hit, + * {@link #getMediaDecodingRequest(MediaRequest)} is invoked to create a async + * decode task, which decodes the compressed byte array back to a regular image resource to + * be consumed by the UI. + */ +public class EncodedImageResource extends ImageResource { + private final byte[] mImageBytes; + + public EncodedImageResource(String key, byte[] imageBytes, int orientation) { + super(key, orientation); + mImageBytes = imageBytes; + } + + @Override + @DoesNotRunOnMainThread + public Bitmap getBitmap() { + acquireLock(); + try { + // This should only be called during the decode request. + Assert.isNotMainThread(); + return BitmapFactory.decodeByteArray(mImageBytes, 0, mImageBytes.length); + } finally { + releaseLock(); + } + } + + @Override + public byte[] getBytes() { + acquireLock(); + try { + return Arrays.copyOf(mImageBytes, mImageBytes.length); + } finally { + releaseLock(); + } + } + + @Override + public Bitmap reuseBitmap() { + return null; + } + + @Override + public boolean supportsBitmapReuse() { + return false; + } + + @Override + public int getMediaSize() { + return mImageBytes.length; + } + + @Override + protected void close() { + } + + @Override + public Drawable getDrawable(Resources resources) { + return null; + } + + @Override + boolean isEncoded() { + return true; + } + + @Override + MediaRequest getMediaDecodingRequest( + final MediaRequest originalRequest) { + Assert.isTrue(isEncoded()); + return new DecodeImageRequest(); + } + + /** + * A MediaRequest that decodes the encoded image resource. This class is chained to the + * original media request that requested the image, so it inherits the listener and + * properties such as binding. + */ + private class DecodeImageRequest implements MediaRequest { + public DecodeImageRequest() { + // Hold a ref onto the encoded resource before the request finishes. + addRef(); + } + + @Override + public String getKey() { + return EncodedImageResource.this.getKey(); + } + + @Override + @DoesNotRunOnMainThread + public ImageResource loadMediaBlocking(List> chainedTask) + throws Exception { + Assert.isNotMainThread(); + acquireLock(); + try { + final Bitmap decodedBitmap = BitmapFactory.decodeByteArray(mImageBytes, 0, + mImageBytes.length); + return new DecodedImageResource(getKey(), decodedBitmap, getOrientation()); + } finally { + releaseLock(); + release(); + } + } + + @Override + public MediaCache getMediaCache() { + // Decoded resource is non-cachable, it's for UI consumption only (for now at least) + return null; + } + + @Override + public int getCacheId() { + return 0; + } + + @Override + public int getRequestType() { + return REQUEST_DECODE_MEDIA; + } + + @Override + public MediaRequestDescriptor getDescriptor() { + return null; + } + } +} diff --git a/src/com/android/messaging/datamodel/media/FileImageRequest.java b/src/com/android/messaging/datamodel/media/FileImageRequest.java new file mode 100644 index 0000000..31c053a --- /dev/null +++ b/src/com/android/messaging/datamodel/media/FileImageRequest.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.ExifInterface; + +import com.android.messaging.datamodel.media.PoolableImageCache.ReusableImageResourcePool; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.LogUtil; + +import java.io.IOException; + +/** + * Serves file system based image requests. Since file paths can be expressed in Uri form, this + * extends regular UriImageRequest but performs additional optimizations such as loading thumbnails + * directly from Exif information. + */ +public class FileImageRequest extends UriImageRequest { + private final String mPath; + private final boolean mCanUseThumbnail; + + public FileImageRequest(final Context context, + final FileImageRequestDescriptor descriptor) { + super(context, descriptor); + mPath = descriptor.path; + mCanUseThumbnail = descriptor.canUseThumbnail; + } + + @Override + protected Bitmap loadBitmapInternal() + throws IOException { + // Before using the FileInputStream, check if the Exif has a thumbnail that we can use. + if (mCanUseThumbnail) { + byte[] thumbnail = null; + try { + final ExifInterface exif = new ExifInterface(mPath); + if (exif.hasThumbnail()) { + thumbnail = exif.getThumbnail(); + } + } catch (final IOException e) { + // Nothing to do + } + + if (thumbnail != null) { + final BitmapFactory.Options options = PoolableImageCache.getBitmapOptionsForPool( + false /* scaled */, 0 /* inputDensity */, 0 /* targetDensity */); + // First, check dimensions of the bitmap. + options.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(thumbnail, 0, thumbnail.length, options); + + // Calculate inSampleSize + options.inSampleSize = ImageUtils.get().calculateInSampleSize(options, + mDescriptor.desiredWidth, mDescriptor.desiredHeight); + + options.inJustDecodeBounds = false; + + // Actually decode the bitmap, optionally using the bitmap pool. + try { + // Get the orientation. We should be able to get the orientation from + // the thumbnail itself but at least on some phones, the thumbnail + // doesn't have an orientation tag. So use the outer image's orientation + // tag and hope for the best. + mOrientation = ImageUtils.getOrientation(getInputStreamForResource()); + if (com.android.messaging.util.exif.ExifInterface. + getOrientationParams(mOrientation).invertDimensions) { + mDescriptor.updateSourceDimensions(options.outHeight, options.outWidth); + } else { + mDescriptor.updateSourceDimensions(options.outWidth, options.outHeight); + } + final ReusableImageResourcePool bitmapPool = getBitmapPool(); + if (bitmapPool == null) { + return BitmapFactory.decodeByteArray(thumbnail, 0, thumbnail.length, + options); + } else { + final int sampledWidth = options.outWidth / options.inSampleSize; + final int sampledHeight = options.outHeight / options.inSampleSize; + return bitmapPool.decodeByteArray(thumbnail, options, sampledWidth, + sampledHeight); + } + } catch (IOException ex) { + // If the thumbnail is broken due to IOException, this will + // fall back to default bitmap loading. + LogUtil.e(LogUtil.BUGLE_IMAGE_TAG, "FileImageRequest: failed to load " + + "thumbnail from Exif", ex); + } + } + } + + // Fall back to default InputStream-based loading if no thumbnails could be retrieved. + return super.loadBitmapInternal(); + } +} diff --git a/src/com/android/messaging/datamodel/media/FileImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/FileImageRequestDescriptor.java new file mode 100644 index 0000000..00105f5 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/FileImageRequestDescriptor.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; + +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.UriUtil; + +/** + * Holds image request info about file system based image resource. + */ +public class FileImageRequestDescriptor extends UriImageRequestDescriptor { + public final String path; + + // Can we use the thumbnail image from Exif data? + public final boolean canUseThumbnail; + + /** + * Convenience constructor for when the image file's dimensions are not known. + */ + public FileImageRequestDescriptor(final String path, final int desiredWidth, + final int desiredHeight, final boolean canUseThumbnail, final boolean canCompress, + final boolean isStatic) { + this(path, desiredWidth, desiredHeight, FileImageRequest.UNSPECIFIED_SIZE, + FileImageRequest.UNSPECIFIED_SIZE, canUseThumbnail, canCompress, isStatic); + } + + /** + * Creates a new file image request with this descriptor. Oftentimes image file metadata + * has information such as the size of the image. Provide these metrics if they are known. + */ + public FileImageRequestDescriptor(final String path, final int desiredWidth, + final int desiredHeight, final int sourceWidth, final int sourceHeight, + final boolean canUseThumbnail, final boolean canCompress, final boolean isStatic) { + super(UriUtil.getUriForResourceFile(path), desiredWidth, desiredHeight, sourceWidth, + sourceHeight, canCompress, isStatic, false /* cropToCircle */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + this.path = path; + this.canUseThumbnail = canUseThumbnail; + } + + @Override + public String getKey() { + final String prefixKey = super.getKey(); + return prefixKey == null ? null : new StringBuilder(prefixKey).append(KEY_PART_DELIMITER) + .append(canUseThumbnail).toString(); + } + + @Override + public MediaRequest buildSyncMediaRequest(final Context context) { + return new FileImageRequest(context, this); + } +} \ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/GifImageResource.java b/src/com/android/messaging/datamodel/media/GifImageResource.java new file mode 100644 index 0000000..d50cf47 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/GifImageResource.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.media.ExifInterface; +import android.support.rastermill.FrameSequence; +import android.support.rastermill.FrameSequenceDrawable; + +import com.android.messaging.util.Assert; + +import java.io.IOException; +import java.io.InputStream; + +public class GifImageResource extends ImageResource { + private FrameSequence mFrameSequence; + + public GifImageResource(String key, FrameSequence frameSequence) { + // GIF does not support exif tags + super(key, ExifInterface.ORIENTATION_NORMAL); + mFrameSequence = frameSequence; + } + + public static GifImageResource createGifImageResource(String key, InputStream inputStream) { + final FrameSequence frameSequence; + try { + frameSequence = FrameSequence.decodeStream(inputStream); + } finally { + try { + inputStream.close(); + } catch (IOException e) { + // Nothing to do if we fail closing the stream + } + } + if (frameSequence == null) { + return null; + } + return new GifImageResource(key, frameSequence); + } + + @Override + public Drawable getDrawable(Resources resources) { + return new FrameSequenceDrawable(mFrameSequence); + } + + @Override + public Bitmap getBitmap() { + Assert.fail("GetBitmap() should never be called on a gif."); + return null; + } + + @Override + public byte[] getBytes() { + Assert.fail("GetBytes() should never be called on a gif."); + return null; + } + + @Override + public Bitmap reuseBitmap() { + return null; + } + + @Override + public boolean supportsBitmapReuse() { + // FrameSequenceDrawable a.) takes two bitmaps and thus does not fit into the current + // bitmap pool architecture b.) will rarely use bitmaps from one FrameSequenceDrawable to + // the next that are the same sizes since they are used by attachments. + return false; + } + + @Override + public int getMediaSize() { + Assert.fail("GifImageResource should not be used by a media cache"); + // Only used by the media cache, which this does not use. + return 0; + } + + @Override + public boolean isCacheable() { + return false; + } + + @Override + protected void close() { + acquireLock(); + try { + if (mFrameSequence != null) { + mFrameSequence = null; + } + } finally { + releaseLock(); + } + } + +} diff --git a/src/com/android/messaging/datamodel/media/ImageRequest.java b/src/com/android/messaging/datamodel/media/ImageRequest.java new file mode 100644 index 0000000..ab8880d --- /dev/null +++ b/src/com/android/messaging/datamodel/media/ImageRequest.java @@ -0,0 +1,258 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; + +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.media.PoolableImageCache.ReusableImageResourcePool; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.exif.ExifInterface; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +/** + * Base class that serves an image request for resolving, retrieving and decoding bitmap resources. + * + * Subclasses may choose to load images from different medium, such as from the file system or + * from the local content resolver, by overriding the abstract getInputStreamForResource() method. + */ +public abstract class ImageRequest + implements MediaRequest { + public static final int UNSPECIFIED_SIZE = MessagePartData.UNSPECIFIED_SIZE; + + protected final Context mContext; + protected final D mDescriptor; + protected int mOrientation; + + /** + * Creates a new image request with the given descriptor. + */ + public ImageRequest(final Context context, final D descriptor) { + mContext = context; + mDescriptor = descriptor; + } + + /** + * Gets a key that uniquely identify the underlying image resource to be loaded (e.g. Uri or + * file path). + */ + @Override + public String getKey() { + return mDescriptor.getKey(); + } + + /** + * Returns the image request descriptor attached to this request. + */ + @Override + public D getDescriptor() { + return mDescriptor; + } + + @Override + public int getRequestType() { + return MediaRequest.REQUEST_LOAD_MEDIA; + } + + /** + * Allows sub classes to specify that they want us to call getBitmapForResource rather than + * getInputStreamForResource + */ + protected boolean hasBitmapObject() { + return false; + } + + protected Bitmap getBitmapForResource() throws IOException { + return null; + } + + /** + * Retrieves an input stream from which image resource could be loaded. + * @throws FileNotFoundException + */ + protected abstract InputStream getInputStreamForResource() throws FileNotFoundException; + + /** + * Loads the image resource. This method is final; to override the media loading behavior + * the subclass should override {@link #loadMediaInternal(List)} + */ + @Override + public final ImageResource loadMediaBlocking(List> chainedTask) + throws IOException { + Assert.isNotMainThread(); + final ImageResource loadedResource = loadMediaInternal(chainedTask); + return postProcessOnBitmapResourceLoaded(loadedResource); + } + + protected ImageResource loadMediaInternal(List> chainedTask) + throws IOException { + if (!mDescriptor.isStatic() && isGif()) { + final GifImageResource gifImageResource = + GifImageResource.createGifImageResource(getKey(), getInputStreamForResource()); + if (gifImageResource == null) { + throw new RuntimeException("Error decoding gif"); + } + return gifImageResource; + } else { + final Bitmap loadedBitmap = loadBitmapInternal(); + if (loadedBitmap == null) { + throw new RuntimeException("failed decoding bitmap"); + } + return new DecodedImageResource(getKey(), loadedBitmap, mOrientation); + } + } + + protected boolean isGif() throws FileNotFoundException { + return ImageUtils.isGif(getInputStreamForResource()); + } + + /** + * The internal routine for loading the image. The caller may optionally provide the width + * and height of the source image if known so that we don't need to manually decode those. + */ + protected Bitmap loadBitmapInternal() throws IOException { + + final boolean unknownSize = mDescriptor.sourceWidth == UNSPECIFIED_SIZE || + mDescriptor.sourceHeight == UNSPECIFIED_SIZE; + + // If the ImageRequest has a Bitmap object rather than a stream, there's little to do here + if (hasBitmapObject()) { + final Bitmap bitmap = getBitmapForResource(); + if (bitmap != null && unknownSize) { + mDescriptor.updateSourceDimensions(bitmap.getWidth(), bitmap.getHeight()); + } + return bitmap; + } + + mOrientation = ImageUtils.getOrientation(getInputStreamForResource()); + + final BitmapFactory.Options options = PoolableImageCache.getBitmapOptionsForPool( + false /* scaled */, 0 /* inputDensity */, 0 /* targetDensity */); + // First, check dimensions of the bitmap if not already known. + if (unknownSize) { + final InputStream inputStream = getInputStreamForResource(); + if (inputStream != null) { + try { + options.inJustDecodeBounds = true; + BitmapFactory.decodeStream(inputStream, null, options); + // This is called when dimensions of image were unknown to allow db update + if (ExifInterface.getOrientationParams(mOrientation).invertDimensions) { + mDescriptor.updateSourceDimensions(options.outHeight, options.outWidth); + } else { + mDescriptor.updateSourceDimensions(options.outWidth, options.outHeight); + } + } finally { + inputStream.close(); + } + } else { + throw new FileNotFoundException(); + } + } else { + options.outWidth = mDescriptor.sourceWidth; + options.outHeight = mDescriptor.sourceHeight; + } + + // Calculate inSampleSize + options.inSampleSize = ImageUtils.get().calculateInSampleSize(options, + mDescriptor.desiredWidth, mDescriptor.desiredHeight); + Assert.isTrue(options.inSampleSize > 0); + + // Reopen the input stream and actually decode the bitmap. The initial + // BitmapFactory.decodeStream() reads the header portion of the bitmap stream and leave + // the input stream at the last read position. Since this input stream doesn't support + // mark() and reset(), the only viable way to reload the input stream is to re-open it. + // Alternatively, we could decode the bitmap into a byte array first and act on the byte + // array, but that also means the entire bitmap (for example a 10MB image from the gallery) + // without downsampling will have to be loaded into memory up front, which we don't want + // as it gives a much bigger possibility of OOM when handling big images. Therefore, the + // solution here is to close and reopen the bitmap input stream. + // For inline images the size is cached in DB and this hit is only taken once per image + final InputStream inputStream = getInputStreamForResource(); + if (inputStream != null) { + try { + options.inJustDecodeBounds = false; + + // Actually decode the bitmap, optionally using the bitmap pool. + final ReusableImageResourcePool bitmapPool = getBitmapPool(); + if (bitmapPool == null) { + return BitmapFactory.decodeStream(inputStream, null, options); + } else { + final int sampledWidth = (options.outWidth + options.inSampleSize - 1) / + options.inSampleSize; + final int sampledHeight = (options.outHeight + options.inSampleSize - 1) / + options.inSampleSize; + return bitmapPool.decodeSampledBitmapFromInputStream( + inputStream, options, sampledWidth, sampledHeight); + } + } finally { + inputStream.close(); + } + } else { + throw new FileNotFoundException(); + } + } + + private ImageResource postProcessOnBitmapResourceLoaded(final ImageResource loadedResource) { + if (mDescriptor.cropToCircle && loadedResource instanceof DecodedImageResource) { + final int width = mDescriptor.desiredWidth; + final int height = mDescriptor.desiredHeight; + final Bitmap sourceBitmap = loadedResource.getBitmap(); + final Bitmap targetBitmap = getBitmapPool().createOrReuseBitmap(width, height); + final RectF dest = new RectF(0, 0, width, height); + final RectF source = new RectF(0, 0, sourceBitmap.getWidth(), sourceBitmap.getHeight()); + final int backgroundColor = mDescriptor.circleBackgroundColor; + final int strokeColor = mDescriptor.circleStrokeColor; + ImageUtils.drawBitmapWithCircleOnCanvas(sourceBitmap, new Canvas(targetBitmap), source, + dest, null, backgroundColor == 0 ? false : true /* fillBackground */, + backgroundColor, strokeColor); + return new DecodedImageResource(getKey(), targetBitmap, + loadedResource.getOrientation()); + } + return loadedResource; + } + + /** + * Returns the bitmap pool for this image request. + */ + protected ReusableImageResourcePool getBitmapPool() { + return MediaCacheManager.get().getOrCreateBitmapPoolForCache(getCacheId()); + } + + @SuppressWarnings("unchecked") + @Override + public MediaCache getMediaCache() { + return (MediaCache) MediaCacheManager.get().getOrCreateMediaCacheById( + getCacheId()); + } + + /** + * Returns the cache id. Subclasses may override this to use a different cache. + */ + @Override + public int getCacheId() { + return BugleMediaCacheManager.DEFAULT_IMAGE_CACHE; + } +} diff --git a/src/com/android/messaging/datamodel/media/ImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/ImageRequestDescriptor.java new file mode 100644 index 0000000..20cb9af --- /dev/null +++ b/src/com/android/messaging/datamodel/media/ImageRequestDescriptor.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; + +import com.android.messaging.util.Assert; + +/** + * The base ImageRequest descriptor that describes the requirement of the requested image + * resource, including the desired size. It holds request info that will be consumed by + * ImageRequest instances. Subclasses of ImageRequest are expected to take + * more descriptions such as content URI or file path. + */ +public abstract class ImageRequestDescriptor extends MediaRequestDescriptor { + /** Desired size for the image (if known). This is used for bitmap downsampling */ + public final int desiredWidth; + public final int desiredHeight; + + /** Source size of the image (if known). This is used so that we don't have to manually decode + * the metrics from the image resource */ + public final int sourceWidth; + public final int sourceHeight; + + /** + * A static image resource is required, even if the image format supports animation (like Gif). + */ + public final boolean isStatic; + + /** + * The loaded image will be cropped to circular shape. + */ + public final boolean cropToCircle; + + /** + * The loaded image will be cropped to circular shape with the background color. + */ + public final int circleBackgroundColor; + + /** + * The loaded image will be cropped to circular shape with a stroke color. + */ + public final int circleStrokeColor; + + protected static final char KEY_PART_DELIMITER = '|'; + + /** + * Creates a new image request with unspecified width and height. In this case, the full + * bitmap is loaded and decoded, so unless you are sure that the image will be of + * reasonable size, you should consider limiting at least one of the two dimensions + * (for example, limiting the image width to the width of the ImageView container). + */ + public ImageRequestDescriptor() { + this(ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, + ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, false, false, 0, 0); + } + + public ImageRequestDescriptor(final int desiredWidth, final int desiredHeight) { + this(desiredWidth, desiredHeight, + ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, false, false, 0, 0); + } + + public ImageRequestDescriptor(final int desiredWidth, + final int desiredHeight, final int sourceWidth, final int sourceHeight, + final boolean isStatic, final boolean cropToCircle, final int circleBackgroundColor, + int circleStrokeColor) { + Assert.isTrue(desiredWidth == ImageRequest.UNSPECIFIED_SIZE || desiredWidth > 0); + Assert.isTrue(desiredHeight == ImageRequest.UNSPECIFIED_SIZE || desiredHeight > 0); + Assert.isTrue(sourceWidth == ImageRequest.UNSPECIFIED_SIZE || sourceWidth > 0); + Assert.isTrue(sourceHeight == ImageRequest.UNSPECIFIED_SIZE || sourceHeight > 0); + this.desiredWidth = desiredWidth; + this.desiredHeight = desiredHeight; + this.sourceWidth = sourceWidth; + this.sourceHeight = sourceHeight; + this.isStatic = isStatic; + this.cropToCircle = cropToCircle; + this.circleBackgroundColor = circleBackgroundColor; + this.circleStrokeColor = circleStrokeColor; + } + + public String getKey() { + return new StringBuilder() + .append(desiredWidth).append(KEY_PART_DELIMITER) + .append(desiredHeight).append(KEY_PART_DELIMITER) + .append(String.valueOf(cropToCircle)).append(KEY_PART_DELIMITER) + .append(String.valueOf(circleBackgroundColor)).append(KEY_PART_DELIMITER) + .append(String.valueOf(isStatic)).toString(); + } + + public boolean isStatic() { + return isStatic; + } + + @Override + public abstract MediaRequest buildSyncMediaRequest(Context context); + + // Called once source dimensions finally determined upon loading the image + public void updateSourceDimensions(final int sourceWidth, final int sourceHeight) { + } +} \ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/ImageResource.java b/src/com/android/messaging/datamodel/media/ImageResource.java new file mode 100644 index 0000000..75d817d --- /dev/null +++ b/src/com/android/messaging/datamodel/media/ImageResource.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; + +/** + * Base class for holding some form of image resource. The subclass gets to define the specific + * type of data format it's holding, whether it be Bitmap objects or compressed byte arrays. + */ +public abstract class ImageResource extends RefCountedMediaResource { + protected final int mOrientation; + + public ImageResource(final String key, int orientation) { + super(key); + mOrientation = orientation; + } + + /** + * Gets the contained image in drawable format. + */ + public abstract Drawable getDrawable(Resources resources); + + /** + * Gets the contained image in bitmap format. + */ + public abstract Bitmap getBitmap(); + + /** + * Gets the contained image in byte array format. + */ + public abstract byte[] getBytes(); + + /** + * Attempt to reuse the bitmap in the image resource and re-purpose it for something else. + * After this, the image resource will relinquish ownership on the bitmap resource so that + * it doesn't try to recycle it when getting closed. + */ + public abstract Bitmap reuseBitmap(); + public abstract boolean supportsBitmapReuse(); + + /** + * Gets the orientation of the image as one of the ExifInterface.ORIENTATION_* constants + */ + public int getOrientation() { + return mOrientation; + } +} diff --git a/src/com/android/messaging/datamodel/media/MediaBytes.java b/src/com/android/messaging/datamodel/media/MediaBytes.java new file mode 100644 index 0000000..823bf27 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MediaBytes.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + + +/** + * Container class for handing around media information used by the MediaResourceManager. + */ +public class MediaBytes extends RefCountedMediaResource { + private final byte[] mBytes; + + public MediaBytes(final String key, final byte[] bytes) { + super(key); + mBytes = bytes; + } + + public byte[] getMediaBytes() { + return mBytes; + } + + @Override + public int getMediaSize() { + return mBytes.length; + } + + @Override + protected void close() { + } +} diff --git a/src/com/android/messaging/datamodel/media/MediaCache.java b/src/com/android/messaging/datamodel/media/MediaCache.java new file mode 100644 index 0000000..510da2d --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MediaCache.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.util.LruCache; + +import com.android.messaging.util.LogUtil; + +/** + * A modified LruCache that is able to hold RefCountedMediaResource instances. It releases + * ref on the entries as they are evicted from the cache, and it uses the media resource + * size in kilobytes, instead of the entry count, as the size of the cache. + * + * This class is used by the MediaResourceManager class to maintain a number of caches for + * holding different types of {@link RefCountedMediaResource} + */ +public class MediaCache extends LruCache { + private static final String TAG = LogUtil.BUGLE_IMAGE_TAG; + + // Default memory cache size in kilobytes + protected static final int DEFAULT_MEDIA_RESOURCE_CACHE_SIZE_IN_KILOBYTES = 1024 * 5; // 5MB + + // Unique identifier for the cache. + private final int mId; + // Descriptive name given to the cache for debugging purposes. + private final String mName; + + // Convenience constructor that uses the default cache size. + public MediaCache(final int id, final String name) { + this(DEFAULT_MEDIA_RESOURCE_CACHE_SIZE_IN_KILOBYTES, id, name); + } + + public MediaCache(final int maxSize, final int id, final String name) { + super(maxSize); + mId = id; + mName = name; + } + + public void destroy() { + evictAll(); + } + + public String getName() { + return mName; + } + + public int getId() { + return mId; + } + + /** + * Gets a media resource from this cache. Must use this method to get resource instead of get() + * to ensure addRef() on the resource. + */ + public synchronized T fetchResourceFromCache(final String key) { + final T ret = get(key); + if (ret != null) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "cache hit in mediaCache @ " + getName() + + ", total cache hit = " + hitCount() + + ", total cache miss = " + missCount()); + } + ret.addRef(); + } else if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "cache miss in mediaCache @ " + getName() + + ", total cache hit = " + hitCount() + + ", total cache miss = " + missCount()); + } + return ret; + } + + /** + * Add a media resource to this cache. Must use this method to add resource instead of put() + * to ensure addRef() on the resource. + */ + public synchronized T addResourceToCache(final String key, final T mediaResource) { + mediaResource.addRef(); + return put(key, mediaResource); + } + + /** + * Notify the removed entry that is no longer being cached + */ + @Override + protected synchronized void entryRemoved(final boolean evicted, final String key, + final T oldValue, final T newValue) { + oldValue.release(); + } + + /** + * Measure item size in kilobytes rather than units which is more practical + * for a media resource cache + */ + @Override + protected int sizeOf(final String key, final T value) { + final int mediaSizeInKilobytes = value.getMediaSize() / 1024; + // Never zero-count any resource, count as at least 1KB. + return mediaSizeInKilobytes == 0 ? 1 : mediaSizeInKilobytes; + } +} \ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/MediaCacheManager.java b/src/com/android/messaging/datamodel/media/MediaCacheManager.java new file mode 100644 index 0000000..6e029f2 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MediaCacheManager.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.util.SparseArray; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.MemoryCacheManager; +import com.android.messaging.datamodel.MemoryCacheManager.MemoryCache; +import com.android.messaging.datamodel.media.PoolableImageCache.ReusableImageResourcePool; + +/** + * Manages a set of media caches by id. + */ +public abstract class MediaCacheManager implements MemoryCache { + public static MediaCacheManager get() { + return Factory.get().getMediaCacheManager(); + } + + protected final SparseArray> mCaches; + + public MediaCacheManager() { + mCaches = new SparseArray>(); + MemoryCacheManager.get().registerMemoryCache(this); + } + + @Override + public void reclaim() { + final int count = mCaches.size(); + for (int i = 0; i < count; i++) { + mCaches.valueAt(i).destroy(); + } + mCaches.clear(); + } + + public synchronized MediaCache getOrCreateMediaCacheById(final int id) { + MediaCache cache = mCaches.get(id); + if (cache == null) { + cache = createMediaCacheById(id); + if (cache != null) { + mCaches.put(id, cache); + } + } + return cache; + } + + public ReusableImageResourcePool getOrCreateBitmapPoolForCache(final int cacheId) { + final MediaCache cache = getOrCreateMediaCacheById(cacheId); + if (cache != null && cache instanceof PoolableImageCache) { + return ((PoolableImageCache) cache).asReusableBitmapPool(); + } + return null; + } + + protected abstract MediaCache createMediaCacheById(final int id); +} \ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/MediaRequest.java b/src/com/android/messaging/datamodel/media/MediaRequest.java new file mode 100644 index 0000000..703671b --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MediaRequest.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import java.util.List; + +/** + * Keeps track of a media loading request. MediaResourceManager uses this interface to load, encode, + * decode, and cache different types of media resource. + * + * This interface defines a media request class that's threading-model-blind. Wrapper classes + * (such as {@link AsyncMediaRequestWrapper} wraps around any base media request to offer async + * extensions). + */ +public interface MediaRequest { + public static final int REQUEST_ENCODE_MEDIA = 1; + public static final int REQUEST_DECODE_MEDIA = 2; + public static final int REQUEST_LOAD_MEDIA = 3; + + /** + * Returns a unique key used for storing and looking up the MediaRequest. + */ + String getKey(); + + /** + * This method performs the heavy-lifting work of synchronously loading the media bytes for + * this MediaRequest on a single threaded executor. + * @param chainedTask subsequent tasks to be performed after this request is complete. For + * example, an image request may need to compress the image resource before putting it in the + * cache + */ + T loadMediaBlocking(List> chainedTask) throws Exception; + + /** + * Returns the media cache where this MediaRequest wants to store the loaded + * media resource. + */ + MediaCache getMediaCache(); + + /** + * Returns the id of the cache where this MediaRequest wants to store the loaded + * media resource. + */ + int getCacheId(); + + /** + * Returns the request type of this media request, i.e. one of {@link #REQUEST_ENCODE_MEDIA}, + * {@link #REQUEST_DECODE_MEDIA}, or {@link #REQUEST_LOAD_MEDIA}. The default is + * {@link #REQUEST_LOAD_MEDIA} + */ + int getRequestType(); + + /** + * Returns the descriptor defining the request. + */ + MediaRequestDescriptor getDescriptor(); +} \ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/MediaRequestDescriptor.java b/src/com/android/messaging/datamodel/media/MediaRequestDescriptor.java new file mode 100644 index 0000000..216b2a3 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MediaRequestDescriptor.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; + +import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener; + +/** + * The base data holder/builder class for constructing async/sync MediaRequest objects during + * runtime. + */ +public abstract class MediaRequestDescriptor { + public abstract MediaRequest buildSyncMediaRequest(Context context); + + /** + * Builds an async media request to be used with + * {@link MediaResourceManager#requestMediaResourceAsync(MediaRequest)} + */ + public BindableMediaRequest buildAsyncMediaRequest(final Context context, + final MediaResourceLoadListener listener) { + final MediaRequest syncRequest = buildSyncMediaRequest(context); + return AsyncMediaRequestWrapper.createWith(syncRequest, listener); + } +} diff --git a/src/com/android/messaging/datamodel/media/MediaResourceManager.java b/src/com/android/messaging/datamodel/media/MediaResourceManager.java new file mode 100644 index 0000000..13f7291 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MediaResourceManager.java @@ -0,0 +1,325 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.os.AsyncTask; + +import com.android.messaging.Factory; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.RunsOnAnyThread; +import com.android.messaging.util.LogUtil; +import com.google.common.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; + +/** + *

Loads and maintains a set of in-memory LRU caches for different types of media resources. + * Right now we don't utilize any disk cache as all media urls are expected to be resolved to + * local content.

+ * + *

The MediaResourceManager takes media loading requests through one of two ways:

+ * + *
    + *
  1. {@link #requestMediaResourceAsync(MediaRequest)} that takes a MediaRequest, which may be a + * regular request if the caller doesn't want to listen for events (fire-and-forget), + * or an async request wrapper if event callback is needed.
  2. + *
  3. {@link #requestMediaResourceSync(MediaRequest)} which takes a MediaRequest and synchronously + * returns the loaded result, or null if failed.
  4. + *
+ * + *

For each media loading task, MediaResourceManager starts an AsyncTask that runs on a + * dedicated thread, which calls MediaRequest.loadMediaBlocking() to perform the actual media + * loading work. As the media resources are loaded, MediaResourceManager notifies the callers + * (which must implement the MediaResourceLoadListener interface) via onMediaResourceLoaded() + * callback. Meanwhile, MediaResourceManager also pushes the loaded resource onto its dedicated + * cache.

+ * + *

The media resource caches ({@link MediaCache}) are maintained as a set of LRU caches. They are + * created on demand by the incoming MediaRequest's getCacheId() method. The implementations of + * MediaRequest (such as {@link ImageRequest}) get to determine the desired cache id. For Bugle, + * the list of available caches are in {@link BugleMediaCacheManager}

+ * + *

Optionally, media loading can support on-demand media encoding and decoding. + * All {@link MediaRequest}'s can opt to chain additional {@link MediaRequest}'s to be executed + * after the completion of the main media loading task, by adding new tasks to the chained + * task list in {@link MediaRequest#loadMediaBlocking(List)}. One possible type of chained task is + * media encoding task. Loaded media will be encoded on a dedicated single threaded executor + * *after* the UI is notified of the loaded media. In this case, the encoded media resource will + * be eventually pushed to the cache, which will later be decoded before posting to the UI thread + * on cache hit.

+ * + *

To add support for a new type of media resource,

+ * + *
    + *
  1. Create a new subclass of {@link RefCountedMediaResource} for the new resource type (example: + * {@link ImageResource} class).
  2. + * + *
  3. Implement the {@link MediaRequest} interface (example: {@link ImageRequest}). Perform the + * media loading work in loadMediaBlocking() and return a cache id in getCacheId().
  4. + * + *
  5. For the UI component that requests the media resource, let it implement + * {@link MediaResourceLoadListener} interface to listen for resource load callback. Let the + * UI component call MediaResourceManager.requestMediaResourceAsync() to request a media source. + * (example: {@link com.android.messaging.ui.ContactIconView}
  6. + *
+ */ +public class MediaResourceManager { + private static final String TAG = LogUtil.BUGLE_TAG; + + public static MediaResourceManager get() { + return Factory.get().getMediaResourceManager(); + } + + /** + * Listener for asynchronous callback from media loading events. + */ + public interface MediaResourceLoadListener { + void onMediaResourceLoaded(MediaRequest request, T resource, boolean cached); + void onMediaResourceLoadError(MediaRequest request, Exception exception); + } + + // We use a fixed thread pool for handling media loading tasks. Using a cached thread pool + // allows for unlimited thread creation which can lead to OOMs so we limit the threads here. + private static final Executor MEDIA_LOADING_EXECUTOR = Executors.newFixedThreadPool(10); + + // A dedicated single thread executor for performing background task after loading the resource + // on the media loading executor. This includes work such as encoding loaded media to be cached. + // These tasks are run on a single worker thread with low priority so as not to contend with the + // media loading tasks. + private static final Executor MEDIA_BACKGROUND_EXECUTOR = Executors.newSingleThreadExecutor( + new ThreadFactory() { + @Override + public Thread newThread(final Runnable runnable) { + final Thread encodingThread = new Thread(runnable); + encodingThread.setPriority(Thread.MIN_PRIORITY); + return encodingThread; + } + }); + + /** + * Requests a media resource asynchronously. Upon completion of the media loading task, + * the listener will be notified of success/failure iff it's still bound. A refcount on the + * resource is held and guaranteed for the caller for the duration of the + * {@link MediaResourceLoadListener#onMediaResourceLoaded( + * MediaRequest, RefCountedMediaResource, boolean)} callback. + * @param mediaRequest the media request. May be either an + * {@link AsyncMediaRequestWrapper} for listening for event callbacks, or a regular media + * request for fire-and-forget type of behavior. + */ + public void requestMediaResourceAsync( + final MediaRequest mediaRequest) { + scheduleAsyncMediaRequest(mediaRequest, MEDIA_LOADING_EXECUTOR); + } + + /** + * Requests a media resource synchronously. + * @return the loaded resource with a refcount reserved for the caller. The caller must call + * release() on the resource once it's done using it (like with Cursors). + */ + public T requestMediaResourceSync( + final MediaRequest mediaRequest) { + Assert.isNotMainThread(); + // Block and load media. + MediaLoadingResult loadResult = null; + try { + loadResult = processMediaRequestInternal(mediaRequest); + // The loaded resource should have at least one refcount by now reserved for the caller. + Assert.isTrue(loadResult.loadedResource.getRefCount() > 0); + return loadResult.loadedResource; + } catch (final Exception e) { + LogUtil.e(LogUtil.BUGLE_TAG, "Synchronous media loading failed, key=" + + mediaRequest.getKey(), e); + return null; + } finally { + if (loadResult != null) { + // Schedule the background requests chained to the main request. + loadResult.scheduleChainedRequests(); + } + } + } + + @SuppressWarnings("unchecked") + private MediaLoadingResult processMediaRequestInternal( + final MediaRequest mediaRequest) + throws Exception { + final List> chainedRequests = new ArrayList<>(); + T loadedResource = null; + // Try fetching from cache first. + final T cachedResource = loadMediaFromCache(mediaRequest); + if (cachedResource != null) { + if (cachedResource.isEncoded()) { + // The resource is encoded, issue a decoding request. + final MediaRequest decodeRequest = (MediaRequest) cachedResource + .getMediaDecodingRequest(mediaRequest); + Assert.notNull(decodeRequest); + cachedResource.release(); + loadedResource = loadMediaFromRequest(decodeRequest, chainedRequests); + } else { + // The resource is ready-to-use. + loadedResource = cachedResource; + } + } else { + // Actually load the media after cache miss. + loadedResource = loadMediaFromRequest(mediaRequest, chainedRequests); + } + return new MediaLoadingResult<>(loadedResource, cachedResource != null /* fromCache */, + chainedRequests); + } + + private T loadMediaFromCache( + final MediaRequest mediaRequest) { + if (mediaRequest.getRequestType() != MediaRequest.REQUEST_LOAD_MEDIA) { + // Only look up in the cache if we are loading media. + return null; + } + final MediaCache mediaCache = mediaRequest.getMediaCache(); + if (mediaCache != null) { + final T mediaResource = mediaCache.fetchResourceFromCache(mediaRequest.getKey()); + if (mediaResource != null) { + return mediaResource; + } + } + return null; + } + + private T loadMediaFromRequest( + final MediaRequest mediaRequest, final List> chainedRequests) + throws Exception { + final T resource = mediaRequest.loadMediaBlocking(chainedRequests); + // mediaRequest.loadMediaBlocking() should never return null without + // throwing an exception. + Assert.notNull(resource); + // It's possible for the media to be evicted right after it's added to + // the cache (possibly because it's by itself too big for the cache). + // It's also possible that, after added to the cache, something else comes + // to the cache and evicts this media resource. To prevent this from + // recycling the underlying resource objects, make sure to add ref before + // adding to cache so that the caller is guaranteed a ref on the resource. + resource.addRef(); + // Don't cache the media request if it is defined as non-cacheable. + if (resource.isCacheable()) { + addResourceToMemoryCache(mediaRequest, resource); + } + return resource; + } + + /** + * Schedule an async media request on the given executor. + * @param mediaRequest the media request to be processed asynchronously. May be either an + * {@link AsyncMediaRequestWrapper} for listening for event callbacks, or a regular media + * request for fire-and-forget type of behavior. + */ + private void scheduleAsyncMediaRequest( + final MediaRequest mediaRequest, final Executor executor) { + final BindableMediaRequest bindableRequest = + (mediaRequest instanceof BindableMediaRequest) ? + (BindableMediaRequest) mediaRequest : null; + if (bindableRequest != null && !bindableRequest.isBound()) { + return; // Request is obsolete + } + // We don't use SafeAsyncTask here since it enforces the shared thread pool executor + // whereas we want a dedicated thread pool executor. + AsyncTask> mediaLoadingTask = + new AsyncTask>() { + private Exception mException; + + @Override + protected MediaLoadingResult doInBackground(Void... params) { + // Double check the request is still valid by the time we start processing it + if (bindableRequest != null && !bindableRequest.isBound()) { + return null; // Request is obsolete + } + try { + return processMediaRequestInternal(mediaRequest); + } catch (Exception e) { + mException = e; + return null; + } + } + + @Override + protected void onPostExecute(final MediaLoadingResult result) { + if (result != null) { + Assert.isNull(mException); + Assert.isTrue(result.loadedResource.getRefCount() > 0); + try { + if (bindableRequest != null) { + bindableRequest.onMediaResourceLoaded( + bindableRequest, result.loadedResource, result.fromCache); + } + } finally { + result.loadedResource.release(); + result.scheduleChainedRequests(); + } + } else if (mException != null) { + LogUtil.e(LogUtil.BUGLE_TAG, "Asynchronous media loading failed, key=" + + mediaRequest.getKey(), mException); + if (bindableRequest != null) { + bindableRequest.onMediaResourceLoadError(bindableRequest, mException); + } + } else { + Assert.isTrue(bindableRequest == null || !bindableRequest.isBound()); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "media request not processed, no longer bound; key=" + + LogUtil.sanitizePII(mediaRequest.getKey()) /* key with phone# */); + } + } + } + }; + mediaLoadingTask.executeOnExecutor(executor, (Void) null); + } + + @VisibleForTesting + @RunsOnAnyThread + void addResourceToMemoryCache( + final MediaRequest mediaRequest, final T mediaResource) { + Assert.isTrue(mediaResource != null); + final MediaCache mediaCache = mediaRequest.getMediaCache(); + if (mediaCache != null) { + mediaCache.addResourceToCache(mediaRequest.getKey(), mediaResource); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "added media resource to " + mediaCache.getName() + ". key=" + + LogUtil.sanitizePII(mediaRequest.getKey()) /* key can contain phone# */); + } + } + } + + private class MediaLoadingResult { + public final T loadedResource; + public final boolean fromCache; + private final List> mChainedRequests; + + MediaLoadingResult(final T loadedResource, final boolean fromCache, + final List> chainedRequests) { + this.loadedResource = loadedResource; + this.fromCache = fromCache; + mChainedRequests = chainedRequests; + } + + /** + * Asynchronously schedule a list of chained requests on the background thread. + */ + public void scheduleChainedRequests() { + for (final MediaRequest mediaRequest : mChainedRequests) { + scheduleAsyncMediaRequest(mediaRequest, MEDIA_BACKGROUND_EXECUTOR); + } + } + } +} diff --git a/src/com/android/messaging/datamodel/media/MessagePartImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/MessagePartImageRequestDescriptor.java new file mode 100644 index 0000000..1871e66 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MessagePartImageRequestDescriptor.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.media; + +import android.net.Uri; + +import com.android.messaging.datamodel.action.UpdateMessagePartSizeAction; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.util.ImageUtils; + +/** + * Image descriptor attached to a message part. + * Once image size is determined during loading this descriptor will update the db if necessary. + */ +public class MessagePartImageRequestDescriptor extends UriImageRequestDescriptor { + private final String mMessagePartId; + + /** + * Creates a new image request for a message part. + */ + public MessagePartImageRequestDescriptor(final MessagePartData messagePart, + final int desiredWidth, final int desiredHeight, boolean isStatic) { + // Pull image parameters out of the MessagePart record + this(messagePart.getPartId(), messagePart.getContentUri(), desiredWidth, desiredHeight, + messagePart.getWidth(), messagePart.getHeight(), isStatic); + } + + protected MessagePartImageRequestDescriptor(final String messagePartId, final Uri contentUri, + final int desiredWidth, final int desiredHeight, final int sourceWidth, + final int sourceHeight, boolean isStatic) { + super(contentUri, desiredWidth, desiredHeight, sourceWidth, sourceHeight, + true /* allowCompression */, isStatic, false /* cropToCircle */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + mMessagePartId = messagePartId; + } + + @Override + public void updateSourceDimensions(final int updatedWidth, final int updatedHeight) { + // If the dimensions of the image do not match then queue a DB update with new size. + // Don't update if we don't have a part id, which happens if this part is loaded as + // draft through actions such as share intent/message forwarding. + if (mMessagePartId != null && + updatedWidth != MessagePartData.UNSPECIFIED_SIZE && + updatedHeight != MessagePartData.UNSPECIFIED_SIZE && + updatedWidth != sourceWidth && updatedHeight != sourceHeight) { + UpdateMessagePartSizeAction.updateSize(mMessagePartId, updatedWidth, updatedHeight); + } + } +} diff --git a/src/com/android/messaging/datamodel/media/MessagePartVideoThumbnailRequestDescriptor.java b/src/com/android/messaging/datamodel/media/MessagePartVideoThumbnailRequestDescriptor.java new file mode 100644 index 0000000..ff11e92 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MessagePartVideoThumbnailRequestDescriptor.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.net.Uri; + +import com.android.messaging.datamodel.data.MessagePartData; + +public class MessagePartVideoThumbnailRequestDescriptor extends MessagePartImageRequestDescriptor { + public MessagePartVideoThumbnailRequestDescriptor(MessagePartData messagePart) { + super(messagePart, ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, false); + } + + public MessagePartVideoThumbnailRequestDescriptor(Uri uri) { + super(null, uri, ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, + ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, false); + } + + @Override + public MediaRequest buildSyncMediaRequest(final Context context) { + return new VideoThumbnailRequest(context, this); + } +} diff --git a/src/com/android/messaging/datamodel/media/NetworkUriImageRequest.java b/src/com/android/messaging/datamodel/media/NetworkUriImageRequest.java new file mode 100644 index 0000000..642e947 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/NetworkUriImageRequest.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; + +import com.android.messaging.Factory; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; + +/** + * Serves network content URI based image requests. + */ +public class NetworkUriImageRequest extends + ImageRequest { + + public NetworkUriImageRequest(Context context, D descriptor) { + super(context, descriptor); + mOrientation = android.media.ExifInterface.ORIENTATION_UNDEFINED; + } + + @Override + protected InputStream getInputStreamForResource() throws FileNotFoundException { + Assert.isNotMainThread(); + // Since we need to have an open urlConnection to get the stream, but we don't want to keep + // that connection open. There is no good way to perform this method. + return null; + } + + @Override + protected boolean isGif() throws FileNotFoundException { + Assert.isNotMainThread(); + + HttpURLConnection connection = null; + try { + final URL url = new URL(mDescriptor.uri.toString()); + connection = (HttpURLConnection) url.openConnection(); + connection.connect(); + if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) { + return ContentType.IMAGE_GIF.equalsIgnoreCase(connection.getContentType()); + } + } catch (MalformedURLException e) { + LogUtil.e(LogUtil.BUGLE_TAG, + "MalformedUrl for image with url: " + + mDescriptor.uri.toString(), e); + } catch (IOException e) { + LogUtil.e(LogUtil.BUGLE_TAG, + "IOException trying to get inputStream for image with url: " + + mDescriptor.uri.toString(), e); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + return false; + } + + @SuppressWarnings("deprecation") + @Override + public Bitmap loadBitmapInternal() throws IOException { + Assert.isNotMainThread(); + + InputStream inputStream = null; + Bitmap bitmap = null; + HttpURLConnection connection = null; + try { + final URL url = new URL(mDescriptor.uri.toString()); + connection = (HttpURLConnection) url.openConnection(); + connection.setDoInput(true); + connection.connect(); + if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) { + bitmap = BitmapFactory.decodeStream(connection.getInputStream()); + } + } catch (MalformedURLException e) { + LogUtil.e(LogUtil.BUGLE_TAG, + "MalformedUrl for image with url: " + + mDescriptor.uri.toString(), e); + } catch (final OutOfMemoryError e) { + LogUtil.e(LogUtil.BUGLE_TAG, + "OutOfMemoryError for image with url: " + + mDescriptor.uri.toString(), e); + Factory.get().reclaimMemory(); + } catch (IOException e) { + LogUtil.e(LogUtil.BUGLE_TAG, + "IOException trying to get inputStream for image with url: " + + mDescriptor.uri.toString(), e); + } finally { + if (inputStream != null) { + inputStream.close(); + } + if (connection != null) { + connection.disconnect(); + } + } + return bitmap; + } +} diff --git a/src/com/android/messaging/datamodel/media/PoolableImageCache.java b/src/com/android/messaging/datamodel/media/PoolableImageCache.java new file mode 100644 index 0000000..df814ba --- /dev/null +++ b/src/com/android/messaging/datamodel/media/PoolableImageCache.java @@ -0,0 +1,419 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.os.SystemClock; +import android.support.annotation.NonNull; +import android.util.SparseArray; + +import com.android.messaging.Factory; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedList; + +/** + * A media cache that holds image resources, which doubles as a bitmap pool that allows the + * consumer to optionally decode image resources using unused bitmaps stored in the cache. + */ +public class PoolableImageCache extends MediaCache { + private static final int MIN_TIME_IN_POOL = 5000; + + /** Encapsulates bitmap pool representation of the image cache */ + private final ReusableImageResourcePool mReusablePoolAccessor = new ReusableImageResourcePool(); + + public PoolableImageCache(final int id, final String name) { + this(DEFAULT_MEDIA_RESOURCE_CACHE_SIZE_IN_KILOBYTES, id, name); + } + + public PoolableImageCache(final int maxSize, final int id, final String name) { + super(maxSize, id, name); + } + + /** + * Creates a new BitmapFactory.Options for using the self-contained bitmap pool. + */ + public static BitmapFactory.Options getBitmapOptionsForPool(final boolean scaled, + final int inputDensity, final int targetDensity) { + final BitmapFactory.Options options = new BitmapFactory.Options(); + options.inScaled = scaled; + options.inDensity = inputDensity; + options.inTargetDensity = targetDensity; + options.inSampleSize = 1; + options.inJustDecodeBounds = false; + options.inMutable = true; + return options; + } + + @Override + public synchronized ImageResource addResourceToCache(final String key, + final ImageResource imageResource) { + mReusablePoolAccessor.onResourceEnterCache(imageResource); + return super.addResourceToCache(key, imageResource); + } + + @Override + protected synchronized void entryRemoved(final boolean evicted, final String key, + final ImageResource oldValue, final ImageResource newValue) { + mReusablePoolAccessor.onResourceLeaveCache(oldValue); + super.entryRemoved(evicted, key, oldValue, newValue); + } + + /** + * Returns a representation of the image cache as a reusable bitmap pool. + */ + public ReusableImageResourcePool asReusableBitmapPool() { + return mReusablePoolAccessor; + } + + /** + * A bitmap pool representation built on top of the image cache. It treats the image resources + * stored in the image cache as a self-contained bitmap pool and is able to create or + * reclaim bitmap resource as needed. + */ + public class ReusableImageResourcePool { + private static final int MAX_SUPPORTED_IMAGE_DIMENSION = 0xFFFF; + private static final int INVALID_POOL_KEY = 0; + + /** + * Number of reuse failures to skip before reporting. + * For debugging purposes, change to a lower number for more frequent reporting. + */ + private static final int FAILED_REPORTING_FREQUENCY = 100; + + /** + * Count of reuse failures which have occurred. + */ + private volatile int mFailedBitmapReuseCount = 0; + + /** + * Count of reuse successes which have occurred. + */ + private volatile int mSucceededBitmapReuseCount = 0; + + /** + * A sparse array from bitmap size to a list of image cache entries that match the + * given size. This map is used to quickly retrieve a usable bitmap to be reused by an + * incoming ImageRequest. We need to ensure that this sparse array always contains only + * elements currently in the image cache with no other consumer. + */ + private final SparseArray> mImageListSparseArray; + + public ReusableImageResourcePool() { + mImageListSparseArray = new SparseArray>(); + } + + /** + * Load an input stream into a bitmap. Uses a bitmap from the pool if possible to reduce + * memory turnover. + * @param inputStream InputStream load. Cannot be null. + * @param optionsTmp Should be the same options returned from getBitmapOptionsForPool(). + * Cannot be null. + * @param width The width of the bitmap. + * @param height The height of the bitmap. + * @return The decoded Bitmap with the resource drawn in it. + * @throws IOException + */ + public Bitmap decodeSampledBitmapFromInputStream(@NonNull final InputStream inputStream, + @NonNull final BitmapFactory.Options optionsTmp, + final int width, final int height) throws IOException { + if (width <= 0 || height <= 0) { + // This is an invalid / corrupted image of zero size. + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "PoolableImageCache: Decoding bitmap with " + + "invalid size"); + throw new IOException("Invalid size / corrupted image"); + } + Assert.notNull(inputStream); + assignPoolBitmap(optionsTmp, width, height); + Bitmap b = null; + try { + b = BitmapFactory.decodeStream(inputStream, null, optionsTmp); + mSucceededBitmapReuseCount++; + } catch (final IllegalArgumentException e) { + // BitmapFactory couldn't decode the file, try again without an inputBufferBitmap. + if (optionsTmp.inBitmap != null) { + optionsTmp.inBitmap.recycle(); + optionsTmp.inBitmap = null; + b = BitmapFactory.decodeStream(inputStream, null, optionsTmp); + onFailedToReuse(); + } + } catch (final OutOfMemoryError e) { + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "Oom decoding inputStream"); + Factory.get().reclaimMemory(); + } + return b; + } + + /** + * Turn encoded bytes into a bitmap. Uses a bitmap from the pool if possible to reduce + * memory turnover. + * @param bytes Encoded bytes to draw on the bitmap. Cannot be null. + * @param optionsTmp The bitmap will set here and the input should be generated from + * getBitmapOptionsForPool(). Cannot be null. + * @param width The width of the bitmap. + * @param height The height of the bitmap. + * @return A Bitmap with the encoded bytes drawn in it. + * @throws IOException + */ + public Bitmap decodeByteArray(@NonNull final byte[] bytes, + @NonNull final BitmapFactory.Options optionsTmp, final int width, + final int height) throws OutOfMemoryError, IOException { + if (width <= 0 || height <= 0) { + // This is an invalid / corrupted image of zero size. + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "PoolableImageCache: Decoding bitmap with " + + "invalid size"); + throw new IOException("Invalid size / corrupted image"); + } + Assert.notNull(bytes); + Assert.notNull(optionsTmp); + assignPoolBitmap(optionsTmp, width, height); + Bitmap b = null; + try { + b = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, optionsTmp); + mSucceededBitmapReuseCount++; + } catch (final IllegalArgumentException e) { + // BitmapFactory couldn't decode the file, try again without an inputBufferBitmap. + // (i.e. without the bitmap from the pool) + if (optionsTmp.inBitmap != null) { + optionsTmp.inBitmap.recycle(); + optionsTmp.inBitmap = null; + b = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, optionsTmp); + onFailedToReuse(); + } + } catch (final OutOfMemoryError e) { + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "Oom decoding inputStream"); + Factory.get().reclaimMemory(); + } + return b; + } + + /** + * Called when a new image resource is added to the cache. We add the resource to the + * pool so it's properly keyed into the pool structure. + */ + void onResourceEnterCache(final ImageResource imageResource) { + if (getPoolKey(imageResource) != INVALID_POOL_KEY) { + addResourceToPool(imageResource); + } + } + + /** + * Called when an image resource is evicted from the cache. Bitmap pool's entries are + * strictly tied to their presence in the image cache. Once an image is evicted from the + * cache, it should be removed from the pool. + */ + void onResourceLeaveCache(final ImageResource imageResource) { + if (getPoolKey(imageResource) != INVALID_POOL_KEY) { + removeResourceFromPool(imageResource); + } + } + + private void addResourceToPool(final ImageResource imageResource) { + synchronized (PoolableImageCache.this) { + final int poolKey = getPoolKey(imageResource); + Assert.isTrue(poolKey != INVALID_POOL_KEY); + LinkedList imageList = mImageListSparseArray.get(poolKey); + if (imageList == null) { + imageList = new LinkedList(); + mImageListSparseArray.put(poolKey, imageList); + } + imageList.addLast(imageResource); + } + } + + private void removeResourceFromPool(final ImageResource imageResource) { + synchronized (PoolableImageCache.this) { + final int poolKey = getPoolKey(imageResource); + Assert.isTrue(poolKey != INVALID_POOL_KEY); + final LinkedList imageList = mImageListSparseArray.get(poolKey); + if (imageList != null) { + imageList.remove(imageResource); + } + } + } + + /** + * Try to get a reusable bitmap from the pool with the given width and height. As a + * result of this call, the caller will assume ownership of the returned bitmap. + */ + private Bitmap getReusableBitmapFromPool(final int width, final int height) { + synchronized (PoolableImageCache.this) { + final int poolKey = getPoolKey(width, height); + if (poolKey != INVALID_POOL_KEY) { + final LinkedList images = mImageListSparseArray.get(poolKey); + if (images != null && images.size() > 0) { + // Try to reuse the first available bitmap from the pool list. We start from + // the least recently added cache entry of the given size. + ImageResource imageToUse = null; + for (int i = 0; i < images.size(); i++) { + final ImageResource image = images.get(i); + if (image.getRefCount() == 1) { + image.acquireLock(); + if (image.getRefCount() == 1) { + // The image is only used by the cache, so it's reusable. + imageToUse = images.remove(i); + break; + } else { + // Logically, this shouldn't happen, because as soon as the + // cache is the only user of this resource, it will not be + // used by anyone else until the next cache access, but we + // currently hold on to the cache lock. But technically + // future changes may violate this assumption, so warn about + // this. + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "Image refCount changed " + + "from 1 in getReusableBitmapFromPool()"); + image.releaseLock(); + } + } + } + + if (imageToUse == null) { + return null; + } + + try { + imageToUse.assertLockHeldByCurrentThread(); + + // Only reuse the bitmap if the last time we use was greater than 5s. + // This allows the cache a chance to reuse instead of always taking the + // oldest. + final long timeSinceLastRef = SystemClock.elapsedRealtime() - + imageToUse.getLastRefAddTimestamp(); + if (timeSinceLastRef < MIN_TIME_IN_POOL) { + if (LogUtil.isLoggable(LogUtil.BUGLE_IMAGE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, "Not reusing reusing " + + "first available bitmap from the pool because it " + + "has not been in the pool long enough. " + + "timeSinceLastRef=" + timeSinceLastRef); + } + // Put back the image and return no reuseable bitmap. + images.addLast(imageToUse); + return null; + } + + // Add a temp ref on the image resource so it won't be GC'd after + // being removed from the cache. + imageToUse.addRef(); + + // Remove the image resource from the image cache. + final ImageResource removed = remove(imageToUse.getKey()); + Assert.isTrue(removed == imageToUse); + + // Try to reuse the bitmap from the image resource. This will transfer + // ownership of the bitmap object to the caller of this method. + final Bitmap reusableBitmap = imageToUse.reuseBitmap(); + + imageToUse.release(); + return reusableBitmap; + } finally { + // We are either done with the reuse operation, or decided not to use + // the image. Either way, release the lock. + imageToUse.releaseLock(); + } + } + } + } + return null; + } + + /** + * Try to locate and return a reusable bitmap from the pool, or create a new bitmap. + * @param width desired bitmap width + * @param height desired bitmap height + * @return the created or reused mutable bitmap that has its background cleared to + * {@value Color#TRANSPARENT} + */ + public Bitmap createOrReuseBitmap(final int width, final int height) { + return createOrReuseBitmap(width, height, Color.TRANSPARENT); + } + + /** + * Try to locate and return a reusable bitmap from the pool, or create a new bitmap. + * @param width desired bitmap width + * @param height desired bitmap height + * @param backgroundColor the background color for the returned bitmap + * @return the created or reused mutable bitmap with the requested background color + */ + public Bitmap createOrReuseBitmap(final int width, final int height, + final int backgroundColor) { + Bitmap retBitmap = null; + try { + final Bitmap poolBitmap = getReusableBitmapFromPool(width, height); + retBitmap = (poolBitmap != null) ? poolBitmap : + Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + retBitmap.eraseColor(backgroundColor); + } catch (final OutOfMemoryError e) { + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "PoolableImageCache:try to createOrReuseBitmap"); + Factory.get().reclaimMemory(); + } + return retBitmap; + } + + private void assignPoolBitmap(final BitmapFactory.Options optionsTmp, final int width, + final int height) { + if (optionsTmp.inJustDecodeBounds) { + return; + } + optionsTmp.inBitmap = getReusableBitmapFromPool(width, height); + } + + /** + * @return The pool key for the provided image dimensions or 0 if either width or height is + * greater than the max supported image dimension. + */ + private int getPoolKey(final int width, final int height) { + if (width > MAX_SUPPORTED_IMAGE_DIMENSION || height > MAX_SUPPORTED_IMAGE_DIMENSION) { + return INVALID_POOL_KEY; + } + return (width << 16) | height; + } + + /** + * @return the pool key for a given image resource. + */ + private int getPoolKey(final ImageResource imageResource) { + if (imageResource.supportsBitmapReuse()) { + final Bitmap bitmap = imageResource.getBitmap(); + if (bitmap != null && bitmap.isMutable()) { + final int width = bitmap.getWidth(); + final int height = bitmap.getHeight(); + if (width > 0 && height > 0) { + return getPoolKey(width, height); + } + } + } + return INVALID_POOL_KEY; + } + + /** + * Called when bitmap reuse fails. Conditionally report the failure with statistics. + */ + private void onFailedToReuse() { + mFailedBitmapReuseCount++; + if (mFailedBitmapReuseCount % FAILED_REPORTING_FREQUENCY == 0) { + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, + "Pooled bitmap consistently not being reused. Failure count = " + + mFailedBitmapReuseCount + ", success count = " + + mSucceededBitmapReuseCount); + } + } + } +} diff --git a/src/com/android/messaging/datamodel/media/RefCountedMediaResource.java b/src/com/android/messaging/datamodel/media/RefCountedMediaResource.java new file mode 100644 index 0000000..c21f477 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/RefCountedMediaResource.java @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.os.SystemClock; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.google.common.base.Throwables; + +import java.util.ArrayList; +import java.util.concurrent.locks.ReentrantLock; + +/** + * A ref-counted class that holds loaded media resource, be it bitmaps or media bytes. + * Subclasses must implement the close() method to release any resources (such as bitmaps) + * when it's no longer used. + * + * Instances of the subclasses are: + * 1. Loaded by their corresponding MediaRequest classes. + * 2. Maintained by MediaResourceManager in its MediaCache pool. + * 3. Used by the UI (such as ContactIconViews) to present the content. + * + * Note: all synchronized methods in this class (e.g. addRef()) should not attempt to make outgoing + * calls that could potentially acquire media cache locks due to the potential deadlock this can + * cause. To synchronize read/write access to shared resource, {@link #acquireLock()} and + * {@link #releaseLock()} must be used, instead of using synchronized keyword. + */ +public abstract class RefCountedMediaResource { + private final String mKey; + private int mRef = 0; + private long mLastRefAddTimestamp; + + // Set DEBUG to true to enable detailed stack trace for each addRef() and release() operation + // to find out where each ref change happens. + private static final boolean DEBUG = false; + private static final String TAG = "bugle_media_ref_history"; + private final ArrayList mRefHistory = new ArrayList(); + + // A lock that guards access to shared members in this class (and all its subclasses). + private final ReentrantLock mLock = new ReentrantLock(); + + public RefCountedMediaResource(final String key) { + mKey = key; + } + + public String getKey() { + return mKey; + } + + public void addRef() { + acquireLock(); + try { + if (DEBUG) { + mRefHistory.add("Added ref current ref = " + mRef); + mRefHistory.add(Throwables.getStackTraceAsString(new Exception())); + } + + mRef++; + mLastRefAddTimestamp = SystemClock.elapsedRealtime(); + } finally { + releaseLock(); + } + } + + public void release() { + acquireLock(); + try { + if (DEBUG) { + mRefHistory.add("Released ref current ref = " + mRef); + mRefHistory.add(Throwables.getStackTraceAsString(new Exception())); + } + + mRef--; + if (mRef == 0) { + close(); + } else if (mRef < 0) { + if (DEBUG) { + LogUtil.i(TAG, "Unwinding ref count history for RefCountedMediaResource " + + this); + for (final String ref : mRefHistory) { + LogUtil.i(TAG, ref); + } + } + Assert.fail("RefCountedMediaResource has unbalanced ref. Refcount=" + mRef); + } + } finally { + releaseLock(); + } + } + + public int getRefCount() { + acquireLock(); + try { + return mRef; + } finally { + releaseLock(); + } + } + + public long getLastRefAddTimestamp() { + acquireLock(); + try { + return mLastRefAddTimestamp; + } finally { + releaseLock(); + } + } + + public void assertSingularRefCount() { + acquireLock(); + try { + Assert.equals(1, mRef); + } finally { + releaseLock(); + } + } + + void acquireLock() { + mLock.lock(); + } + + void releaseLock() { + mLock.unlock(); + } + + void assertLockHeldByCurrentThread() { + Assert.isTrue(mLock.isHeldByCurrentThread()); + } + + boolean isEncoded() { + return false; + } + + boolean isCacheable() { + return true; + } + + MediaRequest getMediaDecodingRequest( + final MediaRequest originalRequest) { + return null; + } + + MediaRequest getMediaEncodingRequest( + final MediaRequest originalRequest) { + return null; + } + + public abstract int getMediaSize(); + protected abstract void close(); +} \ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/SimSelectorAvatarRequest.java b/src/com/android/messaging/datamodel/media/SimSelectorAvatarRequest.java new file mode 100644 index 0000000..e4f0334 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/SimSelectorAvatarRequest.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.BitmapDrawable; +import android.media.ExifInterface; +import android.text.TextUtils; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; + +import java.io.IOException; +import java.util.List; + +public class SimSelectorAvatarRequest extends AvatarRequest { + private static Bitmap sRegularSimIcon; + + public SimSelectorAvatarRequest(final Context context, + final AvatarRequestDescriptor descriptor) { + super(context, descriptor); + } + + /** + * {@inheritDoc} + */ + @Override + protected ImageResource loadMediaInternal(List> chainedTasks) + throws IOException { + Assert.isNotMainThread(); + final String avatarType = AvatarUriUtil.getAvatarType(mDescriptor.uri); + if (AvatarUriUtil.TYPE_SIM_SELECTOR_URI.equals(avatarType)){ + final int width = mDescriptor.desiredWidth; + final int height = mDescriptor.desiredHeight; + final String identifier = AvatarUriUtil.getIdentifier(mDescriptor.uri); + final boolean simSelected = AvatarUriUtil.getSimSelected(mDescriptor.uri); + final int simColor = AvatarUriUtil.getSimColor(mDescriptor.uri); + final boolean incoming = AvatarUriUtil.getSimIncoming(mDescriptor.uri); + return renderSimAvatarInternal(identifier, width, height, simColor, simSelected, + incoming); + } + return super.loadMediaInternal(chainedTasks); + } + + private ImageResource renderSimAvatarInternal(final String identifier, final int width, + final int height, final int subColor, final boolean selected, final boolean incoming) { + final Resources resources = mContext.getResources(); + final float halfWidth = width / 2; + final float halfHeight = height / 2; + final int minOfWidthAndHeight = Math.min(width, height); + final int backgroundColor = selected ? subColor : Color.WHITE; + final int textColor = selected ? subColor : Color.WHITE; + final int simColor = selected ? Color.WHITE : subColor; + final Bitmap bitmap = getBitmapPool().createOrReuseBitmap(width, height, backgroundColor); + final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + final Canvas canvas = new Canvas(bitmap); + + if (sRegularSimIcon == null) { + final BitmapDrawable regularSim = (BitmapDrawable) mContext.getResources() + .getDrawable(R.drawable.ic_sim_card_send); + sRegularSimIcon = regularSim.getBitmap(); + } + + paint.setColorFilter(new PorterDuffColorFilter(simColor, PorterDuff.Mode.SRC_ATOP)); + paint.setAlpha(0xff); + canvas.drawBitmap(sRegularSimIcon, halfWidth - sRegularSimIcon.getWidth() / 2, + halfHeight - sRegularSimIcon.getHeight() / 2, paint); + paint.setColorFilter(null); + paint.setAlpha(0xff); + + if (!TextUtils.isEmpty(identifier)) { + paint.setTypeface(Typeface.create("sans-serif", Typeface.NORMAL)); + paint.setColor(textColor); + final float letterToTileRatio = + resources.getFraction(R.dimen.sim_identifier_to_tile_ratio, 1, 1); + paint.setTextSize(letterToTileRatio * minOfWidthAndHeight); + + final String firstCharString = identifier.substring(0, 1).toUpperCase(); + final Rect textBound = new Rect(); + paint.getTextBounds(firstCharString, 0, 1, textBound); + + final float xOffset = halfWidth - textBound.centerX(); + final float yOffset = halfHeight - textBound.centerY(); + canvas.drawText(firstCharString, xOffset, yOffset, paint); + } + + return new DecodedImageResource(getKey(), bitmap, ExifInterface.ORIENTATION_NORMAL); + } + + @Override + public int getCacheId() { + return BugleMediaCacheManager.AVATAR_IMAGE_CACHE; + } +} diff --git a/src/com/android/messaging/datamodel/media/UriImageRequest.java b/src/com/android/messaging/datamodel/media/UriImageRequest.java new file mode 100644 index 0000000..b4934ca --- /dev/null +++ b/src/com/android/messaging/datamodel/media/UriImageRequest.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +/** + * Serves local content URI based image requests. + */ +public class UriImageRequest extends ImageRequest { + public UriImageRequest(final Context context, final D descriptor) { + super(context, descriptor); + } + + @Override + protected InputStream getInputStreamForResource() throws FileNotFoundException { + return mContext.getContentResolver().openInputStream(mDescriptor.uri); + } + + @Override + protected ImageResource loadMediaInternal(List> chainedTasks) + throws IOException { + final ImageResource resource = super.loadMediaInternal(chainedTasks); + // Check if the caller asked for compression. If so, chain an encoding task if possible. + if (mDescriptor.allowCompression && chainedTasks != null) { + @SuppressWarnings("unchecked") + final MediaRequest chainedTask = (MediaRequest) + resource.getMediaEncodingRequest(this); + if (chainedTask != null) { + chainedTasks.add(chainedTask); + // Don't cache decoded image resource since we'll perform compression and cache + // the compressed resource. + if (resource instanceof DecodedImageResource) { + ((DecodedImageResource) resource).setCacheable(false); + } + } + } + return resource; + } +} diff --git a/src/com/android/messaging/datamodel/media/UriImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/UriImageRequestDescriptor.java new file mode 100644 index 0000000..c5685d1 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/UriImageRequestDescriptor.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.net.Uri; + +import com.android.messaging.util.UriUtil; + +public class UriImageRequestDescriptor extends ImageRequestDescriptor { + public final Uri uri; + public final boolean allowCompression; + + public UriImageRequestDescriptor(final Uri uri) { + this(uri, UriImageRequest.UNSPECIFIED_SIZE, UriImageRequest.UNSPECIFIED_SIZE, false, false, + false, 0, 0); + } + + public UriImageRequestDescriptor(final Uri uri, final int desiredWidth, final int desiredHeight) + { + this(uri, desiredWidth, desiredHeight, false, false, false, 0, 0); + } + + public UriImageRequestDescriptor(final Uri uri, final int desiredWidth, final int desiredHeight, + final boolean cropToCircle, final int circleBackgroundColor, int circleStrokeColor) + { + this(uri, desiredWidth, desiredHeight, false, + false, cropToCircle, circleBackgroundColor, circleStrokeColor); + } + + public UriImageRequestDescriptor(final Uri uri, final int desiredWidth, + final int desiredHeight, final boolean allowCompression, boolean isStatic, + boolean cropToCircle, int circleBackgroundColor, int circleStrokeColor) { + this(uri, desiredWidth, desiredHeight, UriImageRequest.UNSPECIFIED_SIZE, + UriImageRequest.UNSPECIFIED_SIZE, allowCompression, isStatic, cropToCircle, + circleBackgroundColor, circleStrokeColor); + } + + /** + * Creates a new Uri-based image request. + * @param uri the content Uri. Currently Bugle only supports local resources Uri (i.e. it has + * to begin with content: or android.resource: + * @param circleStrokeColor + */ + public UriImageRequestDescriptor(final Uri uri, final int desiredWidth, + final int desiredHeight, final int sourceWidth, final int sourceHeight, + final boolean allowCompression, final boolean isStatic, final boolean cropToCircle, + final int circleBackgroundColor, int circleStrokeColor) { + super(desiredWidth, desiredHeight, sourceWidth, sourceHeight, isStatic, + cropToCircle, circleBackgroundColor, circleStrokeColor); + this.uri = uri; + this.allowCompression = allowCompression; + } + + @Override + public String getKey() { + if (uri != null) { + final String key = super.getKey(); + if (key != null) { + return new StringBuilder() + .append(uri).append(KEY_PART_DELIMITER) + .append(String.valueOf(allowCompression)).append(KEY_PART_DELIMITER) + .append(key).toString(); + } + } + return null; + } + + @Override + public MediaRequest buildSyncMediaRequest(final Context context) { + if (uri == null || UriUtil.isLocalUri(uri)) { + return new UriImageRequest(context, this); + } else { + return new NetworkUriImageRequest(context, this); + } + } + + /** ID of the resource in MediaStore or null if this resource didn't come from MediaStore */ + public Long getMediaStoreId() { + return null; + } +} \ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/VCardRequest.java b/src/com/android/messaging/datamodel/media/VCardRequest.java new file mode 100644 index 0000000..d6e992c --- /dev/null +++ b/src/com/android/messaging/datamodel/media/VCardRequest.java @@ -0,0 +1,328 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.UriUtil; +import com.android.vcard.VCardConfig; +import com.android.vcard.VCardEntry; +import com.android.vcard.VCardEntryCounter; +import com.android.vcard.VCardInterpreter; +import com.android.vcard.VCardParser; +import com.android.vcard.VCardParser_V21; +import com.android.vcard.VCardParser_V30; +import com.android.vcard.VCardSourceDetector; +import com.android.vcard.exception.VCardException; +import com.android.vcard.exception.VCardNestedException; +import com.android.vcard.exception.VCardNotSupportedException; +import com.android.vcard.exception.VCardVersionException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Requests and parses VCard data. In Bugle, we need to display VCard details in the conversation + * view such as avatar icon and name, which can be expensive if we parse VCard every time. + * Therefore, we'd like to load the vcard once and cache it in our media cache using the + * MediaResourceManager component. To load the VCard, we use framework's VCard support to + * interpret the VCard content, which gives us information such as phone and email list, which + * we'll put in VCardResource object to be cached. + * + * Some particular attention is needed for the avatar icon. If the VCard contains avatar icon, + * it's in byte array form that can't easily be cached/persisted. Therefore, we persist the + * image bytes to the scratch directory and generate a content Uri for it, so that ContactIconView + * may use this Uri to display and cache the image if needed. + */ +public class VCardRequest implements MediaRequest { + private final Context mContext; + private final VCardRequestDescriptor mDescriptor; + private final List mLoadedVCards; + private VCardResource mLoadedResource; + private static final int VCARD_LOADING_TIMEOUT_MILLIS = 10000; // 10s + private static final String DEFAULT_VCARD_TYPE = "default"; + + VCardRequest(final Context context, final VCardRequestDescriptor descriptor) { + mDescriptor = descriptor; + mContext = context; + mLoadedVCards = new ArrayList(); + } + + @Override + public String getKey() { + return mDescriptor.vCardUri.toString(); + } + + @Override + @DoesNotRunOnMainThread + public VCardResource loadMediaBlocking(List> chainedTask) + throws Exception { + Assert.isNotMainThread(); + Assert.isTrue(mLoadedResource == null); + Assert.equals(0, mLoadedVCards.size()); + + // The VCard library doesn't support synchronously loading the media resource. Therefore, + // We have to burn the thread waiting for the result to come back. + final CountDownLatch signal = new CountDownLatch(1); + if (!parseVCard(mDescriptor.vCardUri, signal)) { + // Directly fail without actually going through the interpreter, return immediately. + throw new VCardException("Invalid vcard"); + } + + signal.await(VCARD_LOADING_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); + if (mLoadedResource == null) { + // Maybe null if failed or timeout. + throw new VCardException("Failure or timeout loading vcard"); + } + return mLoadedResource; + } + + @Override + public int getCacheId() { + return BugleMediaCacheManager.VCARD_CACHE; + } + + @SuppressWarnings("unchecked") + @Override + public MediaCache getMediaCache() { + return (MediaCache) MediaCacheManager.get().getOrCreateMediaCacheById( + getCacheId()); + } + + @DoesNotRunOnMainThread + private boolean parseVCard(final Uri targetUri, final CountDownLatch signal) { + Assert.isNotMainThread(); + final VCardEntryCounter counter = new VCardEntryCounter(); + final VCardSourceDetector detector = new VCardSourceDetector(); + boolean result; + try { + // We don't know which type should be used to parse the Uri. + // It is possible to misinterpret the vCard, but we expect the parser + // lets VCardSourceDetector detect the type before the misinterpretation. + result = readOneVCardFile(targetUri, VCardConfig.VCARD_TYPE_UNKNOWN, + detector, true, null); + } catch (final VCardNestedException e) { + try { + final int estimatedVCardType = detector.getEstimatedType(); + // Assume that VCardSourceDetector was able to detect the source. + // Try again with the detector. + result = readOneVCardFile(targetUri, estimatedVCardType, + counter, false, null); + } catch (final VCardNestedException e2) { + result = false; + LogUtil.e(LogUtil.BUGLE_TAG, "Must not reach here. " + e2); + } + } + + if (!result) { + // Load failure. + return false; + } + + return doActuallyReadOneVCard(targetUri, true, detector, null, signal); + } + + @DoesNotRunOnMainThread + private boolean doActuallyReadOneVCard(final Uri uri, final boolean showEntryParseProgress, + final VCardSourceDetector detector, final List errorFileNameList, + final CountDownLatch signal) { + Assert.isNotMainThread(); + int vcardType = detector.getEstimatedType(); + if (vcardType == VCardConfig.VCARD_TYPE_UNKNOWN) { + vcardType = VCardConfig.getVCardTypeFromString(DEFAULT_VCARD_TYPE); + } + final CustomVCardEntryConstructor builder = + new CustomVCardEntryConstructor(vcardType, null); + builder.addEntryHandler(new ContactVCardEntryHandler(signal)); + + try { + if (!readOneVCardFile(uri, vcardType, builder, false, null)) { + return false; + } + } catch (final VCardNestedException e) { + LogUtil.e(LogUtil.BUGLE_TAG, "Must not reach here. " + e); + return false; + } + return true; + } + + @DoesNotRunOnMainThread + private boolean readOneVCardFile(final Uri uri, final int vcardType, + final VCardInterpreter interpreter, + final boolean throwNestedException, final List errorFileNameList) + throws VCardNestedException { + Assert.isNotMainThread(); + final ContentResolver resolver = mContext.getContentResolver(); + VCardParser vCardParser; + InputStream is; + try { + is = resolver.openInputStream(uri); + vCardParser = new VCardParser_V21(vcardType); + vCardParser.addInterpreter(interpreter); + + try { + vCardParser.parse(is); + } catch (final VCardVersionException e1) { + try { + is.close(); + } catch (final IOException e) { + // Do nothing. + } + if (interpreter instanceof CustomVCardEntryConstructor) { + // Let the object clean up internal temporal objects, + ((CustomVCardEntryConstructor) interpreter).clear(); + } + + is = resolver.openInputStream(uri); + + try { + vCardParser = new VCardParser_V30(vcardType); + vCardParser.addInterpreter(interpreter); + vCardParser.parse(is); + } catch (final VCardVersionException e2) { + throw new VCardException("vCard with unspported version."); + } + } finally { + if (is != null) { + try { + is.close(); + } catch (final IOException e) { + // Do nothing. + } + } + } + } catch (final IOException e) { + LogUtil.e(LogUtil.BUGLE_TAG, "IOException was emitted: " + e.getMessage()); + + if (errorFileNameList != null) { + errorFileNameList.add(uri.toString()); + } + return false; + } catch (final VCardNotSupportedException e) { + if ((e instanceof VCardNestedException) && throwNestedException) { + throw (VCardNestedException) e; + } + if (errorFileNameList != null) { + errorFileNameList.add(uri.toString()); + } + return false; + } catch (final VCardException e) { + if (errorFileNameList != null) { + errorFileNameList.add(uri.toString()); + } + return false; + } + return true; + } + + class ContactVCardEntryHandler implements CustomVCardEntryConstructor.EntryHandler { + final CountDownLatch mSignal; + + public ContactVCardEntryHandler(final CountDownLatch signal) { + mSignal = signal; + } + + @Override + public void onStart() { + } + + @Override + @DoesNotRunOnMainThread + public void onEntryCreated(final CustomVCardEntry entry) { + Assert.isNotMainThread(); + final String displayName = entry.getDisplayName(); + final List photos = entry.getPhotoList(); + Uri avatarUri = null; + if (photos != null && photos.size() > 0) { + // The photo data is in bytes form, so we need to persist it in our temp directory + // so that ContactIconView can load it and display it later + // (and cache it, of course). + for (final VCardEntry.PhotoData photo : photos) { + final byte[] photoBytes = photo.getBytes(); + if (photoBytes != null) { + final InputStream inputStream = new ByteArrayInputStream(photoBytes); + try { + avatarUri = UriUtil.persistContentToScratchSpace(inputStream); + if (avatarUri != null) { + // Just load the first avatar and be done. Want more? wait for V2. + break; + } + } finally { + try { + inputStream.close(); + } catch (final IOException e) { + // Do nothing. + } + } + } + } + } + + // Fall back to generated avatar. + if (avatarUri == null) { + String destination = null; + final List phones = entry.getPhoneList(); + if (phones != null && phones.size() > 0) { + destination = PhoneUtils.getDefault().getCanonicalBySystemLocale( + phones.get(0).getNumber()); + } + + if (destination == null) { + final List emails = entry.getEmailList(); + if (emails != null && emails.size() > 0) { + destination = emails.get(0).getAddress(); + } + } + avatarUri = AvatarUriUtil.createAvatarUri(null, displayName, destination, null); + } + + // Add the loaded vcard to the list. + mLoadedVCards.add(new VCardResourceEntry(entry, avatarUri)); + } + + @Override + public void onEnd() { + // Finished loading all vCard entries, signal the loading thread to proceed with the + // result. + if (mLoadedVCards.size() > 0) { + mLoadedResource = new VCardResource(getKey(), mLoadedVCards); + } + mSignal.countDown(); + } + } + + @Override + public int getRequestType() { + return MediaRequest.REQUEST_LOAD_MEDIA; + } + + @Override + public MediaRequestDescriptor getDescriptor() { + return mDescriptor; + } +} diff --git a/src/com/android/messaging/datamodel/media/VCardRequestDescriptor.java b/src/com/android/messaging/datamodel/media/VCardRequestDescriptor.java new file mode 100644 index 0000000..4084851 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/VCardRequestDescriptor.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.net.Uri; + +import com.android.messaging.util.Assert; + +public class VCardRequestDescriptor extends MediaRequestDescriptor { + public final Uri vCardUri; + + public VCardRequestDescriptor(final Uri vCardUri) { + Assert.notNull(vCardUri); + this.vCardUri = vCardUri; + } + + @Override + public MediaRequest buildSyncMediaRequest(Context context) { + return new VCardRequest(context, this); + } +} diff --git a/src/com/android/messaging/datamodel/media/VCardResource.java b/src/com/android/messaging/datamodel/media/VCardResource.java new file mode 100644 index 0000000..edf5e88 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/VCardResource.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import java.util.List; + +/** + * Holds cached information of VCard contact info. + * The temporarily persisted avatar icon Uri is tied to the VCardResource. As a result, whenever + * the VCardResource is no longer used (i.e. close() is called), we need to asynchronously + * delete the avatar image from temp storage since no one will have reference to the avatar Uri + * again. The next time the same VCard is displayed, since the old resource has been evicted from + * the memory cache, we'll load and persist the avatar icon again. + */ +public class VCardResource extends RefCountedMediaResource { + private final List mVCards; + + public VCardResource(final String key, final List vcards) { + super(key); + mVCards = vcards; + } + + public List getVCards() { + return mVCards; + } + + @Override + public int getMediaSize() { + // Instead of track VCards by size in kilobytes, we track them by count. + return 0; + } + + @Override + protected void close() { + for (final VCardResourceEntry vcard : mVCards) { + vcard.close(); + } + } +} diff --git a/src/com/android/messaging/datamodel/media/VCardResourceEntry.java b/src/com/android/messaging/datamodel/media/VCardResourceEntry.java new file mode 100644 index 0000000..f76b796 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/VCardResourceEntry.java @@ -0,0 +1,389 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Intent; +import android.content.res.Resources; +import android.content.res.Resources.NotFoundException; +import android.net.Uri; +import android.provider.ContactsContract.CommonDataKinds.Im; +import android.provider.ContactsContract.CommonDataKinds.Organization; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.support.v4.util.ArrayMap; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.datamodel.data.PersonItemData; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.SafeAsyncTask; +import com.android.vcard.VCardEntry; +import com.android.vcard.VCardEntry.EmailData; +import com.android.vcard.VCardEntry.ImData; +import com.android.vcard.VCardEntry.NoteData; +import com.android.vcard.VCardEntry.OrganizationData; +import com.android.vcard.VCardEntry.PhoneData; +import com.android.vcard.VCardEntry.PostalData; +import com.android.vcard.VCardEntry.WebsiteData; +import com.android.vcard.VCardProperty; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; + +/** + * Holds one entry item (i.e. a single contact) within a VCard resource. It is able to take + * a VCardEntry and extract relevant information from it. + */ +public class VCardResourceEntry { + public static final String PROPERTY_KIND = "KIND"; + + public static final String KIND_LOCATION = "location"; + + private final List mContactInfo; + private final Uri mAvatarUri; + private final String mDisplayName; + private final CustomVCardEntry mVCard; + + public VCardResourceEntry(final CustomVCardEntry vcard, final Uri avatarUri) { + mContactInfo = getContactInfoFromVCardEntry(vcard); + mDisplayName = getDisplayNameFromVCardEntry(vcard); + mAvatarUri = avatarUri; + mVCard = vcard; + } + + void close() { + // If the avatar image was temporarily saved in the scratch folder, remove that. + if (MediaScratchFileProvider.isMediaScratchSpaceUri(mAvatarUri)) { + SafeAsyncTask.executeOnThreadPool(new Runnable() { + @Override + public void run() { + Factory.get().getApplicationContext().getContentResolver().delete( + mAvatarUri, null, null); + } + }); + } + } + + public String getKind() { + VCardProperty kindProperty = mVCard.getProperty(PROPERTY_KIND); + return kindProperty == null ? null : kindProperty.getRawValue(); + } + + public Uri getAvatarUri() { + return mAvatarUri; + } + + public String getDisplayName() { + return mDisplayName; + } + + public String getDisplayAddress() { + List postalList = mVCard.getPostalList(); + if (postalList == null || postalList.size() < 1) { + return null; + } + + return formatAddress(postalList.get(0)); + } + + public String getNotes() { + List notes = mVCard.getNotes(); + if (notes == null || notes.size() == 0) { + return null; + } + StringBuilder noteBuilder = new StringBuilder(); + for (NoteData note : notes) { + noteBuilder.append(note.getNote()); + } + return noteBuilder.toString(); + } + + /** + * Returns a UI-facing representation that can be bound and consumed by the UI layer to display + * this VCard resource entry. + */ + public PersonItemData getDisplayItem() { + return new PersonItemData() { + @Override + public Uri getAvatarUri() { + return VCardResourceEntry.this.getAvatarUri(); + } + + @Override + public String getDisplayName() { + return VCardResourceEntry.this.getDisplayName(); + } + + @Override + public String getDetails() { + return null; + } + + @Override + public Intent getClickIntent() { + return null; + } + + @Override + public long getContactId() { + return ContactUtil.INVALID_CONTACT_ID; + } + + @Override + public String getLookupKey() { + return null; + } + + @Override + public String getNormalizedDestination() { + return null; + } + }; + } + + public List getContactInfo() { + return mContactInfo; + } + + private static List getContactInfoFromVCardEntry( + final VCardEntry vcard) { + final Resources resources = Factory.get().getApplicationContext().getResources(); + final List retList = + new ArrayList(); + if (vcard.getPhoneList() != null) { + for (final PhoneData phone : vcard.getPhoneList()) { + final Intent intent = new Intent(Intent.ACTION_DIAL); + intent.setData(Uri.parse("tel:" + phone.getNumber())); + retList.add(new VCardResourceEntryDestinationItem(phone.getNumber(), + Phone.getTypeLabel(resources, phone.getType(), phone.getLabel()).toString(), + intent)); + } + } + + if (vcard.getEmailList() != null) { + for (final EmailData email : vcard.getEmailList()) { + final Intent intent = new Intent(Intent.ACTION_SENDTO); + intent.setData(Uri.parse("mailto:")); + intent.putExtra(Intent.EXTRA_EMAIL, new String[] { email.getAddress() }); + retList.add(new VCardResourceEntryDestinationItem(email.getAddress(), + Phone.getTypeLabel(resources, email.getType(), + email.getLabel()).toString(), intent)); + } + } + + if (vcard.getPostalList() != null) { + for (final PostalData postalData : vcard.getPostalList()) { + String type; + try { + type = resources. + getStringArray(android.R.array.postalAddressTypes) + [postalData.getType() - 1]; + } catch (final NotFoundException ex) { + type = resources.getStringArray(android.R.array.postalAddressTypes)[2]; + } catch (final Exception e) { + LogUtil.e(LogUtil.BUGLE_TAG, "createContactItem postal Exception:" + e); + type = resources.getStringArray(android.R.array.postalAddressTypes)[2]; + } + Intent intent = new Intent(Intent.ACTION_VIEW); + final String address = formatAddress(postalData); + try { + intent.setData(Uri.parse("geo:0,0?q=" + URLEncoder.encode(address, "UTF-8"))); + } catch (UnsupportedEncodingException e) { + intent = null; + } + + retList.add(new VCardResourceEntryDestinationItem(address, type, intent)); + } + } + + if (vcard.getImList() != null) { + for (final ImData imData : vcard.getImList()) { + String type = null; + try { + type = resources. + getString(Im.getProtocolLabelResource(imData.getProtocol())); + } catch (final NotFoundException ex) { + // Do nothing since this implies an empty label. + } + retList.add(new VCardResourceEntryDestinationItem(imData.getAddress(), type, null)); + } + } + + if (vcard.getOrganizationList() != null) { + for (final OrganizationData organtization : vcard.getOrganizationList()) { + String type = null; + try { + type = resources.getString(Organization.getTypeLabelResource( + organtization.getType())); + } catch (final NotFoundException ex) { + //set other kind as "other" + type = resources.getStringArray(android.R.array.organizationTypes)[1]; + } catch (final Exception e) { + LogUtil.e(LogUtil.BUGLE_TAG, "createContactItem org Exception:" + e); + type = resources.getStringArray(android.R.array.organizationTypes)[1]; + } + retList.add(new VCardResourceEntryDestinationItem( + organtization.getOrganizationName(), type, null)); + } + } + + if (vcard.getWebsiteList() != null) { + for (final WebsiteData web : vcard.getWebsiteList()) { + if (web != null && TextUtils.isGraphic(web.getWebsite())){ + String website = web.getWebsite(); + if (!website.startsWith("http://") && !website.startsWith("https://")) { + // Prefix required for parsing to end up with a scheme and result in + // navigation + website = "http://" + website; + } + final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(website)); + retList.add(new VCardResourceEntryDestinationItem(web.getWebsite(), null, + intent)); + } + } + } + + if (vcard.getBirthday() != null) { + final String birthday = vcard.getBirthday(); + if (TextUtils.isGraphic(birthday)){ + retList.add(new VCardResourceEntryDestinationItem(birthday, + resources.getString(R.string.vcard_detail_birthday_label), null)); + } + } + + if (vcard.getNotes() != null) { + for (final NoteData note : vcard.getNotes()) { + final ArrayMap curChildMap = new ArrayMap(); + if (TextUtils.isGraphic(note.getNote())){ + retList.add(new VCardResourceEntryDestinationItem(note.getNote(), + resources.getString(R.string.vcard_detail_notes_label), null)); + } + } + } + return retList; + } + + private static String formatAddress(final PostalData postalData) { + final StringBuilder sb = new StringBuilder(); + final String poBox = postalData.getPobox(); + if (!TextUtils.isEmpty(poBox)) { + sb.append(poBox); + sb.append(" "); + } + final String extendedAddress = postalData.getExtendedAddress(); + if (!TextUtils.isEmpty(extendedAddress)) { + sb.append(extendedAddress); + sb.append(" "); + } + final String street = postalData.getStreet(); + if (!TextUtils.isEmpty(street)) { + sb.append(street); + sb.append(" "); + } + final String localty = postalData.getLocalty(); + if (!TextUtils.isEmpty(localty)) { + sb.append(localty); + sb.append(" "); + } + final String region = postalData.getRegion(); + if (!TextUtils.isEmpty(region)) { + sb.append(region); + sb.append(" "); + } + final String postalCode = postalData.getPostalCode(); + if (!TextUtils.isEmpty(postalCode)) { + sb.append(postalCode); + sb.append(" "); + } + final String country = postalData.getCountry(); + if (!TextUtils.isEmpty(country)) { + sb.append(country); + } + return sb.toString(); + } + + private static String getDisplayNameFromVCardEntry(final VCardEntry vcard) { + String name = vcard.getDisplayName(); + if (name == null) { + vcard.consolidateFields(); + name = vcard.getDisplayName(); + } + return name; + } + + /** + * Represents one entry line (e.g. phone number and phone label) for a single contact. Each + * VCardResourceEntry may hold one or more VCardResourceEntryDestinationItem's. + */ + public static class VCardResourceEntryDestinationItem { + private final String mDisplayDestination; + private final String mDestinationType; + private final Intent mClickIntent; + public VCardResourceEntryDestinationItem(final String displayDestination, + final String destinationType, final Intent clickIntent) { + mDisplayDestination = displayDestination; + mDestinationType = destinationType; + mClickIntent = clickIntent; + } + + /** + * Returns a UI-facing representation that can be bound and consumed by the UI layer to + * display this VCard resource destination entry. + */ + public PersonItemData getDisplayItem() { + return new PersonItemData() { + @Override + public Uri getAvatarUri() { + return null; + } + + @Override + public String getDisplayName() { + return mDisplayDestination; + } + + @Override + public String getDetails() { + return mDestinationType; + } + + @Override + public Intent getClickIntent() { + return mClickIntent; + } + + @Override + public long getContactId() { + return ContactUtil.INVALID_CONTACT_ID; + } + + @Override + public String getLookupKey() { + return null; + } + + @Override + public String getNormalizedDestination() { + return null; + } + }; + } + } +} diff --git a/src/com/android/messaging/datamodel/media/VideoThumbnailRequest.java b/src/com/android/messaging/datamodel/media/VideoThumbnailRequest.java new file mode 100644 index 0000000..f17591c --- /dev/null +++ b/src/com/android/messaging/datamodel/media/VideoThumbnailRequest.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.media; + +import android.content.ContentResolver; +import android.content.Context; +import android.graphics.Bitmap; +import android.provider.MediaStore.Video.Thumbnails; + +import com.android.messaging.Factory; +import com.android.messaging.util.MediaMetadataRetrieverWrapper; +import com.android.messaging.util.OsUtil; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; + +/** + * Class to request a video thumbnail. + * Users of this class as responsible for checking {@link #shouldShowIncomingVideoThumbnails} + */ +public class VideoThumbnailRequest extends ImageRequest { + + public VideoThumbnailRequest(final Context context, + final UriImageRequestDescriptor descriptor) { + super(context, descriptor); + } + + public static boolean shouldShowIncomingVideoThumbnails() { + return OsUtil.isAtLeastM(); + } + + @Override + protected InputStream getInputStreamForResource() throws FileNotFoundException { + return null; + } + + @Override + protected boolean hasBitmapObject() { + return true; + } + + @Override + protected Bitmap getBitmapForResource() throws IOException { + final Long mediaId = mDescriptor.getMediaStoreId(); + Bitmap bitmap = null; + if (mediaId != null) { + final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver(); + bitmap = Thumbnails.getThumbnail(cr, mediaId, Thumbnails.MICRO_KIND, null); + } else { + final MediaMetadataRetrieverWrapper retriever = new MediaMetadataRetrieverWrapper(); + try { + retriever.setDataSource(mDescriptor.uri); + bitmap = retriever.getFrameAtTime(); + } finally { + retriever.release(); + } + } + if (bitmap != null) { + mDescriptor.updateSourceDimensions(bitmap.getWidth(), bitmap.getHeight()); + } + return bitmap; + } +} diff --git a/src/com/android/messaging/datamodel/media/VideoThumbnailRequestDescriptor.java b/src/com/android/messaging/datamodel/media/VideoThumbnailRequestDescriptor.java new file mode 100644 index 0000000..907bb8f --- /dev/null +++ b/src/com/android/messaging/datamodel/media/VideoThumbnailRequestDescriptor.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; + +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.UriUtil; + +public class VideoThumbnailRequestDescriptor extends UriImageRequestDescriptor { + protected final long mMediaId; + public VideoThumbnailRequestDescriptor(final long id, String path, int desiredWidth, + int desiredHeight, int sourceWidth, int sourceHeight) { + super(UriUtil.getUriForResourceFile(path), desiredWidth, desiredHeight, sourceWidth, + sourceHeight, false /* canCompress */, false /* isStatic */, + false /* cropToCircle */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + mMediaId = id; + } + + @Override + public MediaRequest buildSyncMediaRequest(Context context) { + return new VideoThumbnailRequest(context, this); + } + + @Override + public Long getMediaStoreId() { + return mMediaId; + } +} diff --git a/src/com/android/messaging/mmslib/Downloads.java b/src/com/android/messaging/mmslib/Downloads.java new file mode 100644 index 0000000..9afc48c --- /dev/null +++ b/src/com/android/messaging/mmslib/Downloads.java @@ -0,0 +1,807 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib; + +import android.app.DownloadManager; +import android.content.Context; +import android.net.Uri; +import android.provider.BaseColumns; + +/** + * The Download Manager + * + * @pending + */ +public final class Downloads { + private Downloads() {} + + /** + * Implementation details + * + * Exposes constants used to interact with the download manager's + * content provider. + * The constants URI ... STATUS are the names of columns in the downloads table. + * + * @hide + */ + public static final class Impl implements BaseColumns { + private Impl() {} + + /** + * The permission to access the download manager + */ + public static final String PERMISSION_ACCESS = "android.permission.ACCESS_DOWNLOAD_MANAGER"; + + /** + * The permission to access the download manager's advanced functions + */ + public static final String PERMISSION_ACCESS_ADVANCED = + "android.permission.ACCESS_DOWNLOAD_MANAGER_ADVANCED"; + + /** + * The permission to access the all the downloads in the manager. + */ + public static final String PERMISSION_ACCESS_ALL = + "android.permission.ACCESS_ALL_DOWNLOADS"; + + /** + * The permission to directly access the download manager's cache + * directory + */ + public static final String PERMISSION_CACHE = "android.permission.ACCESS_CACHE_FILESYSTEM"; + + /** + * The permission to send broadcasts on download completion + */ + public static final String PERMISSION_SEND_INTENTS = + "android.permission.SEND_DOWNLOAD_COMPLETED_INTENTS"; + + /** + * The permission to download files to the cache partition that won't be automatically + * purged when space is needed. + */ + public static final String PERMISSION_CACHE_NON_PURGEABLE = + "android.permission.DOWNLOAD_CACHE_NON_PURGEABLE"; + + /** + * The permission to download files without any system notification being shown. + */ + public static final String PERMISSION_NO_NOTIFICATION = + "android.permission.DOWNLOAD_WITHOUT_NOTIFICATION"; + + /** + * The content:// URI to access downloads owned by the caller's UID. + */ + public static final Uri CONTENT_URI = + Uri.parse("content://downloads/my_downloads"); + + /** + * The content URI for accessing all downloads across all UIDs (requires the + * ACCESS_ALL_DOWNLOADS permission). + */ + public static final Uri ALL_DOWNLOADS_CONTENT_URI = + Uri.parse("content://downloads/all_downloads"); + + /** URI segment to access a publicly accessible downloaded file */ + public static final String PUBLICLY_ACCESSIBLE_DOWNLOADS_URI_SEGMENT = "public_downloads"; + + /** + * The content URI for accessing publicly accessible downloads (i.e., it requires no + * permissions to access this downloaded file) + */ + public static final Uri PUBLICLY_ACCESSIBLE_DOWNLOADS_URI = + Uri.parse("content://downloads/" + PUBLICLY_ACCESSIBLE_DOWNLOADS_URI_SEGMENT); + + /** + * Broadcast Action: this is sent by the download manager to the app + * that had initiated a download when that download completes. The + * download's content: uri is specified in the intent's data. + */ + public static final String ACTION_DOWNLOAD_COMPLETED = + "android.intent.action.DOWNLOAD_COMPLETED"; + + /** + * Broadcast Action: this is sent by the download manager to the app + * that had initiated a download when the user selects the notification + * associated with that download. The download's content: uri is specified + * in the intent's data if the click is associated with a single download, + * or Downloads.CONTENT_URI if the notification is associated with + * multiple downloads. + * Note: this is not currently sent for downloads that have completed + * successfully. + */ + public static final String ACTION_NOTIFICATION_CLICKED = + "android.intent.action.DOWNLOAD_NOTIFICATION_CLICKED"; + + /** + * The name of the column containing the URI of the data being downloaded. + *

Type: TEXT

+ *

Owner can Init/Read

+ */ + public static final String COLUMN_URI = "uri"; + + /** + * The name of the column containing application-specific data. + *

Type: TEXT

+ *

Owner can Init/Read/Write

+ */ + public static final String COLUMN_APP_DATA = "entity"; + + /** + * The name of the column containing the flags that indicates whether + * the initiating application is capable of verifying the integrity of + * the downloaded file. When this flag is set, the download manager + * performs downloads and reports success even in some situations where + * it can't guarantee that the download has completed (e.g. when doing + * a byte-range request without an ETag, or when it can't determine + * whether a download fully completed). + *

Type: BOOLEAN

+ *

Owner can Init

+ */ + public static final String COLUMN_NO_INTEGRITY = "no_integrity"; + + /** + * The name of the column containing the filename that the initiating + * application recommends. When possible, the download manager will attempt + * to use this filename, or a variation, as the actual name for the file. + *

Type: TEXT

+ *

Owner can Init

+ */ + public static final String COLUMN_FILE_NAME_HINT = "hint"; + + /** + * The name of the column containing the filename where the downloaded data + * was actually stored. + *

Type: TEXT

+ *

Owner can Read

+ */ + public static final String _DATA = "_data"; + + /** + * The name of the column containing the MIME type of the downloaded data. + *

Type: TEXT

+ *

Owner can Init/Read

+ */ + public static final String COLUMN_MIME_TYPE = "mimetype"; + + /** + * The name of the column containing the flag that controls the destination + * of the download. See the DESTINATION_* constants for a list of legal values. + *

Type: INTEGER

+ *

Owner can Init

+ */ + public static final String COLUMN_DESTINATION = "destination"; + + /** + * The name of the column containing the flags that controls whether the + * download is displayed by the UI. See the VISIBILITY_* constants for + * a list of legal values. + *

Type: INTEGER

+ *

Owner can Init/Read/Write

+ */ + public static final String COLUMN_VISIBILITY = "visibility"; + + /** + * The name of the column containing the current control state of the download. + * Applications can write to this to control (pause/resume) the download. + * the CONTROL_* constants for a list of legal values. + *

Type: INTEGER

+ *

Owner can Read

+ */ + public static final String COLUMN_CONTROL = "control"; + + /** + * The name of the column containing the current status of the download. + * Applications can read this to follow the progress of each download. See + * the STATUS_* constants for a list of legal values. + *

Type: INTEGER

+ *

Owner can Read

+ */ + public static final String COLUMN_STATUS = "status"; + + /** + * The name of the column containing the date at which some interesting + * status changed in the download. Stored as a System.currentTimeMillis() + * value. + *

Type: BIGINT

+ *

Owner can Read

+ */ + public static final String COLUMN_LAST_MODIFICATION = "lastmod"; + + /** + * The name of the column containing the package name of the application + * that initiating the download. The download manager will send + * notifications to a component in this package when the download completes. + *

Type: TEXT

+ *

Owner can Init/Read

+ */ + public static final String COLUMN_NOTIFICATION_PACKAGE = "notificationpackage"; + + /** + * The name of the column containing the component name of the class that + * will receive notifications associated with the download. The + * package/class combination is passed to + * Intent.setClassName(String,String). + *

Type: TEXT

+ *

Owner can Init/Read

+ */ + public static final String COLUMN_NOTIFICATION_CLASS = "notificationclass"; + + /** + * If extras are specified when requesting a download they will be provided in the intent + * that is sent to the specified class and package when a download has finished. + *

Type: TEXT

+ *

Owner can Init

+ */ + public static final String COLUMN_NOTIFICATION_EXTRAS = "notificationextras"; + + /** + * The name of the column contain the values of the cookie to be used for + * the download. This is used directly as the value for the Cookie: HTTP + * header that gets sent with the request. + *

Type: TEXT

+ *

Owner can Init

+ */ + public static final String COLUMN_COOKIE_DATA = "cookiedata"; + + /** + * The name of the column containing the user agent that the initiating + * application wants the download manager to use for this download. + *

Type: TEXT

+ *

Owner can Init

+ */ + public static final String COLUMN_USER_AGENT = "useragent"; + + /** + * The name of the column containing the referer (sic) that the initiating + * application wants the download manager to use for this download. + *

Type: TEXT

+ *

Owner can Init

+ */ + public static final String COLUMN_REFERER = "referer"; + + /** + * The name of the column containing the total size of the file being + * downloaded. + *

Type: INTEGER

+ *

Owner can Read

+ */ + public static final String COLUMN_TOTAL_BYTES = "total_bytes"; + + /** + * The name of the column containing the size of the part of the file that + * has been downloaded so far. + *

Type: INTEGER

+ *

Owner can Read

+ */ + public static final String COLUMN_CURRENT_BYTES = "current_bytes"; + + /** + * The name of the column where the initiating application can provide the + * UID of another application that is allowed to access this download. If + * multiple applications share the same UID, all those applications will be + * allowed to access this download. This column can be updated after the + * download is initiated. This requires the permission + * android.permission.ACCESS_DOWNLOAD_MANAGER_ADVANCED. + *

Type: INTEGER

+ *

Owner can Init

+ */ + public static final String COLUMN_OTHER_UID = "otheruid"; + + /** + * The name of the column where the initiating application can provided the + * title of this download. The title will be displayed ito the user in the + * list of downloads. + *

Type: TEXT

+ *

Owner can Init/Read/Write

+ */ + public static final String COLUMN_TITLE = "title"; + + /** + * The name of the column where the initiating application can provide the + * description of this download. The description will be displayed to the + * user in the list of downloads. + *

Type: TEXT

+ *

Owner can Init/Read/Write

+ */ + public static final String COLUMN_DESCRIPTION = "description"; + + /** + * The name of the column indicating whether the download was requesting through the public + * API. This controls some differences in behavior. + *

Type: BOOLEAN

+ *

Owner can Init/Read

+ */ + public static final String COLUMN_IS_PUBLIC_API = "is_public_api"; + + /** + * The name of the column holding a bitmask of allowed network types. This is only used for + * public API downloads. + *

Type: INTEGER

+ *

Owner can Init/Read

+ */ + public static final String COLUMN_ALLOWED_NETWORK_TYPES = "allowed_network_types"; + + /** + * The name of the column indicating whether roaming connections can be used. This is only + * used for public API downloads. + *

Type: BOOLEAN

+ *

Owner can Init/Read

+ */ + public static final String COLUMN_ALLOW_ROAMING = "allow_roaming"; + + /** + * The name of the column indicating whether metered connections can be used. This is only + * used for public API downloads. + *

Type: BOOLEAN

+ *

Owner can Init/Read

+ */ + public static final String COLUMN_ALLOW_METERED = "allow_metered"; + + /** + * Whether or not this download should be displayed in the system's Downloads UI. Defaults + * to true. + *

Type: INTEGER

+ *

Owner can Init/Read

+ */ + public static final String COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI = "is_visible_in_downloads_ui"; + + /** + * If true, the user has confirmed that this download can proceed over the mobile network + * even though it exceeds the recommended maximum size. + *

Type: BOOLEAN

+ */ + public static final String COLUMN_BYPASS_RECOMMENDED_SIZE_LIMIT = + "bypass_recommended_size_limit"; + + /** + * Set to true if this download is deleted. It is completely removed from the database + * when MediaProvider database also deletes the metadata asociated with this downloaded + * file. + *

Type: BOOLEAN

+ *

Owner can Read

+ */ + public static final String COLUMN_DELETED = "deleted"; + + /** + * The URI to the corresponding entry in MediaProvider for this downloaded entry. It is + * used to delete the entries from MediaProvider database when it is deleted from the + * downloaded list. + *

Type: TEXT

+ *

Owner can Read

+ */ + public static final String COLUMN_MEDIAPROVIDER_URI = "mediaprovider_uri"; + + /** + * The column that is used to remember whether the media scanner was invoked. + * It can take the values: null or 0(not scanned), 1(scanned), 2 (not scannable). + *

Type: TEXT

+ */ + public static final String COLUMN_MEDIA_SCANNED = "scanned"; + + /** + * The column with errorMsg for a failed downloaded. + * Used only for debugging purposes. + *

Type: TEXT

+ */ + public static final String COLUMN_ERROR_MSG = "errorMsg"; + + /** + * This column stores the source of the last update to this row. + * This column is only for internal use. + * Valid values are indicated by LAST_UPDATESRC_* constants. + *

Type: INT

+ */ + public static final String COLUMN_LAST_UPDATESRC = "lastUpdateSrc"; + + /** The column that is used to count retries */ + public static final String COLUMN_FAILED_CONNECTIONS = "numfailed"; + + /** + * default value for {@link #COLUMN_LAST_UPDATESRC}. + * This value is used when this column's value is not relevant. + */ + public static final int LAST_UPDATESRC_NOT_RELEVANT = 0; + + /** + * One of the values taken by {@link #COLUMN_LAST_UPDATESRC}. + * This value is used when the update is NOT to be relayed to the DownloadService + * (and thus spare DownloadService from scanning the database when this change occurs) + */ + public static final int LAST_UPDATESRC_DONT_NOTIFY_DOWNLOADSVC = 1; + + /* + * Lists the destinations that an application can specify for a download. + */ + + /** + * This download will be saved to the external storage. This is the + * default behavior, and should be used for any file that the user + * can freely access, copy, delete. Even with that destination, + * unencrypted DRM files are saved in secure internal storage. + * Downloads to the external destination only write files for which + * there is a registered handler. The resulting files are accessible + * by filename to all applications. + */ + public static final int DESTINATION_EXTERNAL = 0; + + /** + * This download will be saved to the download manager's private + * partition. This is the behavior used by applications that want to + * download private files that are used and deleted soon after they + * get downloaded. All file types are allowed, and only the initiating + * application can access the file (indirectly through a content + * provider). This requires the + * android.permission.ACCESS_DOWNLOAD_MANAGER_ADVANCED permission. + */ + public static final int DESTINATION_CACHE_PARTITION = 1; + + /** + * This download will be saved to the download manager's private + * partition and will be purged as necessary to make space. This is + * for private files (similar to CACHE_PARTITION) that aren't deleted + * immediately after they are used, and are kept around by the download + * manager as long as space is available. + */ + public static final int DESTINATION_CACHE_PARTITION_PURGEABLE = 2; + + /** + * This download will be saved to the download manager's private + * partition, as with DESTINATION_CACHE_PARTITION, but the download + * will not proceed if the user is on a roaming data connection. + */ + public static final int DESTINATION_CACHE_PARTITION_NOROAMING = 3; + + /** + * This download will be saved to the location given by the file URI in + * {@link #COLUMN_FILE_NAME_HINT}. + */ + public static final int DESTINATION_FILE_URI = 4; + + /** + * This download will be saved to the system cache ("/cache") + * partition. This option is only used by system apps and so it requires + * android.permission.ACCESS_CACHE_FILESYSTEM permission. + */ + public static final int DESTINATION_SYSTEMCACHE_PARTITION = 5; + + /** + * This download was completed by the caller (i.e., NOT downloadmanager) + * and caller wants to have this download displayed in Downloads App. + */ + public static final int DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD = 6; + + /** + * This download is allowed to run. + */ + public static final int CONTROL_RUN = 0; + + /** + * This download must pause at the first opportunity. + */ + public static final int CONTROL_PAUSED = 1; + + /* + * Lists the states that the download manager can set on a download + * to notify applications of the download progress. + * The codes follow the HTTP families:
+ * 1xx: informational
+ * 2xx: success
+ * 3xx: redirects (not used by the download manager)
+ * 4xx: client errors
+ * 5xx: server errors + */ + + /** + * Returns whether the status is informational (i.e. 1xx). + */ + public static boolean isStatusInformational(int status) { + return (status >= 100 && status < 200); + } + + /** + * Returns whether the status is a success (i.e. 2xx). + */ + public static boolean isStatusSuccess(int status) { + return (status >= 200 && status < 300); + } + + /** + * Returns whether the status is an error (i.e. 4xx or 5xx). + */ + public static boolean isStatusError(int status) { + return (status >= 400 && status < 600); + } + + /** + * Returns whether the status is a client error (i.e. 4xx). + */ + public static boolean isStatusClientError(int status) { + return (status >= 400 && status < 500); + } + + /** + * Returns whether the status is a server error (i.e. 5xx). + */ + public static boolean isStatusServerError(int status) { + return (status >= 500 && status < 600); + } + + /** + * this method determines if a notification should be displayed for a + * given {@link #COLUMN_VISIBILITY} value + * @param visibility the value of {@link #COLUMN_VISIBILITY}. + * @return true if the notification should be displayed. false otherwise. + */ + public static boolean isNotificationToBeDisplayed(int visibility) { + return visibility == DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED || + visibility == DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION; + } + + /** + * Returns whether the download has completed (either with success or + * error). + */ + public static boolean isStatusCompleted(int status) { + return (status >= 200 && status < 300) || (status >= 400 && status < 600); + } + + /** + * This download hasn't stated yet + */ + public static final int STATUS_PENDING = 190; + + /** + * This download has started + */ + public static final int STATUS_RUNNING = 192; + + /** + * This download has been paused by the owning app. + */ + public static final int STATUS_PAUSED_BY_APP = 193; + + /** + * This download encountered some network error and is waiting before retrying the request. + */ + public static final int STATUS_WAITING_TO_RETRY = 194; + + /** + * This download is waiting for network connectivity to proceed. + */ + public static final int STATUS_WAITING_FOR_NETWORK = 195; + + /** + * This download exceeded a size limit for mobile networks and is waiting for a Wi-Fi + * connection to proceed. + */ + public static final int STATUS_QUEUED_FOR_WIFI = 196; + + /** + * This download couldn't be completed due to insufficient storage + * space. Typically, this is because the SD card is full. + */ + public static final int STATUS_INSUFFICIENT_SPACE_ERROR = 198; + + /** + * This download couldn't be completed because no external storage + * device was found. Typically, this is because the SD card is not + * mounted. + */ + public static final int STATUS_DEVICE_NOT_FOUND_ERROR = 199; + + /** + * This download has successfully completed. + * Warning: there might be other status values that indicate success + * in the future. + * Use isSucccess() to capture the entire category. + */ + public static final int STATUS_SUCCESS = 200; + + /** + * This request couldn't be parsed. This is also used when processing + * requests with unknown/unsupported URI schemes. + */ + public static final int STATUS_BAD_REQUEST = 400; + + /** + * This download can't be performed because the content type cannot be + * handled. + */ + public static final int STATUS_NOT_ACCEPTABLE = 406; + + /** + * This download cannot be performed because the length cannot be + * determined accurately. This is the code for the HTTP error "Length + * Required", which is typically used when making requests that require + * a content length but don't have one, and it is also used in the + * client when a response is received whose length cannot be determined + * accurately (therefore making it impossible to know when a download + * completes). + */ + public static final int STATUS_LENGTH_REQUIRED = 411; + + /** + * This download was interrupted and cannot be resumed. + * This is the code for the HTTP error "Precondition Failed", and it is + * also used in situations where the client doesn't have an ETag at all. + */ + public static final int STATUS_PRECONDITION_FAILED = 412; + + /** + * The lowest-valued error status that is not an actual HTTP status code. + */ + public static final int MIN_ARTIFICIAL_ERROR_STATUS = 488; + + /** + * The requested destination file already exists. + */ + public static final int STATUS_FILE_ALREADY_EXISTS_ERROR = 488; + + /** + * Some possibly transient error occurred, but we can't resume the download. + */ + public static final int STATUS_CANNOT_RESUME = 489; + + /** + * This download was canceled + */ + public static final int STATUS_CANCELED = 490; + + /** + * This download has completed with an error. + * Warning: there will be other status values that indicate errors in + * the future. Use isStatusError() to capture the entire category. + */ + public static final int STATUS_UNKNOWN_ERROR = 491; + + /** + * This download couldn't be completed because of a storage issue. + * Typically, that's because the filesystem is missing or full. + * Use the more specific {@link #STATUS_INSUFFICIENT_SPACE_ERROR} + * and {@link #STATUS_DEVICE_NOT_FOUND_ERROR} when appropriate. + */ + public static final int STATUS_FILE_ERROR = 492; + + /** + * This download couldn't be completed because of an HTTP + * redirect response that the download manager couldn't + * handle. + */ + public static final int STATUS_UNHANDLED_REDIRECT = 493; + + /** + * This download couldn't be completed because of an + * unspecified unhandled HTTP code. + */ + public static final int STATUS_UNHANDLED_HTTP_CODE = 494; + + /** + * This download couldn't be completed because of an + * error receiving or processing data at the HTTP level. + */ + public static final int STATUS_HTTP_DATA_ERROR = 495; + + /** + * This download couldn't be completed because of an + * HttpException while setting up the request. + */ + public static final int STATUS_HTTP_EXCEPTION = 496; + + /** + * This download couldn't be completed because there were + * too many redirects. + */ + public static final int STATUS_TOO_MANY_REDIRECTS = 497; + + /** + * This download has failed because requesting application has been + * blocked by {@link NetworkPolicyManager}. + * + * @hide + * @deprecated since behavior now uses + * {@link #STATUS_WAITING_FOR_NETWORK} + */ + @Deprecated + public static final int STATUS_BLOCKED = 498; + + /** {@hide} */ + public static String statusToString(int status) { + switch (status) { + case STATUS_PENDING: return "PENDING"; + case STATUS_RUNNING: return "RUNNING"; + case STATUS_PAUSED_BY_APP: return "PAUSED_BY_APP"; + case STATUS_WAITING_TO_RETRY: return "WAITING_TO_RETRY"; + case STATUS_WAITING_FOR_NETWORK: return "WAITING_FOR_NETWORK"; + case STATUS_QUEUED_FOR_WIFI: return "QUEUED_FOR_WIFI"; + case STATUS_INSUFFICIENT_SPACE_ERROR: return "INSUFFICIENT_SPACE_ERROR"; + case STATUS_DEVICE_NOT_FOUND_ERROR: return "DEVICE_NOT_FOUND_ERROR"; + case STATUS_SUCCESS: return "SUCCESS"; + case STATUS_BAD_REQUEST: return "BAD_REQUEST"; + case STATUS_NOT_ACCEPTABLE: return "NOT_ACCEPTABLE"; + case STATUS_LENGTH_REQUIRED: return "LENGTH_REQUIRED"; + case STATUS_PRECONDITION_FAILED: return "PRECONDITION_FAILED"; + case STATUS_FILE_ALREADY_EXISTS_ERROR: return "FILE_ALREADY_EXISTS_ERROR"; + case STATUS_CANNOT_RESUME: return "CANNOT_RESUME"; + case STATUS_CANCELED: return "CANCELED"; + case STATUS_UNKNOWN_ERROR: return "UNKNOWN_ERROR"; + case STATUS_FILE_ERROR: return "FILE_ERROR"; + case STATUS_UNHANDLED_REDIRECT: return "UNHANDLED_REDIRECT"; + case STATUS_UNHANDLED_HTTP_CODE: return "UNHANDLED_HTTP_CODE"; + case STATUS_HTTP_DATA_ERROR: return "HTTP_DATA_ERROR"; + case STATUS_HTTP_EXCEPTION: return "HTTP_EXCEPTION"; + case STATUS_TOO_MANY_REDIRECTS: return "TOO_MANY_REDIRECTS"; + case STATUS_BLOCKED: return "BLOCKED"; + default: return Integer.toString(status); + } + } + + /** + * This download is visible but only shows in the notifications + * while it's in progress. + */ + public static final int VISIBILITY_VISIBLE = DownloadManager.Request.VISIBILITY_VISIBLE; + + /** + * This download is visible and shows in the notifications while + * in progress and after completion. + */ + public static final int VISIBILITY_VISIBLE_NOTIFY_COMPLETED = + DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED; + + /** + * This download doesn't show in the UI or in the notifications. + */ + public static final int VISIBILITY_HIDDEN = DownloadManager.Request.VISIBILITY_HIDDEN; + + /** + * Constants related to HTTP request headers associated with each download. + */ + public static class RequestHeaders { + public static final String HEADERS_DB_TABLE = "request_headers"; + public static final String COLUMN_DOWNLOAD_ID = "download_id"; + public static final String COLUMN_HEADER = "header"; + public static final String COLUMN_VALUE = "value"; + + /** + * Path segment to add to a download URI to retrieve request headers + */ + public static final String URI_SEGMENT = "headers"; + + /** + * Prefix for ContentValues keys that contain HTTP header lines, to be passed to + * DownloadProvider.insert(). + */ + public static final String INSERT_KEY_PREFIX = "http_header_"; + } + } + + /** + * Query where clause for general querying. + */ + private static final String QUERY_WHERE_CLAUSE = Impl.COLUMN_NOTIFICATION_PACKAGE + "=? AND " + + Impl.COLUMN_NOTIFICATION_CLASS + "=?"; + + /** + * Delete all the downloads for a package/class pair. + */ + public static final void removeAllDownloadsByPackage( + Context context, String notificationPackage, String notificationClass) { + context.getContentResolver().delete(Impl.CONTENT_URI, QUERY_WHERE_CLAUSE, + new String[] { notificationPackage, notificationClass }); + } +} diff --git a/src/com/android/messaging/mmslib/InvalidHeaderValueException.java b/src/com/android/messaging/mmslib/InvalidHeaderValueException.java new file mode 100644 index 0000000..c141591 --- /dev/null +++ b/src/com/android/messaging/mmslib/InvalidHeaderValueException.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib; + +/** + * Thrown when an invalid header value was set. + */ +public class InvalidHeaderValueException extends MmsException { + private static final long serialVersionUID = -2053384496042052262L; + + /** + * Constructs an InvalidHeaderValueException with no detailed message. + */ + public InvalidHeaderValueException() { + super(); + } + + /** + * Constructs an InvalidHeaderValueException with the specified detailed message. + * + * @param message the detailed message. + */ + public InvalidHeaderValueException(String message) { + super(message); + } +} diff --git a/src/com/android/messaging/mmslib/MmsException.java b/src/com/android/messaging/mmslib/MmsException.java new file mode 100644 index 0000000..173511d --- /dev/null +++ b/src/com/android/messaging/mmslib/MmsException.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib; + +/** + * A generic exception that is thrown by the Mms client. + */ +public class MmsException extends Exception { + private static final long serialVersionUID = -7323249827281485390L; + + /** + * Creates a new MmsException. + */ + public MmsException() { + super(); + } + + /** + * Creates a new MmsException with the specified detail message. + * + * @param message the detail message. + */ + public MmsException(String message) { + super(message); + } + + /** + * Creates a new MmsException with the specified cause. + * + * @param cause the cause. + */ + public MmsException(Throwable cause) { + super(cause); + } + + /** + * Creates a new MmsException with the specified detail message and cause. + * + * @param message the detail message. + * @param cause the cause. + */ + public MmsException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/com/android/messaging/mmslib/SqliteWrapper.java b/src/com/android/messaging/mmslib/SqliteWrapper.java new file mode 100644 index 0000000..8ef0e6f --- /dev/null +++ b/src/com/android/messaging/mmslib/SqliteWrapper.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2008 Esmertec AG. + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import android.net.Uri; + +import com.android.messaging.util.LogUtil; + +// Wrapper around content resolver methods to catch exceptions +public final class SqliteWrapper { + private static final String TAG = LogUtil.BUGLE_TAG; + + private SqliteWrapper() { + // Forbidden being instantiated. + } + + public static Cursor query(Context context, ContentResolver resolver, Uri uri, + String[] projection, String selection, String[] selectionArgs, String sortOrder) { + try { + return resolver.query(uri, projection, selection, selectionArgs, sortOrder); + } catch (SQLiteException e) { + LogUtil.e(TAG, "SqliteWrapper: catch an exception when query", e); + return null; + } catch (IllegalArgumentException e) { + LogUtil.e(TAG, "SqliteWrapper: catch an exception when query", e); + return null; + } + } + + public static int update(Context context, ContentResolver resolver, Uri uri, + ContentValues values, String where, String[] selectionArgs) { + try { + return resolver.update(uri, values, where, selectionArgs); + } catch (SQLiteException e) { + LogUtil.e(TAG, "SqliteWrapper: catch an exception when update", e); + return -1; + } catch (IllegalArgumentException e) { + LogUtil.e(TAG, "SqliteWrapper: catch an exception when update", e); + return -1; + } + } + + public static int delete(Context context, ContentResolver resolver, Uri uri, + String where, String[] selectionArgs) { + try { + return resolver.delete(uri, where, selectionArgs); + } catch (SQLiteException e) { + LogUtil.e(TAG, "SqliteWrapper: catch an exception when delete", e); + return -1; + } catch (IllegalArgumentException e) { + LogUtil.e(TAG, "SqliteWrapper: catch an exception when delete", e); + return -1; + } + } + + public static Uri insert(Context context, ContentResolver resolver, + Uri uri, ContentValues values) { + try { + return resolver.insert(uri, values); + } catch (SQLiteException e) { + LogUtil.e(TAG, "SqliteWrapper: catch an exception when insert", e); + return null; + } catch (IllegalArgumentException e) { + LogUtil.e(TAG, "SqliteWrapper: catch an exception when insert", e); + return null; + } + } +} diff --git a/src/com/android/messaging/mmslib/pdu/AcknowledgeInd.java b/src/com/android/messaging/mmslib/pdu/AcknowledgeInd.java new file mode 100644 index 0000000..05494c0 --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/AcknowledgeInd.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import com.android.messaging.mmslib.InvalidHeaderValueException; + +/** + * M-Acknowledge.ind PDU. + */ +public class AcknowledgeInd extends GenericPdu { + /** + * Constructor, used when composing a M-Acknowledge.ind pdu. + * + * @param mmsVersion current viersion of mms + * @param transactionId the transaction-id value + * @throws InvalidHeaderValueException if parameters are invalid. + * @throws NullPointerException if transactionId is null. + */ + public AcknowledgeInd(int mmsVersion, byte[] transactionId) + throws InvalidHeaderValueException { + super(); + + setMessageType(PduHeaders.MESSAGE_TYPE_ACKNOWLEDGE_IND); + setMmsVersion(mmsVersion); + setTransactionId(transactionId); + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + AcknowledgeInd(PduHeaders headers) { + super(headers); + } + + /** + * Get X-Mms-Report-Allowed field value. + * + * @return the X-Mms-Report-Allowed value + */ + public int getReportAllowed() { + return mPduHeaders.getOctet(PduHeaders.REPORT_ALLOWED); + } + + /** + * Set X-Mms-Report-Allowed field value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setReportAllowed(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.REPORT_ALLOWED); + } + + /** + * Get X-Mms-Transaction-Id field value. + * + * @return the X-Mms-Report-Allowed value + */ + public byte[] getTransactionId() { + return mPduHeaders.getTextString(PduHeaders.TRANSACTION_ID); + } + + /** + * Set X-Mms-Transaction-Id field value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setTransactionId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.TRANSACTION_ID); + } +} diff --git a/src/com/android/messaging/mmslib/pdu/Base64.java b/src/com/android/messaging/mmslib/pdu/Base64.java new file mode 100644 index 0000000..2f27117 --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/Base64.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +public class Base64 { + /** + * Used to get the number of Quadruples. + */ + static final int FOURBYTE = 4; + + /** + * Byte used to pad output. + */ + static final byte PAD = (byte) '='; + + /** + * The base length. + */ + static final int BASELENGTH = 255; + + // Create arrays to hold the base64 characters + private static byte[] base64Alphabet = new byte[BASELENGTH]; + + // Populating the character arrays + static { + for (int i = 0; i < BASELENGTH; i++) { + base64Alphabet[i] = (byte) -1; + } + for (int i = 'Z'; i >= 'A'; i--) { + base64Alphabet[i] = (byte) (i - 'A'); + } + for (int i = 'z'; i >= 'a'; i--) { + base64Alphabet[i] = (byte) (i - 'a' + 26); + } + for (int i = '9'; i >= '0'; i--) { + base64Alphabet[i] = (byte) (i - '0' + 52); + } + + base64Alphabet['+'] = 62; + base64Alphabet['/'] = 63; + } + + /** + * Decodes Base64 data into octects + * + * @param base64Data Byte array containing Base64 data + * @return Array containing decoded data. + */ + public static byte[] decodeBase64(byte[] base64Data) { + // RFC 2045 requires that we discard ALL non-Base64 characters + base64Data = discardNonBase64(base64Data); + + // handle the edge case, so we don't have to worry about it later + if (base64Data.length == 0) { + return new byte[0]; + } + + int numberQuadruple = base64Data.length / FOURBYTE; + byte decodedData[] = null; + byte b1 = 0, b2 = 0, b3 = 0, b4 = 0, marker0 = 0, marker1 = 0; + + // Throw away anything not in base64Data + + int encodedIndex = 0; + int dataIndex = 0; + { + // this sizes the output array properly - rlw + int lastData = base64Data.length; + // ignore the '=' padding + while (base64Data[lastData - 1] == PAD) { + if (--lastData == 0) { + return new byte[0]; + } + } + decodedData = new byte[lastData - numberQuadruple]; + } + + for (int i = 0; i < numberQuadruple; i++) { + dataIndex = i * 4; + marker0 = base64Data[dataIndex + 2]; + marker1 = base64Data[dataIndex + 3]; + + b1 = base64Alphabet[base64Data[dataIndex]]; + b2 = base64Alphabet[base64Data[dataIndex + 1]]; + + if (marker0 != PAD && marker1 != PAD) { + //No PAD e.g 3cQl + b3 = base64Alphabet[marker0]; + b4 = base64Alphabet[marker1]; + + decodedData[encodedIndex] = (byte) (b1 << 2 | b2 >> 4); + decodedData[encodedIndex + 1] = + (byte) (((b2 & 0xf) << 4) | ((b3 >> 2) & 0xf)); + decodedData[encodedIndex + 2] = (byte) (b3 << 6 | b4); + } else if (marker0 == PAD) { + //Two PAD e.g. 3c[Pad][Pad] + decodedData[encodedIndex] = (byte) (b1 << 2 | b2 >> 4); + } else if (marker1 == PAD) { + //One PAD e.g. 3cQ[Pad] + b3 = base64Alphabet[marker0]; + + decodedData[encodedIndex] = (byte) (b1 << 2 | b2 >> 4); + decodedData[encodedIndex + 1] = + (byte) (((b2 & 0xf) << 4) | ((b3 >> 2) & 0xf)); + } + encodedIndex += 3; + } + return decodedData; + } + + /** + * Check octect wheter it is a base64 encoding. + * + * @param octect to be checked byte + * @return ture if it is base64 encoding, false otherwise. + */ + private static boolean isBase64(byte octect) { + if (octect == PAD) { + return true; + } else if (base64Alphabet[octect] == -1) { + return false; + } else { + return true; + } + } + + /** + * Discards any characters outside of the base64 alphabet, per + * the requirements on page 25 of RFC 2045 - "Any characters + * outside of the base64 alphabet are to be ignored in base64 + * encoded data." + * + * @param data The base-64 encoded data to groom + * @return The data, less non-base64 characters (see RFC 2045). + */ + static byte[] discardNonBase64(byte[] data) { + byte groomedData[] = new byte[data.length]; + int bytesCopied = 0; + + for (int i = 0; i < data.length; i++) { + if (isBase64(data[i])) { + groomedData[bytesCopied++] = data[i]; + } + } + + byte packedData[] = new byte[bytesCopied]; + + System.arraycopy(groomedData, 0, packedData, 0, bytesCopied); + + return packedData; + } +} diff --git a/src/com/android/messaging/mmslib/pdu/CharacterSets.java b/src/com/android/messaging/mmslib/pdu/CharacterSets.java new file mode 100644 index 0000000..0a9099b --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/CharacterSets.java @@ -0,0 +1,452 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import android.support.v4.util.SimpleArrayMap; +import android.util.SparseArray; + +import java.io.UnsupportedEncodingException; + +public class CharacterSets { + /** + * IANA assigned MIB enum numbers. + * + * From wap-230-wsp-20010705-a.pdf + * Any-charset = + * Equivalent to the special RFC2616 charset value "*" + * + * Link to all the charsets: http://www.iana.org/assignments/character-sets/character-sets.xhtml + * Use Charset.availableCharsets() to see if a potential charset is supported in java: + * private void dumpCharsets() { + * logvv("dumpCharsets"); + * SortedMap charsets = Charset.availableCharsets(); + * for (Entry entry : charsets.entrySet()) { + * String key = entry.getKey(); + * Charset value = entry.getValue(); + * logvv("charset key: " + key + " value: " + value); + * } + * } + * + * As of March 21, 2014, here is the list from dumpCharsets: + * Adobe-Standard-Encoding value: java.nio.charset.CharsetICU[Adobe-Standard-Encoding] + * Big5 value: java.nio.charset.CharsetICU[Big5] + * Big5-HKSCS value: java.nio.charset.CharsetICU[Big5-HKSCS] + * BOCU-1 value: java.nio.charset.CharsetICU[BOCU-1] + * CESU-8 value: java.nio.charset.CharsetICU[CESU-8] + * cp1363 value: java.nio.charset.CharsetICU[cp1363] + * cp851 value: java.nio.charset.CharsetICU[cp851] + * cp864 value: java.nio.charset.CharsetICU[cp864] + * EUC-JP value: java.nio.charset.CharsetICU[EUC-JP] + * EUC-KR value: java.nio.charset.CharsetICU[EUC-KR] + * GB18030 value: java.nio.charset.CharsetICU[GB18030] + * GBK value: java.nio.charset.CharsetICU[GBK] + * hp-roman8 value: java.nio.charset.CharsetICU[hp-roman8] + * HZ-GB-2312 value: java.nio.charset.CharsetICU[HZ-GB-2312] + * IBM-Thai value: java.nio.charset.CharsetICU[IBM-Thai] + * IBM00858 value: java.nio.charset.CharsetICU[IBM00858] + * IBM01140 value: java.nio.charset.CharsetICU[IBM01140] + * IBM01141 value: java.nio.charset.CharsetICU[IBM01141] + * IBM01142 value: java.nio.charset.CharsetICU[IBM01142] + * IBM01143 value: java.nio.charset.CharsetICU[IBM01143] + * IBM01144 value: java.nio.charset.CharsetICU[IBM01144] + * IBM01145 value: java.nio.charset.CharsetICU[IBM01145] + * IBM01146 value: java.nio.charset.CharsetICU[IBM01146] + * IBM01147 value: java.nio.charset.CharsetICU[IBM01147] + * IBM01148 value: java.nio.charset.CharsetICU[IBM01148] + * IBM01149 value: java.nio.charset.CharsetICU[IBM01149] + * IBM037 value: java.nio.charset.CharsetICU[IBM037] + * IBM1026 value: java.nio.charset.CharsetICU[IBM1026] + * IBM1047 value: java.nio.charset.CharsetICU[IBM1047] + * IBM273 value: java.nio.charset.CharsetICU[IBM273] + * IBM277 value: java.nio.charset.CharsetICU[IBM277] + * IBM278 value: java.nio.charset.CharsetICU[IBM278] + * IBM280 value: java.nio.charset.CharsetICU[IBM280] + * IBM284 value: java.nio.charset.CharsetICU[IBM284] + * IBM285 value: java.nio.charset.CharsetICU[IBM285] + * IBM290 value: java.nio.charset.CharsetICU[IBM290] + * IBM297 value: java.nio.charset.CharsetICU[IBM297] + * IBM420 value: java.nio.charset.CharsetICU[IBM420] + * IBM424 value: java.nio.charset.CharsetICU[IBM424] + * IBM437 value: java.nio.charset.CharsetICU[IBM437] + * IBM500 value: java.nio.charset.CharsetICU[IBM500] + * IBM775 value: java.nio.charset.CharsetICU[IBM775] + * IBM850 value: java.nio.charset.CharsetICU[IBM850] + * IBM852 value: java.nio.charset.CharsetICU[IBM852] + * IBM855 value: java.nio.charset.CharsetICU[IBM855] + * IBM857 value: java.nio.charset.CharsetICU[IBM857] + * IBM860 value: java.nio.charset.CharsetICU[IBM860] + * IBM861 value: java.nio.charset.CharsetICU[IBM861] + * IBM862 value: java.nio.charset.CharsetICU[IBM862] + * IBM863 value: java.nio.charset.CharsetICU[IBM863] + * IBM865 value: java.nio.charset.CharsetICU[IBM865] + * IBM866 value: java.nio.charset.CharsetICU[IBM866] + * IBM868 value: java.nio.charset.CharsetICU[IBM868] + * IBM869 value: java.nio.charset.CharsetICU[IBM869] + * IBM870 value: java.nio.charset.CharsetICU[IBM870] + * IBM871 value: java.nio.charset.CharsetICU[IBM871] + * IBM918 value: java.nio.charset.CharsetICU[IBM918] + * ISO-2022-CN value: java.nio.charset.CharsetICU[ISO-2022-CN] + * ISO-2022-CN-EXT value: java.nio.charset.CharsetICU[ISO-2022-CN-EXT] + * ISO-2022-JP value: java.nio.charset.CharsetICU[ISO-2022-JP] + * ISO-2022-JP-1 value: java.nio.charset.CharsetICU[ISO-2022-JP-1] + * ISO-2022-JP-2 value: java.nio.charset.CharsetICU[ISO-2022-JP-2] + * ISO-2022-KR value: java.nio.charset.CharsetICU[ISO-2022-KR] + * ISO-8859-1 value: java.nio.charset.CharsetICU[ISO-8859-1] + * ISO-8859-10 value: java.nio.charset.CharsetICU[ISO-8859-10] + * ISO-8859-13 value: java.nio.charset.CharsetICU[ISO-8859-13] + * ISO-8859-14 value: java.nio.charset.CharsetICU[ISO-8859-14] + * ISO-8859-15 value: java.nio.charset.CharsetICU[ISO-8859-15] + * ISO-8859-2 value: java.nio.charset.CharsetICU[ISO-8859-2] + * ISO-8859-3 value: java.nio.charset.CharsetICU[ISO-8859-3] + * ISO-8859-4 value: java.nio.charset.CharsetICU[ISO-8859-4] + * ISO-8859-5 value: java.nio.charset.CharsetICU[ISO-8859-5] + * ISO-8859-6 value: java.nio.charset.CharsetICU[ISO-8859-6] + * ISO-8859-7 value: java.nio.charset.CharsetICU[ISO-8859-7] + * ISO-8859-8 value: java.nio.charset.CharsetICU[ISO-8859-8] + * ISO-8859-9 value: java.nio.charset.CharsetICU[ISO-8859-9] + * KOI8-R value: java.nio.charset.CharsetICU[KOI8-R] + * KOI8-U value: java.nio.charset.CharsetICU[KOI8-U] + * macintosh value: java.nio.charset.CharsetICU[macintosh] + * SCSU value: java.nio.charset.CharsetICU[SCSU] + * Shift_JIS value: java.nio.charset.CharsetICU[Shift_JIS] + * TIS-620 value: java.nio.charset.CharsetICU[TIS-620] + * US-ASCII value: java.nio.charset.CharsetICU[US-ASCII] + * UTF-16 value: java.nio.charset.CharsetICU[UTF-16] + * UTF-16BE value: java.nio.charset.CharsetICU[UTF-16BE] + * UTF-16LE value: java.nio.charset.CharsetICU[UTF-16LE] + * UTF-32 value: java.nio.charset.CharsetICU[UTF-32] + * UTF-32BE value: java.nio.charset.CharsetICU[UTF-32BE] + * UTF-32LE value: java.nio.charset.CharsetICU[UTF-32LE] + * UTF-7 value: java.nio.charset.CharsetICU[UTF-7] + * UTF-8 value: java.nio.charset.CharsetICU[UTF-8] + * windows-1250 value: java.nio.charset.CharsetICU[windows-1250] + * windows-1251 value: java.nio.charset.CharsetICU[windows-1251] + * windows-1252 value: java.nio.charset.CharsetICU[windows-1252] + * windows-1253 value: java.nio.charset.CharsetICU[windows-1253] + * windows-1254 value: java.nio.charset.CharsetICU[windows-1254] + * windows-1255 value: java.nio.charset.CharsetICU[windows-1255] + * windows-1256 value: java.nio.charset.CharsetICU[windows-1256] + * windows-1257 value: java.nio.charset.CharsetICU[windows-1257] + * windows-1258 value: java.nio.charset.CharsetICU[windows-1258] + * x-compound-text value: java.nio.charset.CharsetICU[x-compound-text] + * x-ebcdic-xml-us value: java.nio.charset.CharsetICU[x-ebcdic-xml-us] + * x-gsm-03.38-2000 value: java.nio.charset.CharsetICU[x-gsm-03.38-2000] + * x-ibm-1047-s390 value: java.nio.charset.CharsetICU[x-ibm-1047-s390] + * x-ibm-1125_P100-1997 value: java.nio.charset.CharsetICU[x-ibm-1125_P100-1997] + * x-ibm-1129_P100-1997 value: java.nio.charset.CharsetICU[x-ibm-1129_P100-1997] + * x-ibm-1130_P100-1997 value: java.nio.charset.CharsetICU[x-ibm-1130_P100-1997] + * x-ibm-1131_P100-1997 value: java.nio.charset.CharsetICU[x-ibm-1131_P100-1997] + * x-ibm-1132_P100-1998 value: java.nio.charset.CharsetICU[x-ibm-1132_P100-1998] + * x-ibm-1133_P100-1997 value: java.nio.charset.CharsetICU[x-ibm-1133_P100-1997] + * x-ibm-1137_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-1137_P100-1999] + * x-ibm-1140-s390 value: java.nio.charset.CharsetICU[x-ibm-1140-s390] + * x-ibm-1141-s390 value: java.nio.charset.CharsetICU[x-ibm-1141-s390] + * x-ibm-1142-s390 value: java.nio.charset.CharsetICU[x-ibm-1142-s390] + * x-ibm-1143-s390 value: java.nio.charset.CharsetICU[x-ibm-1143-s390] + * x-ibm-1144-s390 value: java.nio.charset.CharsetICU[x-ibm-1144-s390] + * x-ibm-1145-s390 value: java.nio.charset.CharsetICU[x-ibm-1145-s390] + * x-ibm-1146-s390 value: java.nio.charset.CharsetICU[x-ibm-1146-s390] + * x-ibm-1147-s390 value: java.nio.charset.CharsetICU[x-ibm-1147-s390] + * x-ibm-1148-s390 value: java.nio.charset.CharsetICU[x-ibm-1148-s390] + * x-ibm-1149-s390 value: java.nio.charset.CharsetICU[x-ibm-1149-s390] + * x-ibm-1153-s390 value: java.nio.charset.CharsetICU[x-ibm-1153-s390] + * x-ibm-1154_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-1154_P100-1999] + * x-ibm-1155_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-1155_P100-1999] + * x-ibm-1156_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-1156_P100-1999] + * x-ibm-1157_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-1157_P100-1999] + * x-ibm-1158_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-1158_P100-1999] + * x-ibm-1160_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-1160_P100-1999] + * x-ibm-1162_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-1162_P100-1999] + * x-ibm-1164_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-1164_P100-1999] + * x-ibm-1250_P100-1995 value: java.nio.charset.CharsetICU[x-ibm-1250_P100-1995] + * x-ibm-1251_P100-1995 value: java.nio.charset.CharsetICU[x-ibm-1251_P100-1995] + * x-ibm-1252_P100-2000 value: java.nio.charset.CharsetICU[x-ibm-1252_P100-2000] + * x-ibm-1253_P100-1995 value: java.nio.charset.CharsetICU[x-ibm-1253_P100-1995] + * x-ibm-1254_P100-1995 value: java.nio.charset.CharsetICU[x-ibm-1254_P100-1995] + * x-ibm-1255_P100-1995 value: java.nio.charset.CharsetICU[x-ibm-1255_P100-1995] + * x-ibm-1256_P110-1997 value: java.nio.charset.CharsetICU[x-ibm-1256_P110-1997] + * x-ibm-1257_P100-1995 value: java.nio.charset.CharsetICU[x-ibm-1257_P100-1995] + * x-ibm-1258_P100-1997 value: java.nio.charset.CharsetICU[x-ibm-1258_P100-1997] + * x-ibm-12712-s390 value: java.nio.charset.CharsetICU[x-ibm-12712-s390] + * x-ibm-12712_P100-1998 value: java.nio.charset.CharsetICU[x-ibm-12712_P100-1998] + * x-ibm-1373_P100-2002 value: java.nio.charset.CharsetICU[x-ibm-1373_P100-2002] + * x-ibm-1383_P110-1999 value: java.nio.charset.CharsetICU[x-ibm-1383_P110-1999] + * x-ibm-1386_P100-2001 value: java.nio.charset.CharsetICU[x-ibm-1386_P100-2001] + * x-ibm-16684_P110-2003 value: java.nio.charset.CharsetICU[x-ibm-16684_P110-2003] + * x-ibm-16804-s390 value: java.nio.charset.CharsetICU[x-ibm-16804-s390] + * x-ibm-16804_X110-1999 value: java.nio.charset.CharsetICU[x-ibm-16804_X110-1999] + * x-ibm-25546 value: java.nio.charset.CharsetICU[x-ibm-25546] + * x-ibm-33722_P12A_P12A-2009_U2 value: + * java.nio.charset.CharsetICU[x-ibm-33722_P12A_P12A-2009_U2] + * x-ibm-37-s390 value: java.nio.charset.CharsetICU[x-ibm-37-s390] + * x-ibm-4517_P100-2005 value: java.nio.charset.CharsetICU[x-ibm-4517_P100-2005] + * x-ibm-4899_P100-1998 value: java.nio.charset.CharsetICU[x-ibm-4899_P100-1998] + * x-ibm-4909_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-4909_P100-1999] + * x-ibm-4971_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-4971_P100-1999] + * x-ibm-5123_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-5123_P100-1999] + * x-ibm-5351_P100-1998 value: java.nio.charset.CharsetICU[x-ibm-5351_P100-1998] + * x-ibm-5352_P100-1998 value: java.nio.charset.CharsetICU[x-ibm-5352_P100-1998] + * x-ibm-5353_P100-1998 value: java.nio.charset.CharsetICU[x-ibm-5353_P100-1998] + * x-ibm-5478_P100-1995 value: java.nio.charset.CharsetICU[x-ibm-5478_P100-1995] + * x-ibm-803_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-803_P100-1999] + * x-ibm-813_P100-1995 value: java.nio.charset.CharsetICU[x-ibm-813_P100-1995] + * x-ibm-8482_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-8482_P100-1999] + * x-ibm-901_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-901_P100-1999] + * x-ibm-902_P100-1999 value: java.nio.charset.CharsetICU[x-ibm-902_P100-1999] + * x-ibm-9067_X100-2005 value: java.nio.charset.CharsetICU[x-ibm-9067_X100-2005] + * x-ibm-916_P100-1995 value: java.nio.charset.CharsetICU[x-ibm-916_P100-1995] + * x-IBM1006 value: java.nio.charset.CharsetICU[x-IBM1006] + * x-IBM1025 value: java.nio.charset.CharsetICU[x-IBM1025] + * x-IBM1097 value: java.nio.charset.CharsetICU[x-IBM1097] + * x-IBM1098 value: java.nio.charset.CharsetICU[x-IBM1098] + * x-IBM1112 value: java.nio.charset.CharsetICU[x-IBM1112] + * x-IBM1122 value: java.nio.charset.CharsetICU[x-IBM1122] + * x-IBM1123 value: java.nio.charset.CharsetICU[x-IBM1123] + * x-IBM1124 value: java.nio.charset.CharsetICU[x-IBM1124] + * x-IBM1153 value: java.nio.charset.CharsetICU[x-IBM1153] + * x-IBM1363 value: java.nio.charset.CharsetICU[x-IBM1363] + * x-IBM1364 value: java.nio.charset.CharsetICU[x-IBM1364] + * x-IBM1371 value: java.nio.charset.CharsetICU[x-IBM1371] + * x-IBM1388 value: java.nio.charset.CharsetICU[x-IBM1388] + * x-IBM1390 value: java.nio.charset.CharsetICU[x-IBM1390] + * x-IBM1399 value: java.nio.charset.CharsetICU[x-IBM1399] + * x-IBM33722 value: java.nio.charset.CharsetICU[x-IBM33722] + * x-IBM720 value: java.nio.charset.CharsetICU[x-IBM720] + * x-IBM737 value: java.nio.charset.CharsetICU[x-IBM737] + * x-IBM856 value: java.nio.charset.CharsetICU[x-IBM856] + * x-IBM867 value: java.nio.charset.CharsetICU[x-IBM867] + * x-IBM875 value: java.nio.charset.CharsetICU[x-IBM875] + * x-IBM922 value: java.nio.charset.CharsetICU[x-IBM922] + * x-IBM930 value: java.nio.charset.CharsetICU[x-IBM930] + * x-IBM933 value: java.nio.charset.CharsetICU[x-IBM933] + * x-IBM935 value: java.nio.charset.CharsetICU[x-IBM935] + * x-IBM937 value: java.nio.charset.CharsetICU[x-IBM937] + * x-IBM939 value: java.nio.charset.CharsetICU[x-IBM939] + * x-IBM942 value: java.nio.charset.CharsetICU[x-IBM942] + * x-IBM943 value: java.nio.charset.CharsetICU[x-IBM943] + * x-IBM949 value: java.nio.charset.CharsetICU[x-IBM949] + * x-IBM949C value: java.nio.charset.CharsetICU[x-IBM949C] + * x-IBM950 value: java.nio.charset.CharsetICU[x-IBM950] + * x-IBM954 value: java.nio.charset.CharsetICU[x-IBM954] + * x-IBM964 value: java.nio.charset.CharsetICU[x-IBM964] + * x-IBM970 value: java.nio.charset.CharsetICU[x-IBM970] + * x-IBM971 value: java.nio.charset.CharsetICU[x-IBM971] + * x-IMAP-mailbox-name value: java.nio.charset.CharsetICU[x-IMAP-mailbox-name] + * x-iscii-be value: java.nio.charset.CharsetICU[x-iscii-be] + * x-iscii-gu value: java.nio.charset.CharsetICU[x-iscii-gu] + * x-iscii-ka value: java.nio.charset.CharsetICU[x-iscii-ka] + * x-iscii-ma value: java.nio.charset.CharsetICU[x-iscii-ma] + * x-iscii-or value: java.nio.charset.CharsetICU[x-iscii-or] + * x-iscii-pa value: java.nio.charset.CharsetICU[x-iscii-pa] + * x-iscii-ta value: java.nio.charset.CharsetICU[x-iscii-ta] + * x-iscii-te value: java.nio.charset.CharsetICU[x-iscii-te] + * x-ISCII91 value: java.nio.charset.CharsetICU[x-ISCII91] + * x-ISO-2022-CN-CNS value: java.nio.charset.CharsetICU[x-ISO-2022-CN-CNS] + * x-iso-8859-11 value: java.nio.charset.CharsetICU[x-iso-8859-11] + * x-JavaUnicode value: java.nio.charset.CharsetICU[x-JavaUnicode] + * x-JavaUnicode2 value: java.nio.charset.CharsetICU[x-JavaUnicode2] + * x-JIS7 value: java.nio.charset.CharsetICU[x-JIS7] + * x-JIS8 value: java.nio.charset.CharsetICU[x-JIS8] + * x-LMBCS-1 value: java.nio.charset.CharsetICU[x-LMBCS-1] + * x-mac-centraleurroman value: java.nio.charset.CharsetICU[x-mac-centraleurroman] + * x-mac-cyrillic value: java.nio.charset.CharsetICU[x-mac-cyrillic] + * x-mac-greek value: java.nio.charset.CharsetICU[x-mac-greek] + * x-mac-turkish value: java.nio.charset.CharsetICU[x-mac-turkish] + * x-MS950-HKSCS value: java.nio.charset.CharsetICU[x-MS950-HKSCS] + * x-UnicodeBig value: java.nio.charset.CharsetICU[x-UnicodeBig] + * x-UTF-16LE-BOM value: java.nio.charset.CharsetICU[x-UTF-16LE-BOM] + * x-UTF16_OppositeEndian value: java.nio.charset.CharsetICU[x-UTF16_OppositeEndian] + * x-UTF16_PlatformEndian value: java.nio.charset.CharsetICU[x-UTF16_PlatformEndian] + * x-UTF32_OppositeEndian value: java.nio.charset.CharsetICU[x-UTF32_OppositeEndian] + * x-UTF32_PlatformEndian value: java.nio.charset.CharsetICU[x-UTF32_PlatformEndian] + * + */ + public static final int ANY_CHARSET = 0x00; + public static final int US_ASCII = 0x03; + public static final int ISO_8859_1 = 0x04; + public static final int ISO_8859_2 = 0x05; + public static final int ISO_8859_3 = 0x06; + public static final int ISO_8859_4 = 0x07; + public static final int ISO_8859_5 = 0x08; + public static final int ISO_8859_6 = 0x09; + public static final int ISO_8859_7 = 0x0A; + public static final int ISO_8859_8 = 0x0B; + public static final int ISO_8859_9 = 0x0C; + public static final int SHIFT_JIS = 0x11; + public static final int EUC_JP = 0x12; + public static final int EUC_KR = 0x26; + public static final int ISO_2022_JP = 0x27; + public static final int ISO_2022_JP_2 = 0x28; + public static final int UTF_8 = 0x6A; + public static final int GBK = 0x71; + public static final int GB18030 = 0x72; + public static final int GB2312 = 0x07E9; + public static final int BIG5 = 0x07EA; + public static final int UCS2 = 0x03E8; + public static final int UTF_16 = 0x03F7; + public static final int HZ_GB_2312 = 0x0825; + + /** + * If the encoding of given data is unsupported, use UTF_8 to decode it. + */ + public static final int DEFAULT_CHARSET = UTF_8; + + /** + * Array of MIB enum numbers. + */ + private static final int[] MIBENUM_NUMBERS = { + ANY_CHARSET, + US_ASCII, + ISO_8859_1, + ISO_8859_2, + ISO_8859_3, + ISO_8859_4, + ISO_8859_5, + ISO_8859_6, + ISO_8859_7, + ISO_8859_8, + ISO_8859_9, + SHIFT_JIS, + EUC_JP, + EUC_KR, + ISO_2022_JP, + ISO_2022_JP_2, + UTF_8, + GBK, + GB18030, + GB2312, + BIG5, + UCS2, + UTF_16, + HZ_GB_2312, + }; + + /** + * The Well-known-charset Mime name. + */ + public static final String MIMENAME_ANY_CHARSET = "*"; + public static final String MIMENAME_US_ASCII = "us-ascii"; + public static final String MIMENAME_ISO_8859_1 = "iso-8859-1"; + public static final String MIMENAME_ISO_8859_2 = "iso-8859-2"; + public static final String MIMENAME_ISO_8859_3 = "iso-8859-3"; + public static final String MIMENAME_ISO_8859_4 = "iso-8859-4"; + public static final String MIMENAME_ISO_8859_5 = "iso-8859-5"; + public static final String MIMENAME_ISO_8859_6 = "iso-8859-6"; + public static final String MIMENAME_ISO_8859_7 = "iso-8859-7"; + public static final String MIMENAME_ISO_8859_8 = "iso-8859-8"; + public static final String MIMENAME_ISO_8859_9 = "iso-8859-9"; + public static final String MIMENAME_SHIFT_JIS = "shift_JIS"; + public static final String MIMENAME_EUC_JP = "euc-jp"; + public static final String MIMENAME_EUC_KR = "euc-kr"; + public static final String MIMENAME_ISO_2022_JP = "iso-2022-jp"; + public static final String MIMENAME_ISO_2022_JP_2 = "iso-2022-jp-2"; + public static final String MIMENAME_UTF_8 = "utf-8"; + public static final String MIMENAME_GBK = "gbk"; + public static final String MIMENAME_GB18030 = "gb18030"; + public static final String MIMENAME_GB2312 = "gb2312"; + public static final String MIMENAME_BIG5 = "big5"; + public static final String MIMENAME_UCS2 = "iso-10646-ucs-2"; + public static final String MIMENAME_UTF_16 = "utf-16"; + public static final String MIMENAME_HZ_GB_2312 = "hz-gb-2312"; + + public static final String DEFAULT_CHARSET_NAME = MIMENAME_UTF_8; + + /** + * Array of the names of character sets. + */ + private static final String[] MIME_NAMES = { + MIMENAME_ANY_CHARSET, + MIMENAME_US_ASCII, + MIMENAME_ISO_8859_1, + MIMENAME_ISO_8859_2, + MIMENAME_ISO_8859_3, + MIMENAME_ISO_8859_4, + MIMENAME_ISO_8859_5, + MIMENAME_ISO_8859_6, + MIMENAME_ISO_8859_7, + MIMENAME_ISO_8859_8, + MIMENAME_ISO_8859_9, + MIMENAME_SHIFT_JIS, + MIMENAME_EUC_JP, + MIMENAME_EUC_KR, + MIMENAME_ISO_2022_JP, + MIMENAME_ISO_2022_JP_2, + MIMENAME_UTF_8, + MIMENAME_GBK, + MIMENAME_GB18030, + MIMENAME_GB2312, + MIMENAME_BIG5, + MIMENAME_UCS2, + MIMENAME_UTF_16, + MIMENAME_HZ_GB_2312, + }; + + private static final SparseArray MIBENUM_TO_NAME_MAP; + + private static final SimpleArrayMap NAME_TO_MIBENUM_MAP; + + static { + // Create the HashMaps. + MIBENUM_TO_NAME_MAP = new SparseArray(); + NAME_TO_MIBENUM_MAP = new SimpleArrayMap(); + assert (MIBENUM_NUMBERS.length == MIME_NAMES.length); + final int count = MIBENUM_NUMBERS.length - 1; + for (int i = 0; i <= count; i++) { + MIBENUM_TO_NAME_MAP.put(MIBENUM_NUMBERS[i], MIME_NAMES[i]); + NAME_TO_MIBENUM_MAP.put(MIME_NAMES[i], MIBENUM_NUMBERS[i]); + } + } + + private CharacterSets() { + } // Non-instantiatable + + /** + * Map an MIBEnum number to the name of the charset which this number + * is assigned to by IANA. + * + * @param mibEnumValue An IANA assigned MIBEnum number. + * @return The name string of the charset. + */ + public static String getMimeName(final int mibEnumValue) + throws UnsupportedEncodingException { + final String name = MIBENUM_TO_NAME_MAP.get(mibEnumValue); + if (name == null) { + throw new UnsupportedEncodingException(); + } + return name; + } + + /** + * Map a well-known charset name to its assigned MIBEnum number. + * + * @param mimeName The charset name. + * @return The MIBEnum number assigned by IANA for this charset. + */ + public static int getMibEnumValue(final String mimeName) + throws UnsupportedEncodingException { + if (null == mimeName) { + return -1; + } + + final Integer mibEnumValue = NAME_TO_MIBENUM_MAP.get(mimeName); + if (mibEnumValue == null) { + throw new UnsupportedEncodingException(); + } + return mibEnumValue; + } +} diff --git a/src/com/android/messaging/mmslib/pdu/DeliveryInd.java b/src/com/android/messaging/mmslib/pdu/DeliveryInd.java new file mode 100644 index 0000000..5e1d873 --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/DeliveryInd.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import com.android.messaging.mmslib.InvalidHeaderValueException; + +/** + * M-Delivery.Ind Pdu. + */ +public class DeliveryInd extends GenericPdu { + /** + * Empty constructor. + * Since the Pdu corresponding to this class is constructed + * by the Proxy-Relay server, this class is only instantiated + * by the Pdu Parser. + * + * @throws InvalidHeaderValueException if error occurs. + */ + public DeliveryInd() throws InvalidHeaderValueException { + super(); + setMessageType(PduHeaders.MESSAGE_TYPE_DELIVERY_IND); + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + DeliveryInd(PduHeaders headers) { + super(headers); + } + + /** + * Get Date value. + * + * @return the value + */ + public long getDate() { + return mPduHeaders.getLongInteger(PduHeaders.DATE); + } + + /** + * Set Date value. + * + * @param value the value + */ + public void setDate(long value) { + mPduHeaders.setLongInteger(value, PduHeaders.DATE); + } + + /** + * Get Message-ID value. + * + * @return the value + */ + public byte[] getMessageId() { + return mPduHeaders.getTextString(PduHeaders.MESSAGE_ID); + } + + /** + * Set Message-ID value. + * + * @param value the value, should not be null + * @throws NullPointerException if the value is null. + */ + public void setMessageId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.MESSAGE_ID); + } + + /** + * Get Status value. + * + * @return the value + */ + public int getStatus() { + return mPduHeaders.getOctet(PduHeaders.STATUS); + } + + /** + * Set Status value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setStatus(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.STATUS); + } + + /** + * Get To value. + * + * @return the value + */ + public EncodedStringValue[] getTo() { + return mPduHeaders.getEncodedStringValues(PduHeaders.TO); + } + + /** + * set To value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setTo(EncodedStringValue[] value) { + mPduHeaders.setEncodedStringValues(value, PduHeaders.TO); + } + + /* + * Optional, not supported header fields: + * + * public byte[] getApplicId() {return null;} + * public void setApplicId(byte[] value) {} + * + * public byte[] getAuxApplicId() {return null;} + * public void getAuxApplicId(byte[] value) {} + * + * public byte[] getReplyApplicId() {return 0x00;} + * public void setReplyApplicId(byte[] value) {} + * + * public EncodedStringValue getStatusText() {return null;} + * public void setStatusText(EncodedStringValue value) {} + */ +} diff --git a/src/com/android/messaging/mmslib/pdu/EncodedStringValue.java b/src/com/android/messaging/mmslib/pdu/EncodedStringValue.java new file mode 100644 index 0000000..1d3c9ea --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/EncodedStringValue.java @@ -0,0 +1,298 @@ +/* + * Copyright (C) 2007-2008 Esmertec AG. + * Copyright (C) 2007-2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import android.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; + +/** + * Encoded-string-value = Text-string | Value-length Char-set Text-string + */ +public class EncodedStringValue implements Cloneable { + private static final String TAG = "EncodedStringValue"; + private static final boolean DEBUG = false; + private static final boolean LOCAL_LOGV = false; + + /** + * The Char-set value. + */ + private int mCharacterSet; + + /** + * The Text-string value. + */ + private byte[] mData; + + /** + * Constructor. + * + * @param charset the Char-set value + * @param data the Text-string value + * @throws NullPointerException if Text-string value is null. + */ + public EncodedStringValue(int charset, byte[] data) { + // TODO: CharSet needs to be validated against MIBEnum. + if (null == data) { + throw new NullPointerException("EncodedStringValue: Text-string is null."); + } + + mCharacterSet = charset; + mData = new byte[data.length]; + System.arraycopy(data, 0, mData, 0, data.length); + } + + /** + * Constructor. + * + * @param data the Text-string value + * @throws NullPointerException if Text-string value is null. + */ + public EncodedStringValue(byte[] data) { + this(CharacterSets.DEFAULT_CHARSET, data); + } + + public EncodedStringValue(String data) { + this(CharacterSets.DEFAULT_CHARSET, data); + } + + /** + * Constructor + * + * @param charset + * @param data The text in Java String + * @throws NullPointerException if Text-string value is null. + */ + public EncodedStringValue(int charset, String data) { + if (null == data) { + throw new NullPointerException("EncodedStringValue: Text-string is null"); + } + mCharacterSet = charset; + try { + mData = data.getBytes(CharacterSets.getMimeName(charset)); + } catch (UnsupportedEncodingException e) { + Log.e(TAG, "Input encoding " + charset + " must be supported.", e); + mData = data.getBytes(); + } + } + + /** + * Get Char-set value. + * + * @return the value + */ + public int getCharacterSet() { + return mCharacterSet; + } + + /** + * Set Char-set value. + * + * @param charset the Char-set value + */ + public void setCharacterSet(int charset) { + // TODO: CharSet needs to be validated against MIBEnum. + mCharacterSet = charset; + } + + /** + * Get Text-string value. + * + * @return the value + */ + public byte[] getTextString() { + byte[] byteArray = new byte[mData.length]; + + System.arraycopy(mData, 0, byteArray, 0, mData.length); + return byteArray; + } + + /** + * Set Text-string value. + * + * @param textString the Text-string value + * @throws NullPointerException if Text-string value is null. + */ + public void setTextString(byte[] textString) { + if (null == textString) { + throw new NullPointerException("EncodedStringValue: Text-string is null."); + } + + mData = new byte[textString.length]; + System.arraycopy(textString, 0, mData, 0, textString.length); + } + + /** + * Convert this object to a {@link java.lang.String}. If the encoding of + * the EncodedStringValue is null or unsupported, it will be + * treated as iso-8859-1 encoding. + * + * @return The decoded String. + */ + public String getString() { + if (CharacterSets.ANY_CHARSET == mCharacterSet) { + return new String(mData); // system default encoding. + } else { + try { + String name = CharacterSets.getMimeName(mCharacterSet); + return new String(mData, name); + } catch (UnsupportedEncodingException e) { + if (LOCAL_LOGV) { + Log.v(TAG, e.getMessage(), e); + } + try { + return new String(mData, CharacterSets.MIMENAME_ISO_8859_1); + } catch (UnsupportedEncodingException exception) { + return new String(mData); // system default encoding. + } + } + } + } + + /** + * Append to Text-string. + * + * @param textString the textString to append + * @throws NullPointerException if the text String is null + * or an IOException occured. + */ + public void appendTextString(byte[] textString) { + if (null == textString) { + throw new NullPointerException("Text-string is null."); + } + + if (null == mData) { + mData = new byte[textString.length]; + System.arraycopy(textString, 0, mData, 0, textString.length); + } else { + ByteArrayOutputStream newTextString = new ByteArrayOutputStream(); + try { + newTextString.write(mData); + newTextString.write(textString); + } catch (IOException e) { + e.printStackTrace(); + throw new NullPointerException( + "appendTextString: failed when write a new Text-string"); + } + + mData = newTextString.toByteArray(); + } + } + + /* + * (non-Javadoc) + * @see java.lang.Object#clone() + */ + @Override + public Object clone() throws CloneNotSupportedException { + super.clone(); + int len = mData.length; + byte[] dstBytes = new byte[len]; + System.arraycopy(mData, 0, dstBytes, 0, len); + + try { + return new EncodedStringValue(mCharacterSet, dstBytes); + } catch (Exception e) { + Log.e(TAG, "failed to clone an EncodedStringValue: " + this); + e.printStackTrace(); + throw new CloneNotSupportedException(e.getMessage()); + } + } + + /** + * Split this encoded string around matches of the given pattern. + * + * @param pattern the delimiting pattern + * @return the array of encoded strings computed by splitting this encoded + * string around matches of the given pattern + */ + public EncodedStringValue[] split(String pattern) { + String[] temp = getString().split(pattern); + EncodedStringValue[] ret = new EncodedStringValue[temp.length]; + for (int i = 0; i < ret.length; ++i) { + try { + ret[i] = new EncodedStringValue(mCharacterSet, + temp[i].getBytes()); + } catch (NullPointerException exception) { + // Can't arrive here + return null; + } + } + return ret; + } + + /** + * Extract an EncodedStringValue[] from a given String. + */ + public static EncodedStringValue[] extract(String src) { + String[] values = src.split(";"); + + ArrayList list = new ArrayList(); + for (int i = 0; i < values.length; i++) { + if (values[i].length() > 0) { + list.add(new EncodedStringValue(values[i])); + } + } + + int len = list.size(); + if (len > 0) { + return list.toArray(new EncodedStringValue[len]); + } else { + return null; + } + } + + /** + * Concatenate an EncodedStringValue[] into a single String. + */ + public static String concat(EncodedStringValue[] addr) { + StringBuilder sb = new StringBuilder(); + int maxIndex = addr.length - 1; + for (int i = 0; i <= maxIndex; i++) { + sb.append(addr[i].getString()); + if (i < maxIndex) { + sb.append(";"); + } + } + + return sb.toString(); + } + + public static EncodedStringValue copy(EncodedStringValue value) { + if (value == null) { + return null; + } + + return new EncodedStringValue(value.mCharacterSet, value.mData); + } + + public static EncodedStringValue[] encodeStrings(String[] array) { + int count = array.length; + if (count > 0) { + EncodedStringValue[] encodedArray = new EncodedStringValue[count]; + for (int i = 0; i < count; i++) { + encodedArray[i] = new EncodedStringValue(array[i]); + } + return encodedArray; + } + return null; + } +} diff --git a/src/com/android/messaging/mmslib/pdu/GenericPdu.java b/src/com/android/messaging/mmslib/pdu/GenericPdu.java new file mode 100644 index 0000000..178dc5a --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/GenericPdu.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import com.android.messaging.mmslib.InvalidHeaderValueException; + +public class GenericPdu { + /** + * The headers of pdu. + */ + PduHeaders mPduHeaders = null; + + /** + * Constructor. + */ + public GenericPdu() { + mPduHeaders = new PduHeaders(); + } + + /** + * Constructor. + * + * @param headers Headers for this PDU. + */ + GenericPdu(PduHeaders headers) { + mPduHeaders = headers; + } + + /** + * Get the headers of this PDU. + * + * @return A PduHeaders of this PDU. + */ + PduHeaders getPduHeaders() { + return mPduHeaders; + } + + /** + * Get X-Mms-Message-Type field value. + * + * @return the X-Mms-Report-Allowed value + */ + public int getMessageType() { + return mPduHeaders.getOctet(PduHeaders.MESSAGE_TYPE); + } + + /** + * Set X-Mms-Message-Type field value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + * @throws RuntimeException if field's value is not Octet. + */ + public void setMessageType(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.MESSAGE_TYPE); + } + + /** + * Get X-Mms-MMS-Version field value. + * + * @return the X-Mms-MMS-Version value + */ + public int getMmsVersion() { + return mPduHeaders.getOctet(PduHeaders.MMS_VERSION); + } + + /** + * Set X-Mms-MMS-Version field value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + * @throws RuntimeException if field's value is not Octet. + */ + public void setMmsVersion(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.MMS_VERSION); + } + + /** + * Get From value. + * From-value = Value-length + * (Address-present-token Encoded-string-value | Insert-address-token) + * + * @return the value + */ + public EncodedStringValue getFrom() { + return mPduHeaders.getEncodedStringValue(PduHeaders.FROM); + } + + /** + * Set From value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setFrom(EncodedStringValue value) { + mPduHeaders.setEncodedStringValue(value, PduHeaders.FROM); + } +} diff --git a/src/com/android/messaging/mmslib/pdu/MultimediaMessagePdu.java b/src/com/android/messaging/mmslib/pdu/MultimediaMessagePdu.java new file mode 100644 index 0000000..cb8c8c7 --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/MultimediaMessagePdu.java @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import com.android.messaging.mmslib.InvalidHeaderValueException; + +/** + * Multimedia message PDU. + */ +public class MultimediaMessagePdu extends GenericPdu { + /** + * The body. + */ + private PduBody mMessageBody; + + /** + * Constructor. + */ + public MultimediaMessagePdu() { + super(); + } + + /** + * Constructor. + * + * @param header the header of this PDU + * @param body the body of this PDU + */ + public MultimediaMessagePdu(PduHeaders header, PduBody body) { + super(header); + mMessageBody = body; + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + MultimediaMessagePdu(PduHeaders headers) { + super(headers); + } + + /** + * Get body of the PDU. + * + * @return the body + */ + public PduBody getBody() { + return mMessageBody; + } + + /** + * Set body of the PDU. + * + * @param body the body + */ + public void setBody(PduBody body) { + mMessageBody = body; + } + + /** + * Get subject. + * + * @return the value + */ + public EncodedStringValue getSubject() { + return mPduHeaders.getEncodedStringValue(PduHeaders.SUBJECT); + } + + /** + * Set subject. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setSubject(EncodedStringValue value) { + mPduHeaders.setEncodedStringValue(value, PduHeaders.SUBJECT); + } + + /** + * Get To value. + * + * @return the value + */ + public EncodedStringValue[] getTo() { + return mPduHeaders.getEncodedStringValues(PduHeaders.TO); + } + + /** + * Add a "To" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void addTo(EncodedStringValue value) { + mPduHeaders.appendEncodedStringValue(value, PduHeaders.TO); + } + + /** + * Get X-Mms-Priority value. + * + * @return the value + */ + public int getPriority() { + return mPduHeaders.getOctet(PduHeaders.PRIORITY); + } + + /** + * Set X-Mms-Priority value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setPriority(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.PRIORITY); + } + + /** + * Get Date value. + * + * @return the value + */ + public long getDate() { + return mPduHeaders.getLongInteger(PduHeaders.DATE); + } + + /** + * Set Date value in seconds. + * + * @param value the value + */ + public void setDate(long value) { + mPduHeaders.setLongInteger(value, PduHeaders.DATE); + } + + /** + * Get the message size + * + * @return the size of the message + */ + public long getMessageSize() { + return mPduHeaders.getLongInteger(PduHeaders.MESSAGE_SIZE); + } +} diff --git a/src/com/android/messaging/mmslib/pdu/NotificationInd.java b/src/com/android/messaging/mmslib/pdu/NotificationInd.java new file mode 100644 index 0000000..4cbbd30 --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/NotificationInd.java @@ -0,0 +1,285 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import com.android.messaging.mmslib.InvalidHeaderValueException; + +/** + * M-Notification.ind PDU. + */ +public class NotificationInd extends GenericPdu { + /** + * Empty constructor. + * Since the Pdu corresponding to this class is constructed + * by the Proxy-Relay server, this class is only instantiated + * by the Pdu Parser. + * + * @throws InvalidHeaderValueException if error occurs. + * @throws RuntimeException if an undeclared error occurs. + */ + public NotificationInd() throws InvalidHeaderValueException { + super(); + setMessageType(PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND); + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + NotificationInd(PduHeaders headers) { + super(headers); + } + + /** + * Get X-Mms-Content-Class Value. + * + * @return the value + */ + public int getContentClass() { + return mPduHeaders.getOctet(PduHeaders.CONTENT_CLASS); + } + + /** + * Set X-Mms-Content-Class Value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + * @throws RuntimeException if an undeclared error occurs. + */ + public void setContentClass(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.CONTENT_CLASS); + } + + /** + * Get X-Mms-Content-Location value. + * When used in a PDU other than M-Mbox-Delete.conf and M-Delete.conf: + * Content-location-value = Uri-value + * + * @return the value + */ + public byte[] getContentLocation() { + return mPduHeaders.getTextString(PduHeaders.CONTENT_LOCATION); + } + + /** + * Set X-Mms-Content-Location value. + * + * @param value the value + * @throws NullPointerException if the value is null. + * @throws RuntimeException if an undeclared error occurs. + */ + public void setContentLocation(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.CONTENT_LOCATION); + } + + /** + * Get X-Mms-Expiry value. + * + * Expiry-value = Value-length + * (Absolute-token Date-value | Relative-token Delta-seconds-value) + * + * @return the value + */ + public long getExpiry() { + return mPduHeaders.getLongInteger(PduHeaders.EXPIRY); + } + + /** + * Set X-Mms-Expiry value. + * + * @param value the value + * @throws RuntimeException if an undeclared error occurs. + */ + public void setExpiry(long value) { + mPduHeaders.setLongInteger(value, PduHeaders.EXPIRY); + } + + /** + * Get From value. + * From-value = Value-length + * (Address-present-token Encoded-string-value | Insert-address-token) + * + * @return the value + */ + public EncodedStringValue getFrom() { + return mPduHeaders.getEncodedStringValue(PduHeaders.FROM); + } + + /** + * Set From value. + * + * @param value the value + * @throws NullPointerException if the value is null. + * @throws RuntimeException if an undeclared error occurs. + */ + public void setFrom(EncodedStringValue value) { + mPduHeaders.setEncodedStringValue(value, PduHeaders.FROM); + } + + /** + * Get X-Mms-Message-Class value. + * Message-class-value = Class-identifier | Token-text + * Class-identifier = Personal | Advertisement | Informational | Auto + * + * @return the value + */ + public byte[] getMessageClass() { + return mPduHeaders.getTextString(PduHeaders.MESSAGE_CLASS); + } + + /** + * Set X-Mms-Message-Class value. + * + * @param value the value + * @throws NullPointerException if the value is null. + * @throws RuntimeException if an undeclared error occurs. + */ + public void setMessageClass(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.MESSAGE_CLASS); + } + + /** + * Get X-Mms-Message-Size value. + * Message-size-value = Long-integer + * + * @return the value + */ + public long getMessageSize() { + return mPduHeaders.getLongInteger(PduHeaders.MESSAGE_SIZE); + } + + /** + * Set X-Mms-Message-Size value. + * + * @param value the value + * @throws RuntimeException if an undeclared error occurs. + */ + public void setMessageSize(long value) { + mPduHeaders.setLongInteger(value, PduHeaders.MESSAGE_SIZE); + } + + /** + * Get subject. + * + * @return the value + */ + public EncodedStringValue getSubject() { + return mPduHeaders.getEncodedStringValue(PduHeaders.SUBJECT); + } + + /** + * Set subject. + * + * @param value the value + * @throws NullPointerException if the value is null. + * @throws RuntimeException if an undeclared error occurs. + */ + public void setSubject(EncodedStringValue value) { + mPduHeaders.setEncodedStringValue(value, PduHeaders.SUBJECT); + } + + /** + * Get X-Mms-Transaction-Id. + * + * @return the value + */ + public byte[] getTransactionId() { + return mPduHeaders.getTextString(PduHeaders.TRANSACTION_ID); + } + + /** + * Set X-Mms-Transaction-Id. + * + * @param value the value + * @throws NullPointerException if the value is null. + * @throws RuntimeException if an undeclared error occurs. + */ + public void setTransactionId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.TRANSACTION_ID); + } + + /** + * Get X-Mms-Delivery-Report Value. + * + * @return the value + */ + public int getDeliveryReport() { + return mPduHeaders.getOctet(PduHeaders.DELIVERY_REPORT); + } + + /** + * Set X-Mms-Delivery-Report Value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + * @throws RuntimeException if an undeclared error occurs. + */ + public void setDeliveryReport(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.DELIVERY_REPORT); + } + + /* + * Optional, not supported header fields: + * + * public byte[] getApplicId() {return null;} + * public void setApplicId(byte[] value) {} + * + * public byte[] getAuxApplicId() {return null;} + * public void getAuxApplicId(byte[] value) {} + * + * public byte getDrmContent() {return 0x00;} + * public void setDrmContent(byte value) {} + * + * public byte getDistributionIndicator() {return 0x00;} + * public void setDistributionIndicator(byte value) {} + * + * public ElementDescriptorValue getElementDescriptor() {return null;} + * public void getElementDescriptor(ElementDescriptorValue value) {} + * + * public byte getPriority() {return 0x00;} + * public void setPriority(byte value) {} + * + * public byte getRecommendedRetrievalMode() {return 0x00;} + * public void setRecommendedRetrievalMode(byte value) {} + * + * public byte getRecommendedRetrievalModeText() {return 0x00;} + * public void setRecommendedRetrievalModeText(byte value) {} + * + * public byte[] getReplaceId() {return 0x00;} + * public void setReplaceId(byte[] value) {} + * + * public byte[] getReplyApplicId() {return 0x00;} + * public void setReplyApplicId(byte[] value) {} + * + * public byte getReplyCharging() {return 0x00;} + * public void setReplyCharging(byte value) {} + * + * public byte getReplyChargingDeadline() {return 0x00;} + * public void setReplyChargingDeadline(byte value) {} + * + * public byte[] getReplyChargingId() {return 0x00;} + * public void setReplyChargingId(byte[] value) {} + * + * public long getReplyChargingSize() {return 0;} + * public void setReplyChargingSize(long value) {} + * + * public byte getStored() {return 0x00;} + * public void setStored(byte value) {} + */ +} diff --git a/src/com/android/messaging/mmslib/pdu/NotifyRespInd.java b/src/com/android/messaging/mmslib/pdu/NotifyRespInd.java new file mode 100644 index 0000000..7dabd89 --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/NotifyRespInd.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import com.android.messaging.mmslib.InvalidHeaderValueException; + +/** + * M-NofifyResp.ind PDU. + */ +public class NotifyRespInd extends GenericPdu { + /** + * Constructor, used when composing a M-NotifyResp.ind pdu. + * + * @param mmsVersion current version of mms + * @param transactionId the transaction-id value + * @param status the status value + * @throws InvalidHeaderValueException if parameters are invalid. + * @throws NullPointerException if transactionId is null. + * @throws RuntimeException if an undeclared error occurs. + */ + public NotifyRespInd(int mmsVersion, + byte[] transactionId, + int status) throws InvalidHeaderValueException { + super(); + setMessageType(PduHeaders.MESSAGE_TYPE_NOTIFYRESP_IND); + setMmsVersion(mmsVersion); + setTransactionId(transactionId); + setStatus(status); + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + NotifyRespInd(PduHeaders headers) { + super(headers); + } + + /** + * Get X-Mms-Report-Allowed field value. + * + * @return the X-Mms-Report-Allowed value + */ + public int getReportAllowed() { + return mPduHeaders.getOctet(PduHeaders.REPORT_ALLOWED); + } + + /** + * Set X-Mms-Report-Allowed field value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + * @throws RuntimeException if an undeclared error occurs. + */ + public void setReportAllowed(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.REPORT_ALLOWED); + } + + /** + * Set X-Mms-Status field value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + * @throws RuntimeException if an undeclared error occurs. + */ + public void setStatus(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.STATUS); + } + + /** + * GetX-Mms-Status field value. + * + * @return the X-Mms-Status value + */ + public int getStatus() { + return mPduHeaders.getOctet(PduHeaders.STATUS); + } + + /** + * Get X-Mms-Transaction-Id field value. + * + * @return the X-Mms-Report-Allowed value + */ + public byte[] getTransactionId() { + return mPduHeaders.getTextString(PduHeaders.TRANSACTION_ID); + } + + /** + * Set X-Mms-Transaction-Id field value. + * + * @param value the value + * @throws NullPointerException if the value is null. + * @throws RuntimeException if an undeclared error occurs. + */ + public void setTransactionId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.TRANSACTION_ID); + } +} diff --git a/src/com/android/messaging/mmslib/pdu/PduBody.java b/src/com/android/messaging/mmslib/pdu/PduBody.java new file mode 100644 index 0000000..8cbb2e6 --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/PduBody.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import java.util.Vector; + +public class PduBody { + private Vector mParts = null; + + /** + * Constructor. + */ + public PduBody() { + mParts = new Vector(); + } + + /** + * Appends the specified part to the end of this body. + * + * @param part part to be appended + * @return true when success, false when fail + * @throws NullPointerException when part is null + */ + public boolean addPart(PduPart part) { + if (null == part) { + throw new NullPointerException(); + } + + return mParts.add(part); + } + + /** + * Inserts the specified part at the specified position. + * + * @param index index at which the specified part is to be inserted + * @param part part to be inserted + * @throws NullPointerException when part is null + */ + public void addPart(int index, PduPart part) { + if (null == part) { + throw new NullPointerException(); + } + + mParts.add(index, part); + } + + /** + * Remove all of the parts. + */ + public void removeAll() { + mParts.clear(); + } + + /** + * Get the part at the specified position. + * + * @param index index of the part to return + * @return part at the specified index + */ + public PduPart getPart(int index) { + return mParts.get(index); + } + + /** + * Get the number of parts. + * + * @return the number of parts + */ + public int getPartsNum() { + return mParts.size(); + } +} diff --git a/src/com/android/messaging/mmslib/pdu/PduComposer.java b/src/com/android/messaging/mmslib/pdu/PduComposer.java new file mode 100644 index 0000000..d05a198 --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/PduComposer.java @@ -0,0 +1,1260 @@ +/* + * Copyright (C) 2007-2008 Esmertec AG. + * Copyright (C) 2007-2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import android.content.ContentResolver; +import android.content.Context; +import android.support.v4.util.SimpleArrayMap; +import android.text.TextUtils; + +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; + +public class PduComposer { + /** + * Address type. + */ + private static final int PDU_PHONE_NUMBER_ADDRESS_TYPE = 1; + private static final int PDU_EMAIL_ADDRESS_TYPE = 2; + private static final int PDU_IPV4_ADDRESS_TYPE = 3; + private static final int PDU_IPV6_ADDRESS_TYPE = 4; + private static final int PDU_UNKNOWN_ADDRESS_TYPE = 5; + + /** + * Address regular expression string. + */ + static final String REGEXP_PHONE_NUMBER_ADDRESS_TYPE = "\\+?[0-9|\\.|\\-]+"; + + static final String REGEXP_EMAIL_ADDRESS_TYPE = "[a-zA-Z| ]*\\<{0,1}[a-zA-Z| ]+@{1}" + + "[a-zA-Z| ]+\\.{1}[a-zA-Z| ]+\\>{0,1}"; + + static final String REGEXP_IPV6_ADDRESS_TYPE = + "[a-fA-F]{4}\\:{1}[a-fA-F0-9]{4}\\:{1}[a-fA-F0-9]{4}\\:{1}" + + "[a-fA-F0-9]{4}\\:{1}[a-fA-F0-9]{4}\\:{1}[a-fA-F0-9]{4}\\:{1}" + + "[a-fA-F0-9]{4}\\:{1}[a-fA-F0-9]{4}"; + + static final String REGEXP_IPV4_ADDRESS_TYPE = "[0-9]{1,3}\\.{1}[0-9]{1,3}\\.{1}" + + "[0-9]{1,3}\\.{1}[0-9]{1,3}"; + + /** + * The postfix strings of address. + */ + static final String STRING_PHONE_NUMBER_ADDRESS_TYPE = "/TYPE=PLMN"; + static final String STRING_IPV4_ADDRESS_TYPE = "/TYPE=IPV4"; + static final String STRING_IPV6_ADDRESS_TYPE = "/TYPE=IPV6"; + + /** + * Error values. + */ + private static final int PDU_COMPOSE_SUCCESS = 0; + private static final int PDU_COMPOSE_CONTENT_ERROR = 1; + private static final int PDU_COMPOSE_FIELD_NOT_SET = 2; + private static final int PDU_COMPOSE_FIELD_NOT_SUPPORTED = 3; + + /** + * WAP values defined in WSP spec. + */ + private static final int QUOTED_STRING_FLAG = 34; + private static final int END_STRING_FLAG = 0; + private static final int LENGTH_QUOTE = 31; + private static final int TEXT_MAX = 127; + private static final int SHORT_INTEGER_MAX = 127; + private static final int LONG_INTEGER_LENGTH_MAX = 8; + + /** + * Block size when read data from InputStream. + */ + private static final int PDU_COMPOSER_BLOCK_SIZE = 1024; + + /** + * The output message. + */ + protected ByteArrayOutputStream mMessage = null; + + /** + * The PDU. + */ + private GenericPdu mPdu = null; + + /** + * Current visiting position of the mMessage. + */ + protected int mPosition = 0; + + /** + * Message compose buffer stack. + */ + private BufferStack mStack = null; + + /** + * Content resolver. + */ + private final ContentResolver mResolver; + + /** + * Header of this pdu. + */ + private PduHeaders mPduHeader = null; + + /** + * Map of all content type + */ + private static SimpleArrayMap mContentTypeMap = null; + + static { + mContentTypeMap = new SimpleArrayMap(); + + int i; + for (i = 0; i < PduContentTypes.contentTypes.length; i++) { + mContentTypeMap.put(PduContentTypes.contentTypes[i], i); + } + } + + /** + * Constructor. + * + * @param context the context + * @param pdu the pdu to be composed + */ + public PduComposer(final Context context, final GenericPdu pdu) { + mPdu = pdu; + mResolver = context.getContentResolver(); + mPduHeader = pdu.getPduHeaders(); + mStack = new BufferStack(); + mMessage = new ByteArrayOutputStream(); + mPosition = 0; + } + + /** + * Make the message. No need to check whether mandatory fields are set, + * because the constructors of outgoing pdus are taking care of this. + * + * @return OutputStream of maked message. Return null if + * the PDU is invalid. + */ + public byte[] make() { + // Get Message-type. + final int type = mPdu.getMessageType(); + + /* make the message */ + switch (type) { + case PduHeaders.MESSAGE_TYPE_SEND_REQ: + if (makeSendReqPdu() != PDU_COMPOSE_SUCCESS) { + return null; + } + break; + case PduHeaders.MESSAGE_TYPE_NOTIFYRESP_IND: + if (makeNotifyResp() != PDU_COMPOSE_SUCCESS) { + return null; + } + break; + case PduHeaders.MESSAGE_TYPE_ACKNOWLEDGE_IND: + if (makeAckInd() != PDU_COMPOSE_SUCCESS) { + return null; + } + break; + case PduHeaders.MESSAGE_TYPE_READ_REC_IND: + if (makeReadRecInd() != PDU_COMPOSE_SUCCESS) { + return null; + } + break; + case PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND: + if (makeNotificationInd() != PDU_COMPOSE_SUCCESS) { + return null; + } + break; + default: + return null; + } + + return mMessage.toByteArray(); + } + + /** + * Copy buf to mMessage. + */ + protected void arraycopy(final byte[] buf, final int pos, final int length) { + mMessage.write(buf, pos, length); + mPosition = mPosition + length; + } + + /** + * Append a byte to mMessage. + */ + protected void append(final int value) { + mMessage.write(value); + mPosition++; + } + + /** + * Append short integer value to mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendShortInteger(final int value) { + /* + * From WAP-230-WSP-20010705-a: + * Short-integer = OCTET + * ; Integers in range 0-127 shall be encoded as a one octet value + * ; with the most significant bit set to one (1xxx xxxx) and with + * ; the value in the remaining least significant bits. + * In our implementation, only low 7 bits are stored and otherwise + * bits are ignored. + */ + append((value | 0x80) & 0xff); + } + + /** + * Append an octet number between 128 and 255 into mMessage. + * NOTE: + * A value between 0 and 127 should be appended by using appendShortInteger. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendOctet(final int number) { + append(number); + } + + /** + * Append a short length into mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendShortLength(final int value) { + /* + * From WAP-230-WSP-20010705-a: + * Short-length = + */ + append(value); + } + + /** + * Append long integer into mMessage. it's used for really long integers. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendLongInteger(final long longInt) { + /* + * From WAP-230-WSP-20010705-a: + * Long-integer = Short-length Multi-octet-integer + * ; The Short-length indicates the length of the Multi-octet-integer + * Multi-octet-integer = 1*30 OCTET + * ; The content octets shall be an unsigned integer value with the + * ; most significant octet encoded first (big-endian representation). + * ; The minimum number of octets must be used to encode the value. + */ + int size; + long temp = longInt; + + // Count the length of the long integer. + for (size = 0; (temp != 0) && (size < LONG_INTEGER_LENGTH_MAX); size++) { + temp = (temp >>> 8); + } + + // Set Length. + appendShortLength(size); + + // Count and set the long integer. + int i; + int shift = (size - 1) * 8; + + for (i = 0; i < size; i++) { + append((int) ((longInt >>> shift) & 0xff)); + shift = shift - 8; + } + } + + /** + * Append text string into mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendTextString(final byte[] text) { + /* + * From WAP-230-WSP-20010705-a: + * Text-string = [Quote] *TEXT End-of-string + * ; If the first character in the TEXT is in the range of 128-255, + * ; a Quote character must precede it. Otherwise the Quote character + * ;must be omitted. The Quote is not part of the contents. + */ + if (((text[0]) & 0xff) > TEXT_MAX) { // No need to check for <= 255 + append(TEXT_MAX); + } + + arraycopy(text, 0, text.length); + append(0); + } + + /** + * Append text string into mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendTextString(final String str) { + /* + * From WAP-230-WSP-20010705-a: + * Text-string = [Quote] *TEXT End-of-string + * ; If the first character in the TEXT is in the range of 128-255, + * ; a Quote character must precede it. Otherwise the Quote character + * ;must be omitted. The Quote is not part of the contents. + */ + appendTextString(str.getBytes()); + } + + /** + * Append encoded string value to mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendEncodedString(final EncodedStringValue enStr) { + /* + * From OMA-TS-MMS-ENC-V1_3-20050927-C: + * Encoded-string-value = Text-string | Value-length Char-set Text-string + */ + assert (enStr != null); + + final int charset = enStr.getCharacterSet(); + final byte[] textString = enStr.getTextString(); + if (null == textString) { + return; + } + + /* + * In the implementation of EncodedStringValue, the charset field will + * never be 0. It will always be composed as + * Encoded-string-value = Value-length Char-set Text-string + */ + mStack.newbuf(); + final PositionMarker start = mStack.mark(); + + appendShortInteger(charset); + appendTextString(textString); + + final int len = start.getLength(); + mStack.pop(); + appendValueLength(len); + mStack.copy(); + } + + /** + * Append uintvar integer into mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendUintvarInteger(final long value) { + /* + * From WAP-230-WSP-20010705-a: + * To encode a large unsigned integer, split it into 7-bit fragments + * and place them in the payloads of multiple octets. The most significant + * bits are placed in the first octets with the least significant bits + * ending up in the last octet. All octets MUST set the Continue bit to 1 + * except the last octet, which MUST set the Continue bit to 0. + */ + int i; + long max = SHORT_INTEGER_MAX; + + for (i = 0; i < 5; i++) { + if (value < max) { + break; + } + + max = (max << 7) | 0x7fL; + } + + while (i > 0) { + long temp = value >>> (i * 7); + temp = temp & 0x7f; + + append((int) ((temp | 0x80) & 0xff)); + + i--; + } + + append((int) (value & 0x7f)); + } + + /** + * Append date value into mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendDateValue(final long date) { + /* + * From OMA-TS-MMS-ENC-V1_3-20050927-C: + * Date-value = Long-integer + */ + appendLongInteger(date); + } + + /** + * Append value length to mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendValueLength(final long value) { + /* + * From WAP-230-WSP-20010705-a: + * Value-length = Short-length | (Length-quote Length) + * ; Value length is used to indicate the length of the value to follow + * Short-length = + * Length-quote = + * Length = Uintvar-integer + */ + if (value < LENGTH_QUOTE) { + appendShortLength((int) value); + return; + } + + append(LENGTH_QUOTE); + appendUintvarInteger(value); + } + + /** + * Append quoted string to mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendQuotedString(final byte[] text) { + /* + * From WAP-230-WSP-20010705-a: + * Quoted-string = *TEXT End-of-string + * ;The TEXT encodes an RFC2616 Quoted-string with the enclosing + * ;quotation-marks <"> removed. + */ + append(QUOTED_STRING_FLAG); + arraycopy(text, 0, text.length); + append(END_STRING_FLAG); + } + + /** + * Append quoted string to mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendQuotedString(final String str) { + /* + * From WAP-230-WSP-20010705-a: + * Quoted-string = *TEXT End-of-string + * ;The TEXT encodes an RFC2616 Quoted-string with the enclosing + * ;quotation-marks <"> removed. + */ + appendQuotedString(str.getBytes()); + } + + private EncodedStringValue appendAddressType(final EncodedStringValue address) { + EncodedStringValue temp = null; + + try { + final int addressType = checkAddressType(address.getString()); + temp = EncodedStringValue.copy(address); + if (PDU_PHONE_NUMBER_ADDRESS_TYPE == addressType) { + // Phone number. + temp.appendTextString(STRING_PHONE_NUMBER_ADDRESS_TYPE.getBytes()); + } else if (PDU_IPV4_ADDRESS_TYPE == addressType) { + // Ipv4 address. + temp.appendTextString(STRING_IPV4_ADDRESS_TYPE.getBytes()); + } else if (PDU_IPV6_ADDRESS_TYPE == addressType) { + // Ipv6 address. + temp.appendTextString(STRING_IPV6_ADDRESS_TYPE.getBytes()); + } + } catch (final NullPointerException e) { + return null; + } + + return temp; + } + + /** + * Append header to mMessage. + */ + private int appendHeader(final int field) { + switch (field) { + case PduHeaders.MMS_VERSION: + appendOctet(field); + + final int version = mPduHeader.getOctet(field); + if (0 == version) { + appendShortInteger(PduHeaders.CURRENT_MMS_VERSION); + } else { + appendShortInteger(version); + } + + break; + + case PduHeaders.MESSAGE_ID: + case PduHeaders.TRANSACTION_ID: + case PduHeaders.CONTENT_LOCATION: + final byte[] textString = mPduHeader.getTextString(field); + if (null == textString) { + return PDU_COMPOSE_FIELD_NOT_SET; + } + + appendOctet(field); + appendTextString(textString); + break; + + case PduHeaders.TO: + case PduHeaders.BCC: + case PduHeaders.CC: + final EncodedStringValue[] addr = mPduHeader.getEncodedStringValues(field); + + if (null == addr) { + return PDU_COMPOSE_FIELD_NOT_SET; + } + + EncodedStringValue temp; + for (int i = 0; i < addr.length; i++) { + temp = appendAddressType(addr[i]); + if (temp == null) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + appendOctet(field); + appendEncodedString(temp); + } + break; + + case PduHeaders.FROM: + // Value-length (Address-present-token Encoded-string-value | Insert-address-token) + appendOctet(field); + + final EncodedStringValue from = mPduHeader.getEncodedStringValue(field); + if ((from == null) + || TextUtils.isEmpty(from.getString()) + || new String(from.getTextString()).equals( + PduHeaders.FROM_INSERT_ADDRESS_TOKEN_STR)) { + // Length of from = 1 + append(1); + // Insert-address-token = + append(PduHeaders.FROM_INSERT_ADDRESS_TOKEN); + } else { + mStack.newbuf(); + final PositionMarker fstart = mStack.mark(); + + // Address-present-token = + append(PduHeaders.FROM_ADDRESS_PRESENT_TOKEN); + + temp = appendAddressType(from); + if (temp == null) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + appendEncodedString(temp); + + final int flen = fstart.getLength(); + mStack.pop(); + appendValueLength(flen); + mStack.copy(); + } + break; + + case PduHeaders.READ_STATUS: + case PduHeaders.STATUS: + case PduHeaders.REPORT_ALLOWED: + case PduHeaders.PRIORITY: + case PduHeaders.DELIVERY_REPORT: + case PduHeaders.READ_REPORT: + final int octet = mPduHeader.getOctet(field); + if (0 == octet) { + return PDU_COMPOSE_FIELD_NOT_SET; + } + + appendOctet(field); + appendOctet(octet); + break; + + case PduHeaders.DATE: + final long date = mPduHeader.getLongInteger(field); + if (-1 == date) { + return PDU_COMPOSE_FIELD_NOT_SET; + } + + appendOctet(field); + appendDateValue(date); + break; + + case PduHeaders.SUBJECT: + final EncodedStringValue enString = + mPduHeader.getEncodedStringValue(field); + if (null == enString) { + return PDU_COMPOSE_FIELD_NOT_SET; + } + + appendOctet(field); + appendEncodedString(enString); + break; + + case PduHeaders.MESSAGE_CLASS: + final byte[] messageClass = mPduHeader.getTextString(field); + if (null == messageClass) { + return PDU_COMPOSE_FIELD_NOT_SET; + } + + appendOctet(field); + if (Arrays.equals(messageClass, + PduHeaders.MESSAGE_CLASS_ADVERTISEMENT_STR.getBytes())) { + appendOctet(PduHeaders.MESSAGE_CLASS_ADVERTISEMENT); + } else if (Arrays.equals(messageClass, + PduHeaders.MESSAGE_CLASS_AUTO_STR.getBytes())) { + appendOctet(PduHeaders.MESSAGE_CLASS_AUTO); + } else if (Arrays.equals(messageClass, + PduHeaders.MESSAGE_CLASS_PERSONAL_STR.getBytes())) { + appendOctet(PduHeaders.MESSAGE_CLASS_PERSONAL); + } else if (Arrays.equals(messageClass, + PduHeaders.MESSAGE_CLASS_INFORMATIONAL_STR.getBytes())) { + appendOctet(PduHeaders.MESSAGE_CLASS_INFORMATIONAL); + } else { + appendTextString(messageClass); + } + break; + + case PduHeaders.EXPIRY: + case PduHeaders.MESSAGE_SIZE: + final long value = mPduHeader.getLongInteger(field); + if (-1 == value) { + return PDU_COMPOSE_FIELD_NOT_SET; + } + + appendOctet(field); + + mStack.newbuf(); + final PositionMarker valueStart = mStack.mark(); + + append(PduHeaders.VALUE_RELATIVE_TOKEN); + appendLongInteger(value); + + final int valueLength = valueStart.getLength(); + mStack.pop(); + appendValueLength(valueLength); + mStack.copy(); + break; + + default: + return PDU_COMPOSE_FIELD_NOT_SUPPORTED; + } + + return PDU_COMPOSE_SUCCESS; + } + + /** + * Make ReadRec.Ind. + */ + private int makeReadRecInd() { + if (mMessage == null) { + mMessage = new ByteArrayOutputStream(); + mPosition = 0; + } + + // X-Mms-Message-Type + appendOctet(PduHeaders.MESSAGE_TYPE); + appendOctet(PduHeaders.MESSAGE_TYPE_READ_REC_IND); + + // X-Mms-MMS-Version + if (appendHeader(PduHeaders.MMS_VERSION) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // Message-ID + if (appendHeader(PduHeaders.MESSAGE_ID) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // To + if (appendHeader(PduHeaders.TO) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // From + if (appendHeader(PduHeaders.FROM) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // Date Optional + appendHeader(PduHeaders.DATE); + + // X-Mms-Read-Status + if (appendHeader(PduHeaders.READ_STATUS) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // X-Mms-Applic-ID Optional(not support) + // X-Mms-Reply-Applic-ID Optional(not support) + // X-Mms-Aux-Applic-Info Optional(not support) + + return PDU_COMPOSE_SUCCESS; + } + + /** + * Make NotifyResp.Ind. + */ + private int makeNotifyResp() { + if (mMessage == null) { + mMessage = new ByteArrayOutputStream(); + mPosition = 0; + } + + // X-Mms-Message-Type + appendOctet(PduHeaders.MESSAGE_TYPE); + appendOctet(PduHeaders.MESSAGE_TYPE_NOTIFYRESP_IND); + + // X-Mms-Transaction-ID + if (appendHeader(PduHeaders.TRANSACTION_ID) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // X-Mms-MMS-Version + if (appendHeader(PduHeaders.MMS_VERSION) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // X-Mms-Status + if (appendHeader(PduHeaders.STATUS) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // X-Mms-Report-Allowed Optional (not support) + return PDU_COMPOSE_SUCCESS; + } + + /** + * Make Acknowledge.Ind. + */ + private int makeAckInd() { + if (mMessage == null) { + mMessage = new ByteArrayOutputStream(); + mPosition = 0; + } + + // X-Mms-Message-Type + appendOctet(PduHeaders.MESSAGE_TYPE); + appendOctet(PduHeaders.MESSAGE_TYPE_ACKNOWLEDGE_IND); + + // X-Mms-Transaction-ID + if (appendHeader(PduHeaders.TRANSACTION_ID) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // X-Mms-MMS-Version + if (appendHeader(PduHeaders.MMS_VERSION) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // X-Mms-Report-Allowed Optional + appendHeader(PduHeaders.REPORT_ALLOWED); + + return PDU_COMPOSE_SUCCESS; + } + + /** + * Make Acknowledge.Ind. + */ + private int makeNotificationInd() { + if (mMessage == null) { + mMessage = new ByteArrayOutputStream(); + mPosition = 0; + } + + // X-Mms-Message-Type + appendOctet(PduHeaders.MESSAGE_TYPE); + appendOctet(PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND); + + // X-Mms-Transaction-ID + if (appendHeader(PduHeaders.TRANSACTION_ID) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // X-Mms-MMS-Version + if (appendHeader(PduHeaders.MMS_VERSION) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // From + if (appendHeader(PduHeaders.FROM) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // Subject Optional + appendHeader(PduHeaders.SUBJECT); + + // Expiry + if (appendHeader(PduHeaders.MESSAGE_CLASS) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // Expiry + if (appendHeader(PduHeaders.MESSAGE_SIZE) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // Expiry + if (appendHeader(PduHeaders.EXPIRY) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // X-Mms-Content-Location + if (appendHeader(PduHeaders.CONTENT_LOCATION) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + return PDU_COMPOSE_SUCCESS; + } + + /** + * Make Send.req. + */ + private int makeSendReqPdu() { + if (mMessage == null) { + mMessage = new ByteArrayOutputStream(); + mPosition = 0; + } + + // X-Mms-Message-Type + appendOctet(PduHeaders.MESSAGE_TYPE); + appendOctet(PduHeaders.MESSAGE_TYPE_SEND_REQ); + + // X-Mms-Transaction-ID + appendOctet(PduHeaders.TRANSACTION_ID); + + final byte[] trid = mPduHeader.getTextString(PduHeaders.TRANSACTION_ID); + if (trid == null) { + // Transaction-ID should be set(by Transaction) before make(). + throw new IllegalArgumentException("Transaction-ID is null."); + } + appendTextString(trid); + + // X-Mms-MMS-Version + if (appendHeader(PduHeaders.MMS_VERSION) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // Date Date-value Optional. + appendHeader(PduHeaders.DATE); + + // From + if (appendHeader(PduHeaders.FROM) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + boolean recipient = false; + + // To + if (appendHeader(PduHeaders.TO) != PDU_COMPOSE_CONTENT_ERROR) { + recipient = true; + } + + // Cc + if (appendHeader(PduHeaders.CC) != PDU_COMPOSE_CONTENT_ERROR) { + recipient = true; + } + + // Bcc + if (appendHeader(PduHeaders.BCC) != PDU_COMPOSE_CONTENT_ERROR) { + recipient = true; + } + + // Need at least one of "cc", "bcc" and "to". + if (false == recipient) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // Subject Optional + appendHeader(PduHeaders.SUBJECT); + + // X-Mms-Message-Class Optional + // Message-class-value = Class-identifier | Token-text + appendHeader(PduHeaders.MESSAGE_CLASS); + + // X-Mms-Expiry Optional + appendHeader(PduHeaders.EXPIRY); + + // X-Mms-Priority Optional + appendHeader(PduHeaders.PRIORITY); + + // X-Mms-Delivery-Report Optional + appendHeader(PduHeaders.DELIVERY_REPORT); + + // X-Mms-Read-Report Optional + appendHeader(PduHeaders.READ_REPORT); + + // Content-Type + appendOctet(PduHeaders.CONTENT_TYPE); + + // Message body + return makeMessageBody(); + } + + /** + * Make message body. + */ + private int makeMessageBody() { + // 1. add body informations + mStack.newbuf(); // Switching buffer because we need to + + final PositionMarker ctStart = mStack.mark(); + + // This contentTypeIdentifier should be used for type of attachment... + final String contentType = new String(mPduHeader.getTextString(PduHeaders.CONTENT_TYPE)); + final Integer contentTypeIdentifier = mContentTypeMap.get(contentType); + if (contentTypeIdentifier == null) { + // content type is mandatory + return PDU_COMPOSE_CONTENT_ERROR; + } + + appendShortInteger(contentTypeIdentifier.intValue()); + + // content-type parameter: start + final PduBody body = ((SendReq) mPdu).getBody(); + if (null == body || body.getPartsNum() == 0) { + // empty message + appendUintvarInteger(0); + mStack.pop(); + mStack.copy(); + return PDU_COMPOSE_SUCCESS; + } + + PduPart part; + try { + part = body.getPart(0); + + final byte[] start = part.getContentId(); + if (start != null) { + appendOctet(PduPart.P_DEP_START); + if (('<' == start[0]) && ('>' == start[start.length - 1])) { + appendTextString(start); + } else { + appendTextString("<" + new String(start) + ">"); + } + } + + // content-type parameter: type + appendOctet(PduPart.P_CT_MR_TYPE); + appendTextString(part.getContentType()); + } catch (final ArrayIndexOutOfBoundsException e) { + e.printStackTrace(); + } + + final int ctLength = ctStart.getLength(); + mStack.pop(); + appendValueLength(ctLength); + mStack.copy(); + + // 3. add content + final int partNum = body.getPartsNum(); + appendUintvarInteger(partNum); + for (int i = 0; i < partNum; i++) { + part = body.getPart(i); + mStack.newbuf(); // Leaving space for header lengh and data length + final PositionMarker attachment = mStack.mark(); + + mStack.newbuf(); // Leaving space for Content-Type length + final PositionMarker contentTypeBegin = mStack.mark(); + + final byte[] partContentType = part.getContentType(); + + if (partContentType == null) { + // content type is mandatory + return PDU_COMPOSE_CONTENT_ERROR; + } + + // content-type value + final Integer partContentTypeIdentifier = + mContentTypeMap.get(new String(partContentType)); + if (partContentTypeIdentifier == null) { + appendTextString(partContentType); + } else { + appendShortInteger(partContentTypeIdentifier.intValue()); + } + + /* Content-type parameter : name. + * The value of name, filename, content-location is the same. + * Just one of them is enough for this PDU. + */ + byte[] name = part.getName(); + + if (null == name) { + name = part.getFilename(); + + if (null == name) { + name = part.getContentLocation(); + + if (null == name) { + /* at lease one of name, filename, Content-location + * should be available. + */ + // I found that an mms received from tmomail.net will include a SMIL part + // that has no name. That would cause the code here to return + // PDU_COMPOSE_CONTENT_ERROR when a user tried to forward the message. The + // message would never send and the user would be stuck in a retry + // situation. Simply jam in any old name here to fix the problem. + name = "smil.xml".getBytes(); + } + } + } + appendOctet(PduPart.P_DEP_NAME); + appendTextString(name); + + // content-type parameter : charset + final int charset = part.getCharset(); + if (charset != 0) { + appendOctet(PduPart.P_CHARSET); + appendShortInteger(charset); + } + + final int contentTypeLength = contentTypeBegin.getLength(); + mStack.pop(); + appendValueLength(contentTypeLength); + mStack.copy(); + + // content id + final byte[] contentId = part.getContentId(); + + if (null != contentId) { + appendOctet(PduPart.P_CONTENT_ID); + if (('<' == contentId[0]) && ('>' == contentId[contentId.length - 1])) { + appendQuotedString(contentId); + } else { + appendQuotedString("<" + new String(contentId) + ">"); + } + } + + // content-location + final byte[] contentLocation = part.getContentLocation(); + if (null != contentLocation) { + appendOctet(PduPart.P_CONTENT_LOCATION); + appendTextString(contentLocation); + } + + // content + final int headerLength = attachment.getLength(); + + int dataLength = 0; // Just for safety... + final byte[] partData = part.getData(); + + if (partData != null) { + arraycopy(partData, 0, partData.length); + dataLength = partData.length; + } else { + InputStream cr = null; + try { + final byte[] buffer = new byte[PDU_COMPOSER_BLOCK_SIZE]; + cr = mResolver.openInputStream(part.getDataUri()); + int len = 0; + while ((len = cr.read(buffer)) != -1) { + mMessage.write(buffer, 0, len); + mPosition += len; + dataLength += len; + } + } catch (final FileNotFoundException e) { + return PDU_COMPOSE_CONTENT_ERROR; + } catch (final IOException e) { + return PDU_COMPOSE_CONTENT_ERROR; + } catch (final RuntimeException e) { + return PDU_COMPOSE_CONTENT_ERROR; + } finally { + if (cr != null) { + try { + cr.close(); + } catch (final IOException e) { + // Nothing to do + } + } + } + } + + if (dataLength != (attachment.getLength() - headerLength)) { + throw new RuntimeException("BUG: Length sanity check failed"); + } + + mStack.pop(); + appendUintvarInteger(headerLength); + appendUintvarInteger(dataLength); + mStack.copy(); + } + + return PDU_COMPOSE_SUCCESS; + } + + /** + * Record current message informations. + */ + private static class LengthRecordNode { + + ByteArrayOutputStream currentMessage = null; + + public int currentPosition = 0; + + public LengthRecordNode next = null; + } + + /** + * Mark current message position and stact size. + */ + private class PositionMarker { + + private int c_pos; // Current position + + private int currentStackSize; // Current stack size + + int getLength() { + // If these assert fails, likely that you are finding the + // size of buffer that is deep in BufferStack you can only + // find the length of the buffer that is on top + if (currentStackSize != mStack.stackSize) { + throw new RuntimeException("BUG: Invalid call to getLength()"); + } + + return mPosition - c_pos; + } + } + + /** + * This implementation can be OPTIMIZED to use only + * 2 buffers. This optimization involves changing BufferStack + * only... Its usage (interface) will not change. + */ + private class BufferStack { + + private LengthRecordNode stack = null; + + private LengthRecordNode toCopy = null; + + int stackSize = 0; + + /** + * Create a new message buffer and push it into the stack. + */ + void newbuf() { + // You can't create a new buff when toCopy != null + // That is after calling pop() and before calling copy() + // If you do, it is a bug + if (toCopy != null) { + throw new RuntimeException("BUG: Invalid newbuf() before copy()"); + } + + final LengthRecordNode temp = new LengthRecordNode(); + + temp.currentMessage = mMessage; + temp.currentPosition = mPosition; + + temp.next = stack; + stack = temp; + + stackSize = stackSize + 1; + + mMessage = new ByteArrayOutputStream(); + mPosition = 0; + } + + /** + * Pop the message before and record current message in the stack. + */ + void pop() { + final ByteArrayOutputStream currentMessage = mMessage; + final int currentPosition = mPosition; + + mMessage = stack.currentMessage; + mPosition = stack.currentPosition; + + toCopy = stack; + // Re using the top element of the stack to avoid memory allocation + + stack = stack.next; + stackSize = stackSize - 1; + + toCopy.currentMessage = currentMessage; + toCopy.currentPosition = currentPosition; + } + + /** + * Append current message to the message before. + */ + void copy() { + arraycopy(toCopy.currentMessage.toByteArray(), 0, + toCopy.currentPosition); + + toCopy = null; + } + + /** + * Mark current message position + */ + PositionMarker mark() { + final PositionMarker m = new PositionMarker(); + + m.c_pos = mPosition; + m.currentStackSize = stackSize; + + return m; + } + } + + /** + * Check address type. + * + * @param address address string without the postfix stinng type, + * such as "/TYPE=PLMN", "/TYPE=IPv6" and "/TYPE=IPv4" + * @return PDU_PHONE_NUMBER_ADDRESS_TYPE if it is phone number, + * PDU_EMAIL_ADDRESS_TYPE if it is email address, + * PDU_IPV4_ADDRESS_TYPE if it is ipv4 address, + * PDU_IPV6_ADDRESS_TYPE if it is ipv6 address, + * PDU_UNKNOWN_ADDRESS_TYPE if it is unknown. + */ + protected static int checkAddressType(final String address) { + /** + * From OMA-TS-MMS-ENC-V1_3-20050927-C.pdf, section 8. + * address = ( e-mail / device-address / alphanum-shortcode / num-shortcode) + * e-mail = mailbox; to the definition of mailbox as described in + * section 3.4 of [RFC2822], but excluding the + * obsolete definitions as indicated by the "obs-" prefix. + * device-address = ( global-phone-number "/TYPE=PLMN" ) + * / ( ipv4 "/TYPE=IPv4" ) / ( ipv6 "/TYPE=IPv6" ) + * / ( escaped-value "/TYPE=" address-type ) + * + * global-phone-number = ["+"] 1*( DIGIT / written-sep ) + * written-sep =("-"/".") + * + * ipv4 = 1*3DIGIT 3( "." 1*3DIGIT ) ; IPv4 address value + * + * ipv6 = 4HEXDIG 7( ":" 4HEXDIG ) ; IPv6 address per RFC 2373 + */ + + if (null == address) { + return PDU_UNKNOWN_ADDRESS_TYPE; + } + + if (address.matches(REGEXP_IPV4_ADDRESS_TYPE)) { + // Ipv4 address. + return PDU_IPV4_ADDRESS_TYPE; + } else if (address.matches(REGEXP_PHONE_NUMBER_ADDRESS_TYPE)) { + // Phone number. + return PDU_PHONE_NUMBER_ADDRESS_TYPE; + } else if (address.matches(REGEXP_EMAIL_ADDRESS_TYPE)) { + // Email address. + return PDU_EMAIL_ADDRESS_TYPE; + } else if (address.matches(REGEXP_IPV6_ADDRESS_TYPE)) { + // Ipv6 address. + return PDU_IPV6_ADDRESS_TYPE; + } else { + // Unknown address. + return PDU_UNKNOWN_ADDRESS_TYPE; + } + } +} diff --git a/src/com/android/messaging/mmslib/pdu/PduContentTypes.java b/src/com/android/messaging/mmslib/pdu/PduContentTypes.java new file mode 100644 index 0000000..f2cebf1 --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/PduContentTypes.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +public class PduContentTypes { + /** + * All content types. From: + * http://www.openmobilealliance.org/tech/omna/omna-wsp-content-type.htm + */ + static final String[] contentTypes = { + "*/*", /* 0x00 */ + "text/*", /* 0x01 */ + "text/html", /* 0x02 */ + "text/plain", /* 0x03 */ + "text/x-hdml", /* 0x04 */ + "text/x-ttml", /* 0x05 */ + "text/x-vCalendar", /* 0x06 */ + "text/x-vCard", /* 0x07 */ + "text/vnd.wap.wml", /* 0x08 */ + "text/vnd.wap.wmlscript", /* 0x09 */ + "text/vnd.wap.wta-event", /* 0x0A */ + "multipart/*", /* 0x0B */ + "multipart/mixed", /* 0x0C */ + "multipart/form-data", /* 0x0D */ + "multipart/byterantes", /* 0x0E */ + "multipart/alternative", /* 0x0F */ + "application/*", /* 0x10 */ + "application/java-vm", /* 0x11 */ + "application/x-www-form-urlencoded", /* 0x12 */ + "application/x-hdmlc", /* 0x13 */ + "application/vnd.wap.wmlc", /* 0x14 */ + "application/vnd.wap.wmlscriptc", /* 0x15 */ + "application/vnd.wap.wta-eventc", /* 0x16 */ + "application/vnd.wap.uaprof", /* 0x17 */ + "application/vnd.wap.wtls-ca-certificate", /* 0x18 */ + "application/vnd.wap.wtls-user-certificate", /* 0x19 */ + "application/x-x509-ca-cert", /* 0x1A */ + "application/x-x509-user-cert", /* 0x1B */ + "image/*", /* 0x1C */ + "image/gif", /* 0x1D */ + "image/jpeg", /* 0x1E */ + "image/tiff", /* 0x1F */ + "image/png", /* 0x20 */ + "image/vnd.wap.wbmp", /* 0x21 */ + "application/vnd.wap.multipart.*", /* 0x22 */ + "application/vnd.wap.multipart.mixed", /* 0x23 */ + "application/vnd.wap.multipart.form-data", /* 0x24 */ + "application/vnd.wap.multipart.byteranges", /* 0x25 */ + "application/vnd.wap.multipart.alternative", /* 0x26 */ + "application/xml", /* 0x27 */ + "text/xml", /* 0x28 */ + "application/vnd.wap.wbxml", /* 0x29 */ + "application/x-x968-cross-cert", /* 0x2A */ + "application/x-x968-ca-cert", /* 0x2B */ + "application/x-x968-user-cert", /* 0x2C */ + "text/vnd.wap.si", /* 0x2D */ + "application/vnd.wap.sic", /* 0x2E */ + "text/vnd.wap.sl", /* 0x2F */ + "application/vnd.wap.slc", /* 0x30 */ + "text/vnd.wap.co", /* 0x31 */ + "application/vnd.wap.coc", /* 0x32 */ + "application/vnd.wap.multipart.related", /* 0x33 */ + "application/vnd.wap.sia", /* 0x34 */ + "text/vnd.wap.connectivity-xml", /* 0x35 */ + "application/vnd.wap.connectivity-wbxml", /* 0x36 */ + "application/pkcs7-mime", /* 0x37 */ + "application/vnd.wap.hashed-certificate", /* 0x38 */ + "application/vnd.wap.signed-certificate", /* 0x39 */ + "application/vnd.wap.cert-response", /* 0x3A */ + "application/xhtml+xml", /* 0x3B */ + "application/wml+xml", /* 0x3C */ + "text/css", /* 0x3D */ + "application/vnd.wap.mms-message", /* 0x3E */ + "application/vnd.wap.rollover-certificate", /* 0x3F */ + "application/vnd.wap.locc+wbxml", /* 0x40 */ + "application/vnd.wap.loc+xml", /* 0x41 */ + "application/vnd.syncml.dm+wbxml", /* 0x42 */ + "application/vnd.syncml.dm+xml", /* 0x43 */ + "application/vnd.syncml.notification", /* 0x44 */ + "application/vnd.wap.xhtml+xml", /* 0x45 */ + "application/vnd.wv.csp.cir", /* 0x46 */ + "application/vnd.oma.dd+xml", /* 0x47 */ + "application/vnd.oma.drm.message", /* 0x48 */ + "application/vnd.oma.drm.content", /* 0x49 */ + "application/vnd.oma.drm.rights+xml", /* 0x4A */ + "application/vnd.oma.drm.rights+wbxml", /* 0x4B */ + "application/vnd.wv.csp+xml", /* 0x4C */ + "application/vnd.wv.csp+wbxml", /* 0x4D */ + "application/vnd.syncml.ds.notification", /* 0x4E */ + "audio/*", /* 0x4F */ + "video/*", /* 0x50 */ + "application/vnd.oma.dd2+xml", /* 0x51 */ + "application/mikey" /* 0x52 */ + }; +} diff --git a/src/com/android/messaging/mmslib/pdu/PduHeaders.java b/src/com/android/messaging/mmslib/pdu/PduHeaders.java new file mode 100644 index 0000000..96d18ee --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/PduHeaders.java @@ -0,0 +1,743 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import android.util.SparseArray; + +import com.android.messaging.mmslib.InvalidHeaderValueException; + +import java.util.ArrayList; + +public class PduHeaders { + /** + * All pdu header fields. + */ + public static final int BCC = 0x81; + public static final int CC = 0x82; + public static final int CONTENT_LOCATION = 0x83; + public static final int CONTENT_TYPE = 0x84; + public static final int DATE = 0x85; + public static final int DELIVERY_REPORT = 0x86; + public static final int DELIVERY_TIME = 0x87; + public static final int EXPIRY = 0x88; + public static final int FROM = 0x89; + public static final int MESSAGE_CLASS = 0x8A; + public static final int MESSAGE_ID = 0x8B; + public static final int MESSAGE_TYPE = 0x8C; + public static final int MMS_VERSION = 0x8D; + public static final int MESSAGE_SIZE = 0x8E; + public static final int PRIORITY = 0x8F; + + public static final int READ_REPLY = 0x90; + public static final int READ_REPORT = 0x90; + public static final int REPORT_ALLOWED = 0x91; + public static final int RESPONSE_STATUS = 0x92; + public static final int RESPONSE_TEXT = 0x93; + public static final int SENDER_VISIBILITY = 0x94; + public static final int STATUS = 0x95; + public static final int SUBJECT = 0x96; + public static final int TO = 0x97; + public static final int TRANSACTION_ID = 0x98; + public static final int RETRIEVE_STATUS = 0x99; + public static final int RETRIEVE_TEXT = 0x9A; + public static final int READ_STATUS = 0x9B; + public static final int REPLY_CHARGING = 0x9C; + public static final int REPLY_CHARGING_DEADLINE = 0x9D; + public static final int REPLY_CHARGING_ID = 0x9E; + public static final int REPLY_CHARGING_SIZE = 0x9F; + + public static final int PREVIOUSLY_SENT_BY = 0xA0; + public static final int PREVIOUSLY_SENT_DATE = 0xA1; + public static final int STORE = 0xA2; + public static final int MM_STATE = 0xA3; + public static final int MM_FLAGS = 0xA4; + public static final int STORE_STATUS = 0xA5; + public static final int STORE_STATUS_TEXT = 0xA6; + public static final int STORED = 0xA7; + public static final int ATTRIBUTES = 0xA8; + public static final int TOTALS = 0xA9; + public static final int MBOX_TOTALS = 0xAA; + public static final int QUOTAS = 0xAB; + public static final int MBOX_QUOTAS = 0xAC; + public static final int MESSAGE_COUNT = 0xAD; + public static final int CONTENT = 0xAE; + public static final int START = 0xAF; + + public static final int ADDITIONAL_HEADERS = 0xB0; + public static final int DISTRIBUTION_INDICATOR = 0xB1; + public static final int ELEMENT_DESCRIPTOR = 0xB2; + public static final int LIMIT = 0xB3; + public static final int RECOMMENDED_RETRIEVAL_MODE = 0xB4; + public static final int RECOMMENDED_RETRIEVAL_MODE_TEXT = 0xB5; + public static final int STATUS_TEXT = 0xB6; + public static final int APPLIC_ID = 0xB7; + public static final int REPLY_APPLIC_ID = 0xB8; + public static final int AUX_APPLIC_ID = 0xB9; + public static final int CONTENT_CLASS = 0xBA; + public static final int DRM_CONTENT = 0xBB; + public static final int ADAPTATION_ALLOWED = 0xBC; + public static final int REPLACE_ID = 0xBD; + public static final int CANCEL_ID = 0xBE; + public static final int CANCEL_STATUS = 0xBF; + + /** + * X-Mms-Message-Type field types. + */ + public static final int MESSAGE_TYPE_SEND_REQ = 0x80; + public static final int MESSAGE_TYPE_SEND_CONF = 0x81; + public static final int MESSAGE_TYPE_NOTIFICATION_IND = 0x82; + public static final int MESSAGE_TYPE_NOTIFYRESP_IND = 0x83; + public static final int MESSAGE_TYPE_RETRIEVE_CONF = 0x84; + public static final int MESSAGE_TYPE_ACKNOWLEDGE_IND = 0x85; + public static final int MESSAGE_TYPE_DELIVERY_IND = 0x86; + public static final int MESSAGE_TYPE_READ_REC_IND = 0x87; + public static final int MESSAGE_TYPE_READ_ORIG_IND = 0x88; + public static final int MESSAGE_TYPE_FORWARD_REQ = 0x89; + public static final int MESSAGE_TYPE_FORWARD_CONF = 0x8A; + public static final int MESSAGE_TYPE_MBOX_STORE_REQ = 0x8B; + public static final int MESSAGE_TYPE_MBOX_STORE_CONF = 0x8C; + public static final int MESSAGE_TYPE_MBOX_VIEW_REQ = 0x8D; + public static final int MESSAGE_TYPE_MBOX_VIEW_CONF = 0x8E; + public static final int MESSAGE_TYPE_MBOX_UPLOAD_REQ = 0x8F; + public static final int MESSAGE_TYPE_MBOX_UPLOAD_CONF = 0x90; + public static final int MESSAGE_TYPE_MBOX_DELETE_REQ = 0x91; + public static final int MESSAGE_TYPE_MBOX_DELETE_CONF = 0x92; + public static final int MESSAGE_TYPE_MBOX_DESCR = 0x93; + public static final int MESSAGE_TYPE_DELETE_REQ = 0x94; + public static final int MESSAGE_TYPE_DELETE_CONF = 0x95; + public static final int MESSAGE_TYPE_CANCEL_REQ = 0x96; + public static final int MESSAGE_TYPE_CANCEL_CONF = 0x97; + + /** + * X-Mms-Delivery-Report | + * X-Mms-Read-Report | + * X-Mms-Report-Allowed | + * X-Mms-Sender-Visibility | + * X-Mms-Store | + * X-Mms-Stored | + * X-Mms-Totals | + * X-Mms-Quotas | + * X-Mms-Distribution-Indicator | + * X-Mms-DRM-Content | + * X-Mms-Adaptation-Allowed | + * field types. + */ + public static final int VALUE_YES = 0x80; + public static final int VALUE_NO = 0x81; + + /** + * Delivery-Time | + * Expiry and Reply-Charging-Deadline | + * field type components. + */ + public static final int VALUE_ABSOLUTE_TOKEN = 0x80; + public static final int VALUE_RELATIVE_TOKEN = 0x81; + + /** + * X-Mms-MMS-Version field types. + */ + public static final int MMS_VERSION_1_3 = ((1 << 4) | 3); + public static final int MMS_VERSION_1_2 = ((1 << 4) | 2); + public static final int MMS_VERSION_1_1 = ((1 << 4) | 1); + public static final int MMS_VERSION_1_0 = ((1 << 4) | 0); + + // Current version is 1.2. + public static final int CURRENT_MMS_VERSION = MMS_VERSION_1_2; + + /** + * From field type components. + */ + public static final int FROM_ADDRESS_PRESENT_TOKEN = 0x80; + public static final int FROM_INSERT_ADDRESS_TOKEN = 0x81; + + public static final String FROM_ADDRESS_PRESENT_TOKEN_STR = "address-present-token"; + + public static final String FROM_INSERT_ADDRESS_TOKEN_STR = "insert-address-token"; + + /** + * X-Mms-Status Field. + */ + public static final int STATUS_EXPIRED = 0x80; + public static final int STATUS_RETRIEVED = 0x81; + public static final int STATUS_REJECTED = 0x82; + public static final int STATUS_DEFERRED = 0x83; + public static final int STATUS_UNRECOGNIZED = 0x84; + public static final int STATUS_INDETERMINATE = 0x85; + public static final int STATUS_FORWARDED = 0x86; + public static final int STATUS_UNREACHABLE = 0x87; + + /** + * MM-Flags field type components. + */ + public static final int MM_FLAGS_ADD_TOKEN = 0x80; + public static final int MM_FLAGS_REMOVE_TOKEN = 0x81; + public static final int MM_FLAGS_FILTER_TOKEN = 0x82; + + /** + * X-Mms-Message-Class field types. + */ + public static final int MESSAGE_CLASS_PERSONAL = 0x80; + public static final int MESSAGE_CLASS_ADVERTISEMENT = 0x81; + public static final int MESSAGE_CLASS_INFORMATIONAL = 0x82; + public static final int MESSAGE_CLASS_AUTO = 0x83; + + public static final String MESSAGE_CLASS_PERSONAL_STR = "personal"; + + public static final String MESSAGE_CLASS_ADVERTISEMENT_STR = "advertisement"; + + public static final String MESSAGE_CLASS_INFORMATIONAL_STR = "informational"; + + public static final String MESSAGE_CLASS_AUTO_STR = "auto"; + + /** + * X-Mms-Priority field types. + */ + public static final int PRIORITY_LOW = 0x80; + public static final int PRIORITY_NORMAL = 0x81; + public static final int PRIORITY_HIGH = 0x82; + + /** + * X-Mms-Response-Status field types. + */ + public static final int RESPONSE_STATUS_OK = 0x80; + public static final int RESPONSE_STATUS_ERROR_UNSPECIFIED = 0x81; + public static final int RESPONSE_STATUS_ERROR_SERVICE_DENIED = 0x82; + + public static final int RESPONSE_STATUS_ERROR_MESSAGE_FORMAT_CORRUPT = 0x83; + public static final int RESPONSE_STATUS_ERROR_SENDING_ADDRESS_UNRESOLVED = 0x84; + + public static final int RESPONSE_STATUS_ERROR_MESSAGE_NOT_FOUND = 0x85; + public static final int RESPONSE_STATUS_ERROR_NETWORK_PROBLEM = 0x86; + public static final int RESPONSE_STATUS_ERROR_CONTENT_NOT_ACCEPTED = 0x87; + public static final int RESPONSE_STATUS_ERROR_UNSUPPORTED_MESSAGE = 0x88; + public static final int RESPONSE_STATUS_ERROR_TRANSIENT_FAILURE = 0xC0; + + public static final int RESPONSE_STATUS_ERROR_TRANSIENT_SENDNG_ADDRESS_UNRESOLVED = 0xC1; + public static final int RESPONSE_STATUS_ERROR_TRANSIENT_MESSAGE_NOT_FOUND = 0xC2; + public static final int RESPONSE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM = 0xC3; + public static final int RESPONSE_STATUS_ERROR_TRANSIENT_PARTIAL_SUCCESS = 0xC4; + + public static final int RESPONSE_STATUS_ERROR_PERMANENT_FAILURE + = 0xE0; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_SERVICE_DENIED + = 0xE1; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_MESSAGE_FORMAT_CORRUPT + = 0xE2; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_SENDING_ADDRESS_UNRESOLVED + = 0xE3; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_MESSAGE_NOT_FOUND + = 0xE4; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_CONTENT_NOT_ACCEPTED + = 0xE5; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_LIMITATIONS_NOT_MET + = 0xE6; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_REQUEST_NOT_ACCEPTED + = 0xE7; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_FORWARDING_DENIED + = 0xE8; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_NOT_SUPPORTED + = 0xE9; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_ADDRESS_HIDING_NOT_SUPPORTED + = 0xEA; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_LACK_OF_PREPAID + = 0xEB; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_END + = 0xFF; + + /** + * X-Mms-Retrieve-Status field types. + */ + public static final int RETRIEVE_STATUS_OK = 0x80; + public static final int RETRIEVE_STATUS_ERROR_TRANSIENT_FAILURE = 0xC0; + public static final int RETRIEVE_STATUS_ERROR_TRANSIENT_MESSAGE_NOT_FOUND = 0xC1; + public static final int RETRIEVE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM = 0xC2; + public static final int RETRIEVE_STATUS_ERROR_PERMANENT_FAILURE = 0xE0; + public static final int RETRIEVE_STATUS_ERROR_PERMANENT_SERVICE_DENIED = 0xE1; + public static final int RETRIEVE_STATUS_ERROR_PERMANENT_MESSAGE_NOT_FOUND = 0xE2; + public static final int RETRIEVE_STATUS_ERROR_PERMANENT_CONTENT_UNSUPPORTED = 0xE3; + public static final int RETRIEVE_STATUS_ERROR_END = 0xFF; + + /** + * X-Mms-Sender-Visibility field types. + */ + public static final int SENDER_VISIBILITY_HIDE = 0x80; + public static final int SENDER_VISIBILITY_SHOW = 0x81; + + /** + * X-Mms-Read-Status field types. + */ + public static final int READ_STATUS_READ = 0x80; + public static final int READ_STATUS__DELETED_WITHOUT_BEING_READ = 0x81; + + /** + * X-Mms-Cancel-Status field types. + */ + public static final int CANCEL_STATUS_REQUEST_SUCCESSFULLY_RECEIVED = 0x80; + public static final int CANCEL_STATUS_REQUEST_CORRUPTED = 0x81; + + /** + * X-Mms-Reply-Charging field types. + */ + public static final int REPLY_CHARGING_REQUESTED = 0x80; + public static final int REPLY_CHARGING_REQUESTED_TEXT_ONLY = 0x81; + public static final int REPLY_CHARGING_ACCEPTED = 0x82; + public static final int REPLY_CHARGING_ACCEPTED_TEXT_ONLY = 0x83; + + /** + * X-Mms-MM-State field types. + */ + public static final int MM_STATE_DRAFT = 0x80; + public static final int MM_STATE_SENT = 0x81; + public static final int MM_STATE_NEW = 0x82; + public static final int MM_STATE_RETRIEVED = 0x83; + public static final int MM_STATE_FORWARDED = 0x84; + + /** + * X-Mms-Recommended-Retrieval-Mode field types. + */ + public static final int RECOMMENDED_RETRIEVAL_MODE_MANUAL = 0x80; + + /** + * X-Mms-Content-Class field types. + */ + public static final int CONTENT_CLASS_TEXT = 0x80; + public static final int CONTENT_CLASS_IMAGE_BASIC = 0x81; + public static final int CONTENT_CLASS_IMAGE_RICH = 0x82; + public static final int CONTENT_CLASS_VIDEO_BASIC = 0x83; + public static final int CONTENT_CLASS_VIDEO_RICH = 0x84; + public static final int CONTENT_CLASS_MEGAPIXEL = 0x85; + public static final int CONTENT_CLASS_CONTENT_BASIC = 0x86; + public static final int CONTENT_CLASS_CONTENT_RICH = 0x87; + + /** + * X-Mms-Store-Status field types. + */ + public static final int STORE_STATUS_SUCCESS = 0x80; + public static final int STORE_STATUS_ERROR_TRANSIENT_FAILURE = 0xC0; + public static final int STORE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM = 0xC1; + public static final int STORE_STATUS_ERROR_PERMANENT_FAILURE = 0xE0; + public static final int STORE_STATUS_ERROR_PERMANENT_SERVICE_DENIED = 0xE1; + public static final int STORE_STATUS_ERROR_PERMANENT_MESSAGE_FORMAT_CORRUPT = 0xE2; + public static final int STORE_STATUS_ERROR_PERMANENT_MESSAGE_NOT_FOUND = 0xE3; + public static final int STORE_STATUS_ERROR_PERMANENT_MMBOX_FULL = 0xE4; + public static final int STORE_STATUS_ERROR_END = 0xFF; + + /** + * The map contains the value of all headers. + */ + private SparseArray mHeaderMap = null; + + /** + * Constructor of PduHeaders. + */ + public PduHeaders() { + mHeaderMap = new SparseArray(); + } + + /** + * Get octet value by header field. + * + * @param field the field + * @return the octet value of the pdu header + * with specified header field. Return 0 if + * the value is not set. + */ + protected int getOctet(int field) { + Integer octet = (Integer) mHeaderMap.get(field); + if (null == octet) { + return 0; + } + + return octet; + } + + /** + * Set octet value to pdu header by header field. + * + * @param value the value + * @param field the field + * @throws InvalidHeaderValueException if the value is invalid. + */ + protected void setOctet(int value, int field) + throws InvalidHeaderValueException { + /** + * Check whether this field can be set for specific + * header and check validity of the field. + */ + switch (field) { + case REPORT_ALLOWED: + case ADAPTATION_ALLOWED: + case DELIVERY_REPORT: + case DRM_CONTENT: + case DISTRIBUTION_INDICATOR: + case QUOTAS: + case READ_REPORT: + case STORE: + case STORED: + case TOTALS: + case SENDER_VISIBILITY: + if ((VALUE_YES != value) && (VALUE_NO != value)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case READ_STATUS: + if ((READ_STATUS_READ != value) && + (READ_STATUS__DELETED_WITHOUT_BEING_READ != value)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case CANCEL_STATUS: + if ((CANCEL_STATUS_REQUEST_SUCCESSFULLY_RECEIVED != value) && + (CANCEL_STATUS_REQUEST_CORRUPTED != value)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case PRIORITY: + if ((value < PRIORITY_LOW) || (value > PRIORITY_HIGH)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case STATUS: + if ((value < STATUS_EXPIRED) || (value > STATUS_UNREACHABLE)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case REPLY_CHARGING: + if ((value < REPLY_CHARGING_REQUESTED) + || (value > REPLY_CHARGING_ACCEPTED_TEXT_ONLY)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case MM_STATE: + if ((value < MM_STATE_DRAFT) || (value > MM_STATE_FORWARDED)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case RECOMMENDED_RETRIEVAL_MODE: + if (RECOMMENDED_RETRIEVAL_MODE_MANUAL != value) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case CONTENT_CLASS: + if ((value < CONTENT_CLASS_TEXT) + || (value > CONTENT_CLASS_CONTENT_RICH)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case RETRIEVE_STATUS: + // According to oma-ts-mms-enc-v1_3, section 7.3.50, we modify the invalid value. + if ((value > RETRIEVE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM) && + (value < RETRIEVE_STATUS_ERROR_PERMANENT_FAILURE)) { + value = RETRIEVE_STATUS_ERROR_TRANSIENT_FAILURE; + } else if ((value > RETRIEVE_STATUS_ERROR_PERMANENT_CONTENT_UNSUPPORTED) && + (value <= RETRIEVE_STATUS_ERROR_END)) { + value = RETRIEVE_STATUS_ERROR_PERMANENT_FAILURE; + } else if ((value < RETRIEVE_STATUS_OK) || + ((value > RETRIEVE_STATUS_OK) && + (value < RETRIEVE_STATUS_ERROR_TRANSIENT_FAILURE)) || + (value > RETRIEVE_STATUS_ERROR_END)) { + value = RETRIEVE_STATUS_ERROR_PERMANENT_FAILURE; + } + break; + case STORE_STATUS: + // According to oma-ts-mms-enc-v1_3, section 7.3.58, we modify the invalid value. + if ((value > STORE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM) && + (value < STORE_STATUS_ERROR_PERMANENT_FAILURE)) { + value = STORE_STATUS_ERROR_TRANSIENT_FAILURE; + } else if ((value > STORE_STATUS_ERROR_PERMANENT_MMBOX_FULL) && + (value <= STORE_STATUS_ERROR_END)) { + value = STORE_STATUS_ERROR_PERMANENT_FAILURE; + } else if ((value < STORE_STATUS_SUCCESS) || + ((value > STORE_STATUS_SUCCESS) && + (value < STORE_STATUS_ERROR_TRANSIENT_FAILURE)) || + (value > STORE_STATUS_ERROR_END)) { + value = STORE_STATUS_ERROR_PERMANENT_FAILURE; + } + break; + case RESPONSE_STATUS: + // According to oma-ts-mms-enc-v1_3, section 7.3.48, we modify the invalid value. + if ((value > RESPONSE_STATUS_ERROR_TRANSIENT_PARTIAL_SUCCESS) && + (value < RESPONSE_STATUS_ERROR_PERMANENT_FAILURE)) { + value = RESPONSE_STATUS_ERROR_TRANSIENT_FAILURE; + } else if (((value > RESPONSE_STATUS_ERROR_PERMANENT_LACK_OF_PREPAID) && + (value <= RESPONSE_STATUS_ERROR_PERMANENT_END)) || + (value < RESPONSE_STATUS_OK) || + ((value > RESPONSE_STATUS_ERROR_UNSUPPORTED_MESSAGE) && + (value < RESPONSE_STATUS_ERROR_TRANSIENT_FAILURE)) || + (value > RESPONSE_STATUS_ERROR_PERMANENT_END)) { + value = RESPONSE_STATUS_ERROR_PERMANENT_FAILURE; + } + break; + case MMS_VERSION: + if ((value < MMS_VERSION_1_0) || (value > MMS_VERSION_1_3)) { + value = CURRENT_MMS_VERSION; // Current version is the default value. + } + break; + case MESSAGE_TYPE: + if ((value < MESSAGE_TYPE_SEND_REQ) || (value > MESSAGE_TYPE_CANCEL_CONF)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + default: + // This header value should not be Octect. + throw new RuntimeException("Invalid header field!"); + } + mHeaderMap.put(field, value); + } + + /** + * Get TextString value by header field. + * + * @param field the field + * @return the TextString value of the pdu header + * with specified header field + */ + protected byte[] getTextString(int field) { + return (byte[]) mHeaderMap.get(field); + } + + /** + * Set TextString value to pdu header by header field. + * + * @param value the value + * @param field the field + * @return the TextString value of the pdu header + * with specified header field + * @throws NullPointerException if the value is null. + */ + protected void setTextString(byte[] value, int field) { + /** + * Check whether this field can be set for specific + * header and check validity of the field. + */ + if (null == value) { + throw new NullPointerException(); + } + + switch (field) { + case TRANSACTION_ID: + case REPLY_CHARGING_ID: + case AUX_APPLIC_ID: + case APPLIC_ID: + case REPLY_APPLIC_ID: + case MESSAGE_ID: + case REPLACE_ID: + case CANCEL_ID: + case CONTENT_LOCATION: + case MESSAGE_CLASS: + case CONTENT_TYPE: + break; + default: + // This header value should not be Text-String. + throw new RuntimeException("Invalid header field!"); + } + mHeaderMap.put(field, value); + } + + /** + * Get EncodedStringValue value by header field. + * + * @param field the field + * @return the EncodedStringValue value of the pdu header + * with specified header field + */ + protected EncodedStringValue getEncodedStringValue(int field) { + return (EncodedStringValue) mHeaderMap.get(field); + } + + /** + * Get TO, CC or BCC header value. + * + * @param field the field + * @return the EncodeStringValue array of the pdu header + * with specified header field + */ + protected EncodedStringValue[] getEncodedStringValues(int field) { + ArrayList list = + (ArrayList) mHeaderMap.get(field); + if (null == list) { + return null; + } + EncodedStringValue[] values = new EncodedStringValue[list.size()]; + return list.toArray(values); + } + + /** + * Set EncodedStringValue value to pdu header by header field. + * + * @param value the value + * @param field the field + * @return the EncodedStringValue value of the pdu header + * with specified header field + * @throws NullPointerException if the value is null. + */ + protected void setEncodedStringValue(EncodedStringValue value, int field) { + /** + * Check whether this field can be set for specific + * header and check validity of the field. + */ + if (null == value) { + throw new NullPointerException(); + } + + switch (field) { + case SUBJECT: + case RECOMMENDED_RETRIEVAL_MODE_TEXT: + case RETRIEVE_TEXT: + case STATUS_TEXT: + case STORE_STATUS_TEXT: + case RESPONSE_TEXT: + case FROM: + case PREVIOUSLY_SENT_BY: + case MM_FLAGS: + break; + default: + // This header value should not be Encoded-String-Value. + throw new RuntimeException("Invalid header field!"); + } + + mHeaderMap.put(field, value); + } + + /** + * Set TO, CC or BCC header value. + * + * @param value the value + * @param field the field + * @return the EncodedStringValue value array of the pdu header + * with specified header field + * @throws NullPointerException if the value is null. + */ + protected void setEncodedStringValues(EncodedStringValue[] value, int field) { + /** + * Check whether this field can be set for specific + * header and check validity of the field. + */ + if (null == value) { + throw new NullPointerException(); + } + + switch (field) { + case BCC: + case CC: + case TO: + break; + default: + // This header value should not be Encoded-String-Value. + throw new RuntimeException("Invalid header field!"); + } + + ArrayList list = new ArrayList(); + for (int i = 0; i < value.length; i++) { + list.add(value[i]); + } + mHeaderMap.put(field, list); + } + + /** + * Append one EncodedStringValue to another. + * + * @param value the EncodedStringValue to append + * @param field the field + * @throws NullPointerException if the value is null. + */ + protected void appendEncodedStringValue(EncodedStringValue value, + int field) { + if (null == value) { + throw new NullPointerException(); + } + + switch (field) { + case BCC: + case CC: + case TO: + break; + default: + throw new RuntimeException("Invalid header field!"); + } + + ArrayList list = + (ArrayList) mHeaderMap.get(field); + if (null == list) { + list = new ArrayList(); + } + list.add(value); + mHeaderMap.put(field, list); + } + + /** + * Get LongInteger value by header field. + * + * @param field the field + * @return the LongInteger value of the pdu header + * with specified header field. if return -1, the + * field is not existed in pdu header. + */ + protected long getLongInteger(int field) { + Long longInteger = (Long) mHeaderMap.get(field); + if (null == longInteger) { + return -1; + } + + return longInteger.longValue(); + } + + /** + * Set LongInteger value to pdu header by header field. + * + * @param value the value + * @param field the field + */ + protected void setLongInteger(long value, int field) { + /** + * Check whether this field can be set for specific + * header and check validity of the field. + */ + switch (field) { + case DATE: + case REPLY_CHARGING_SIZE: + case MESSAGE_SIZE: + case MESSAGE_COUNT: + case START: + case LIMIT: + case DELIVERY_TIME: + case EXPIRY: + case REPLY_CHARGING_DEADLINE: + case PREVIOUSLY_SENT_DATE: + break; + default: + // This header value should not be LongInteger. + throw new RuntimeException("Invalid header field!"); + } + mHeaderMap.put(field, value); + } + + public boolean hasHeader(int field) { + return mHeaderMap.get(field, null) != null; + } +} diff --git a/src/com/android/messaging/mmslib/pdu/PduParser.java b/src/com/android/messaging/mmslib/pdu/PduParser.java new file mode 100755 index 0000000..e392fb5 --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/PduParser.java @@ -0,0 +1,2044 @@ +/* + * Copyright (C) 2007-2008 Esmertec AG. + * Copyright (C) 2007-2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import android.util.Log; +import android.util.SparseArray; + +import com.android.messaging.mmslib.InvalidHeaderValueException; +import com.android.messaging.util.ContentType; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.UnsupportedEncodingException; +import java.util.Arrays; + +public class PduParser { + /** + * The log tag. + */ + private static final String LOG_TAG = "PduParser"; + + private static final boolean LOCAL_LOGV = false; + + /** + * The next are WAP values defined in WSP specification. + */ + private static final int QUOTE = 127; + + private static final int LENGTH_QUOTE = 31; + + private static final int TEXT_MIN = 32; + + private static final int TEXT_MAX = 127; + + private static final int SHORT_INTEGER_MAX = 127; + + private static final int SHORT_LENGTH_MAX = 30; + + private static final int LONG_INTEGER_LENGTH_MAX = 8; + + private static final int QUOTED_STRING_FLAG = 34; + + private static final int END_STRING_FLAG = 0x00; + + //The next two are used by the interface "parseWapString" to + //distinguish Text-String and Quoted-String. + private static final int TYPE_TEXT_STRING = 0; + + private static final int TYPE_QUOTED_STRING = 1; + + private static final int TYPE_TOKEN_STRING = 2; + + /** + * Specify the part position. + */ + private static final int THE_FIRST_PART = 0; + + private static final int THE_LAST_PART = 1; + + /** + * The pdu data. + */ + private ByteArrayInputStream mPduDataStream = null; + + /** + * Store pdu headers + */ + private PduHeaders mHeaders = null; + + /** + * Store pdu parts. + */ + private PduBody mBody = null; + + /** + * Store the "type" parameter in "Content-Type" header field. + */ + private static byte[] mTypeParam = null; + + /** + * Store the "start" parameter in "Content-Type" header field. + */ + private static byte[] mStartParam = null; + + /** + * Whether to parse content-disposition part header + */ + private final boolean mParseContentDisposition; + + /** + * Constructor. + * + * @param pduDataStream pdu data to be parsed + * @param parseContentDisposition whether to parse the Content-Disposition part header + */ + public PduParser(byte[] pduDataStream, boolean parseContentDisposition) { + mPduDataStream = new ByteArrayInputStream(pduDataStream); + mParseContentDisposition = parseContentDisposition; + } + + /** + * Parse the pdu. + * + * @return the pdu structure if parsing successfully. + * null if parsing error happened or mandatory fields are not set. + */ + public GenericPdu parse() { + if (mPduDataStream == null) { + return null; + } + + /* parse headers */ + mHeaders = parseHeaders(mPduDataStream); + if (null == mHeaders) { + // Parse headers failed. + return null; + } + + /* get the message type */ + int messageType = mHeaders.getOctet(PduHeaders.MESSAGE_TYPE); + + /* check mandatory header fields */ + if (false == checkMandatoryHeader(mHeaders)) { + log("check mandatory headers failed!"); + return null; + } + /* + * Get retrieve status. If the header is not there, assuming it is OK status. + * Some carriers may choose to not send this header. + */ + int retrieveStatus = mHeaders.hasHeader(PduHeaders.RETRIEVE_STATUS) ? + mHeaders.getOctet(PduHeaders.RETRIEVE_STATUS) : PduHeaders.RETRIEVE_STATUS_OK; + + if ((PduHeaders.MESSAGE_TYPE_SEND_REQ == messageType) || + (PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF == messageType && + retrieveStatus == PduHeaders.RETRIEVE_STATUS_OK)) { + /* need to parse the parts */ + mBody = parseParts(mPduDataStream); + if (null == mBody) { + // Parse parts failed. + return null; + } + } + + switch (messageType) { + case PduHeaders.MESSAGE_TYPE_SEND_REQ: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parse: MESSAGE_TYPE_SEND_REQ"); + } + SendReq sendReq = new SendReq(mHeaders, mBody); + return sendReq; + case PduHeaders.MESSAGE_TYPE_SEND_CONF: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parse: MESSAGE_TYPE_SEND_CONF"); + } + SendConf sendConf = new SendConf(mHeaders); + return sendConf; + case PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parse: MESSAGE_TYPE_NOTIFICATION_IND"); + } + NotificationInd notificationInd = + new NotificationInd(mHeaders); + return notificationInd; + case PduHeaders.MESSAGE_TYPE_NOTIFYRESP_IND: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parse: MESSAGE_TYPE_NOTIFYRESP_IND"); + } + NotifyRespInd notifyRespInd = + new NotifyRespInd(mHeaders); + return notifyRespInd; + case PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parse: MESSAGE_TYPE_RETRIEVE_CONF"); + } + RetrieveConf retrieveConf = + new RetrieveConf(mHeaders, mBody); + if (retrieveStatus != PduHeaders.RETRIEVE_STATUS_OK) { + // For failure only no need to check content type + return retrieveConf; + } + byte[] contentType = retrieveConf.getContentType(); + if (null == contentType) { + return null; + } + String ctTypeStr = new String(contentType); + if (ctTypeStr.equals(ContentType.MMS_MULTIPART_MIXED) + || ctTypeStr.equals(ContentType.MMS_MULTIPART_RELATED) + || ctTypeStr.equals(ContentType.MMS_MULTIPART_ALTERNATIVE)) { + // The MMS content type must be "application/vnd.wap.multipart.mixed" + // or "application/vnd.wap.multipart.related" + // or "application/vnd.wap.multipart.alternative" + return retrieveConf; + } else if (ctTypeStr.equals(ContentType.MMS_MULTIPART_ALTERNATIVE)) { + // "application/vnd.wap.multipart.alternative" + // should take only the first part. + PduPart firstPart = mBody.getPart(0); + mBody.removeAll(); + mBody.addPart(0, firstPart); + return retrieveConf; + } + return null; + case PduHeaders.MESSAGE_TYPE_DELIVERY_IND: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parse: MESSAGE_TYPE_DELIVERY_IND"); + } + DeliveryInd deliveryInd = + new DeliveryInd(mHeaders); + return deliveryInd; + case PduHeaders.MESSAGE_TYPE_ACKNOWLEDGE_IND: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parse: MESSAGE_TYPE_ACKNOWLEDGE_IND"); + } + AcknowledgeInd acknowledgeInd = + new AcknowledgeInd(mHeaders); + return acknowledgeInd; + case PduHeaders.MESSAGE_TYPE_READ_ORIG_IND: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parse: MESSAGE_TYPE_READ_ORIG_IND"); + } + ReadOrigInd readOrigInd = + new ReadOrigInd(mHeaders); + return readOrigInd; + case PduHeaders.MESSAGE_TYPE_READ_REC_IND: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parse: MESSAGE_TYPE_READ_REC_IND"); + } + ReadRecInd readRecInd = + new ReadRecInd(mHeaders); + return readRecInd; + default: + log("Parser doesn't support this message type in this version!"); + return null; + } + } + + /** + * Parse pdu headers. + * + * @param pduDataStream pdu data input stream + * @return headers in PduHeaders structure, null when parse fail + */ + protected PduHeaders parseHeaders(ByteArrayInputStream pduDataStream) { + if (pduDataStream == null) { + return null; + } + boolean keepParsing = true; + PduHeaders headers = new PduHeaders(); + + while (keepParsing && (pduDataStream.available() > 0)) { + pduDataStream.mark(1); + int headerField = extractByteValue(pduDataStream); + /* parse custom text header */ + if ((headerField >= TEXT_MIN) && (headerField <= TEXT_MAX)) { + pduDataStream.reset(); + byte[] bVal = parseWapString(pduDataStream, TYPE_TEXT_STRING); + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "TextHeader: " + new String(bVal)); + } + /* we should ignore it at the moment */ + continue; + } + switch (headerField) { + case PduHeaders.MESSAGE_TYPE: { + int messageType = extractByteValue(pduDataStream); + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: messageType: " + messageType + + " (" + Integer.toHexString(headerField) + ")"); + } + switch (messageType) { + // We don't support these kind of messages now. + case PduHeaders.MESSAGE_TYPE_FORWARD_REQ: + case PduHeaders.MESSAGE_TYPE_FORWARD_CONF: + case PduHeaders.MESSAGE_TYPE_MBOX_STORE_REQ: + case PduHeaders.MESSAGE_TYPE_MBOX_STORE_CONF: + case PduHeaders.MESSAGE_TYPE_MBOX_VIEW_REQ: + case PduHeaders.MESSAGE_TYPE_MBOX_VIEW_CONF: + case PduHeaders.MESSAGE_TYPE_MBOX_UPLOAD_REQ: + case PduHeaders.MESSAGE_TYPE_MBOX_UPLOAD_CONF: + case PduHeaders.MESSAGE_TYPE_MBOX_DELETE_REQ: + case PduHeaders.MESSAGE_TYPE_MBOX_DELETE_CONF: + case PduHeaders.MESSAGE_TYPE_MBOX_DESCR: + case PduHeaders.MESSAGE_TYPE_DELETE_REQ: + case PduHeaders.MESSAGE_TYPE_DELETE_CONF: + case PduHeaders.MESSAGE_TYPE_CANCEL_REQ: + case PduHeaders.MESSAGE_TYPE_CANCEL_CONF: + return null; + } + try { + headers.setOctet(messageType, headerField); + } catch (InvalidHeaderValueException e) { + log("Set invalid Octet value: " + messageType + + " into the header filed: " + headerField); + return null; + } catch (RuntimeException e) { + log(headerField + "is not Octet header field!"); + return null; + } + break; + } + /* Octect value */ + case PduHeaders.REPORT_ALLOWED: + case PduHeaders.ADAPTATION_ALLOWED: + case PduHeaders.DELIVERY_REPORT: + case PduHeaders.DRM_CONTENT: + case PduHeaders.DISTRIBUTION_INDICATOR: + case PduHeaders.QUOTAS: + case PduHeaders.READ_REPORT: + case PduHeaders.STORE: + case PduHeaders.STORED: + case PduHeaders.TOTALS: + case PduHeaders.SENDER_VISIBILITY: + case PduHeaders.READ_STATUS: + case PduHeaders.CANCEL_STATUS: + case PduHeaders.PRIORITY: + case PduHeaders.STATUS: + case PduHeaders.REPLY_CHARGING: + case PduHeaders.MM_STATE: + case PduHeaders.RECOMMENDED_RETRIEVAL_MODE: + case PduHeaders.CONTENT_CLASS: + case PduHeaders.RETRIEVE_STATUS: + case PduHeaders.STORE_STATUS: + /** + * The following field has a different value when + * used in the M-Mbox-Delete.conf and M-Delete.conf PDU. + * For now we ignore this fact, since we do not support these PDUs + */ + case PduHeaders.RESPONSE_STATUS: { + int value = extractByteValue(pduDataStream); + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField + + " (" + Integer.toHexString(headerField) + ") Octect value: " + + value); + } + + try { + headers.setOctet(value, headerField); + } catch (InvalidHeaderValueException e) { + log("Set invalid Octet value: " + value + + " into the header filed: " + headerField); + return null; + } catch (RuntimeException e) { + log(headerField + "is not Octet header field!"); + return null; + } + break; + } + + /* Long-Integer */ + case PduHeaders.DATE: + case PduHeaders.REPLY_CHARGING_SIZE: + case PduHeaders.MESSAGE_SIZE: { + try { + long value = parseLongInteger(pduDataStream); + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField + + " (" + Integer.toHexString(headerField) + ") longint value: " + + value); + } + headers.setLongInteger(value, headerField); + } catch (RuntimeException e) { + log(headerField + "is not Long-Integer header field!"); + return null; + } + break; + } + + /* Integer-Value */ + case PduHeaders.MESSAGE_COUNT: + case PduHeaders.START: + case PduHeaders.LIMIT: { + try { + long value = parseIntegerValue(pduDataStream); + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField + + " (" + Integer.toHexString(headerField) + ") integer value: " + + value); + } + headers.setLongInteger(value, headerField); + } catch (RuntimeException e) { + log(headerField + "is not Long-Integer header field!"); + return null; + } + break; + } + + /* Text-String */ + case PduHeaders.TRANSACTION_ID: + case PduHeaders.REPLY_CHARGING_ID: + case PduHeaders.AUX_APPLIC_ID: + case PduHeaders.APPLIC_ID: + case PduHeaders.REPLY_APPLIC_ID: + /** + * The next three header fields are email addresses + * as defined in RFC2822, + * not including the characters "<" and ">" + */ + case PduHeaders.MESSAGE_ID: + case PduHeaders.REPLACE_ID: + case PduHeaders.CANCEL_ID: + /** + * The following field has a different value when + * used in the M-Mbox-Delete.conf and M-Delete.conf PDU. + * For now we ignore this fact, since we do not support these PDUs + */ + case PduHeaders.CONTENT_LOCATION: { + byte[] value = parseWapString(pduDataStream, TYPE_TEXT_STRING); + if (null != value) { + try { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField + + " (" + Integer.toHexString(headerField) + ") string value: " + + + new String(value)); + } + headers.setTextString(value, headerField); + } catch (NullPointerException e) { + log("null pointer error!"); + } catch (RuntimeException e) { + log(headerField + "is not Text-String header field!"); + return null; + } + } + break; + } + + /* Encoded-string-value */ + case PduHeaders.SUBJECT: + case PduHeaders.RECOMMENDED_RETRIEVAL_MODE_TEXT: + case PduHeaders.RETRIEVE_TEXT: + case PduHeaders.STATUS_TEXT: + case PduHeaders.STORE_STATUS_TEXT: + /* the next one is not support + * M-Mbox-Delete.conf and M-Delete.conf now */ + case PduHeaders.RESPONSE_TEXT: { + EncodedStringValue value = + parseEncodedStringValue(pduDataStream); + if (null != value) { + try { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField + + " (" + Integer.toHexString(headerField) + + ") encoded string: " + + value.getString()); + } + headers.setEncodedStringValue(value, headerField); + } catch (NullPointerException e) { + log("null pointer error!"); + } catch (RuntimeException e) { + log(headerField + "is not Encoded-String-Value header field!"); + return null; + } + } + break; + } + + /* Addressing model */ + case PduHeaders.BCC: + case PduHeaders.CC: + case PduHeaders.TO: { + EncodedStringValue value = + parseEncodedStringValue(pduDataStream); + if (null != value) { + byte[] address = value.getTextString(); + if (null != address) { + String str = new String(address); + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: (to/cc/bcc) address: " + headerField + + " value: " + str); + } + int endIndex = str.indexOf("/"); + if (endIndex > 0) { + str = str.substring(0, endIndex); + } + try { + value.setTextString(str.getBytes()); + } catch (NullPointerException e) { + log("null pointer error!"); + return null; + } + } + + try { + headers.appendEncodedStringValue(value, headerField); + } catch (NullPointerException e) { + log("null pointer error!"); + } catch (RuntimeException e) { + log(headerField + "is not Encoded-String-Value header field!"); + return null; + } + } + break; + } + + /* Value-length + * (Absolute-token Date-value | Relative-token Delta-seconds-value) */ + case PduHeaders.DELIVERY_TIME: + case PduHeaders.EXPIRY: + case PduHeaders.REPLY_CHARGING_DEADLINE: { + /* parse Value-length */ + parseValueLength(pduDataStream); + + /* Absolute-token or Relative-token */ + int token = extractByteValue(pduDataStream); + + /* Date-value or Delta-seconds-value */ + long timeValue; + try { + timeValue = parseLongInteger(pduDataStream); + } catch (RuntimeException e) { + log(headerField + "is not Long-Integer header field!"); + return null; + } + if (PduHeaders.VALUE_RELATIVE_TOKEN == token) { + /* need to convert the Delta-seconds-value + * into Date-value */ + timeValue = System.currentTimeMillis() / 1000 + timeValue; + } + + try { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField + + " (" + Integer.toHexString(headerField) + ") time value: " + + timeValue); + } + headers.setLongInteger(timeValue, headerField); + } catch (RuntimeException e) { + log(headerField + "is not Long-Integer header field!"); + return null; + } + break; + } + + case PduHeaders.FROM: { + /* From-value = + * Value-length + * (Address-present-token Encoded-string-value | Insert-address-token) + */ + EncodedStringValue from = null; + parseValueLength(pduDataStream); /* parse value-length */ + + /* Address-present-token or Insert-address-token */ + int fromToken = extractByteValue(pduDataStream); + + /* Address-present-token or Insert-address-token */ + if (PduHeaders.FROM_ADDRESS_PRESENT_TOKEN == fromToken) { + /* Encoded-string-value */ + from = parseEncodedStringValue(pduDataStream); + if (null != from) { + byte[] address = from.getTextString(); + if (null != address) { + String str = new String(address); + int endIndex = str.indexOf("/"); + if (endIndex > 0) { + str = str.substring(0, endIndex); + } + try { + from.setTextString(str.getBytes()); + } catch (NullPointerException e) { + log("null pointer error!"); + return null; + } + } + } + } else { + try { + from = new EncodedStringValue( + PduHeaders.FROM_INSERT_ADDRESS_TOKEN_STR.getBytes()); + } catch (NullPointerException e) { + log(headerField + "is not Encoded-String-Value header field!"); + return null; + } + } + + try { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField + + " (" + Integer.toHexString(headerField) + ") from address: " + + from.getString()); + } + headers.setEncodedStringValue(from, PduHeaders.FROM); + } catch (NullPointerException e) { + log("null pointer error!"); + } catch (RuntimeException e) { + log(headerField + "is not Encoded-String-Value header field!"); + return null; + } + break; + } + + case PduHeaders.MESSAGE_CLASS: { + /* Message-class-value = Class-identifier | Token-text */ + pduDataStream.mark(1); + int messageClass = extractByteValue(pduDataStream); + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField + + " (" + Integer.toHexString(headerField) + ") MESSAGE_CLASS: " + + messageClass); + } + + if (messageClass >= PduHeaders.MESSAGE_CLASS_PERSONAL) { + /* Class-identifier */ + try { + if (PduHeaders.MESSAGE_CLASS_PERSONAL == messageClass) { + headers.setTextString( + PduHeaders.MESSAGE_CLASS_PERSONAL_STR.getBytes(), + PduHeaders.MESSAGE_CLASS); + } else if (PduHeaders.MESSAGE_CLASS_ADVERTISEMENT == messageClass) { + headers.setTextString( + PduHeaders.MESSAGE_CLASS_ADVERTISEMENT_STR.getBytes(), + PduHeaders.MESSAGE_CLASS); + } else if (PduHeaders.MESSAGE_CLASS_INFORMATIONAL == messageClass) { + headers.setTextString( + PduHeaders.MESSAGE_CLASS_INFORMATIONAL_STR.getBytes(), + PduHeaders.MESSAGE_CLASS); + } else if (PduHeaders.MESSAGE_CLASS_AUTO == messageClass) { + headers.setTextString( + PduHeaders.MESSAGE_CLASS_AUTO_STR.getBytes(), + PduHeaders.MESSAGE_CLASS); + } + } catch (NullPointerException e) { + log("null pointer error!"); + } catch (RuntimeException e) { + log(headerField + "is not Text-String header field!"); + return null; + } + } else { + /* Token-text */ + pduDataStream.reset(); + byte[] messageClassString = parseWapString(pduDataStream, TYPE_TEXT_STRING); + if (null != messageClassString) { + try { + headers.setTextString(messageClassString, PduHeaders.MESSAGE_CLASS); + } catch (NullPointerException e) { + log("null pointer error!"); + } catch (RuntimeException e) { + log(headerField + "is not Text-String header field!"); + return null; + } + } + } + break; + } + + case PduHeaders.MMS_VERSION: { + int version = parseShortInteger(pduDataStream); + + try { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField + + " (" + Integer.toHexString(headerField) + ") MMS_VERSION: " + + version); + } + headers.setOctet(version, PduHeaders.MMS_VERSION); + } catch (InvalidHeaderValueException e) { + log("Set invalid Octet value: " + version + + " into the header filed: " + headerField); + return null; + } catch (RuntimeException e) { + log(headerField + "is not Octet header field!"); + return null; + } + break; + } + + case PduHeaders.PREVIOUSLY_SENT_BY: { + /* Previously-sent-by-value = + * Value-length Forwarded-count-value Encoded-string-value */ + /* parse value-length */ + parseValueLength(pduDataStream); + + /* parse Forwarded-count-value */ + try { + parseIntegerValue(pduDataStream); + } catch (RuntimeException e) { + log(headerField + " is not Integer-Value"); + return null; + } + + /* parse Encoded-string-value */ + EncodedStringValue previouslySentBy = + parseEncodedStringValue(pduDataStream); + if (null != previouslySentBy) { + try { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField + + " (" + Integer.toHexString(headerField) + + ") PREVIOUSLY_SENT_BY: " + previouslySentBy.getString()); + } + headers.setEncodedStringValue(previouslySentBy, + PduHeaders.PREVIOUSLY_SENT_BY); + } catch (NullPointerException e) { + log("null pointer error!"); + } catch (RuntimeException e) { + log(headerField + "is not Encoded-String-Value header field!"); + return null; + } + } + break; + } + + case PduHeaders.PREVIOUSLY_SENT_DATE: { + /* Previously-sent-date-value = + * Value-length Forwarded-count-value Date-value */ + /* parse value-length */ + parseValueLength(pduDataStream); + + /* parse Forwarded-count-value */ + try { + parseIntegerValue(pduDataStream); + } catch (RuntimeException e) { + log(headerField + " is not Integer-Value"); + return null; + } + + /* Date-value */ + try { + long previouslySentDate = parseLongInteger(pduDataStream); + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField + + " (" + Integer.toHexString(headerField) + + ") PREVIOUSLY_SENT_DATE: " + previouslySentDate); + } + headers.setLongInteger(previouslySentDate, + PduHeaders.PREVIOUSLY_SENT_DATE); + } catch (RuntimeException e) { + log(headerField + "is not Long-Integer header field!"); + return null; + } + break; + } + + case PduHeaders.MM_FLAGS: { + /* MM-flags-value = + * Value-length + * ( Add-token | Remove-token | Filter-token ) + * Encoded-string-value + */ + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField + + " (" + Integer.toHexString(headerField) + ") MM_FLAGS: " + + " NOT REALLY SUPPORTED"); + } + + /* parse Value-length */ + parseValueLength(pduDataStream); + + /* Add-token | Remove-token | Filter-token */ + extractByteValue(pduDataStream); + + /* Encoded-string-value */ + parseEncodedStringValue(pduDataStream); + + /* not store this header filed in "headers", + * because now PduHeaders doesn't support it */ + break; + } + + /* Value-length + * (Message-total-token | Size-total-token) Integer-Value */ + case PduHeaders.MBOX_TOTALS: + case PduHeaders.MBOX_QUOTAS: { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField + + " (" + Integer.toHexString(headerField) + ") MBOX_"); + } + /* Value-length */ + parseValueLength(pduDataStream); + + /* Message-total-token | Size-total-token */ + extractByteValue(pduDataStream); + + /*Integer-Value*/ + try { + parseIntegerValue(pduDataStream); + } catch (RuntimeException e) { + log(headerField + " is not Integer-Value"); + return null; + } + + /* not store these headers filed in "headers", + because now PduHeaders doesn't support them */ + break; + } + + case PduHeaders.ELEMENT_DESCRIPTOR: { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField + + " (" + Integer.toHexString(headerField) + ") ELEMENT_DESCRIPTOR"); + } + parseContentType(pduDataStream, null); + + /* not store this header filed in "headers", + because now PduHeaders doesn't support it */ + break; + } + + case PduHeaders.CONTENT_TYPE: { + SparseArray map = new SparseArray(); + byte[] contentType = + parseContentType(pduDataStream, map); + + if (null != contentType) { + try { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: headerField: " + headerField + + " (" + Integer.toHexString(headerField) + ") CONTENT_TYPE: " + + Arrays.toString(contentType)); + } + headers.setTextString(contentType, PduHeaders.CONTENT_TYPE); + } catch (NullPointerException e) { + log("null pointer error!"); + } catch (RuntimeException e) { + log(headerField + "is not Text-String header field!"); + return null; + } + } + + /* get start parameter */ + mStartParam = (byte[]) map.get(PduPart.P_START); + + /* get charset parameter */ + mTypeParam = (byte[]) map.get(PduPart.P_TYPE); + + keepParsing = false; + break; + } + + case PduHeaders.CONTENT: + case PduHeaders.ADDITIONAL_HEADERS: + case PduHeaders.ATTRIBUTES: + default: { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "parseHeaders: Unknown header: " + headerField + + " (" + Integer.toHexString(headerField) + ")"); + } + log("Unknown header"); + } + } + } + + return headers; + } + + /** + * Parse pdu parts. + * + * @param pduDataStream pdu data input stream + * @return parts in PduBody structure + */ + protected PduBody parseParts(ByteArrayInputStream pduDataStream) { + if (pduDataStream == null) { + return null; + } + + int count = parseUnsignedInt(pduDataStream); // get the number of parts + PduBody body = new PduBody(); + + for (int i = 0; i < count; i++) { + int headerLength = parseUnsignedInt(pduDataStream); + int dataLength = parseUnsignedInt(pduDataStream); + PduPart part = new PduPart(); + int startPos = pduDataStream.available(); + if (startPos <= 0) { + // Invalid part. + return null; + } + + /* parse part's content-type */ + SparseArray map = new SparseArray(); + byte[] contentType = parseContentType(pduDataStream, map); + if (null != contentType) { + part.setContentType(contentType); + } else { + part.setContentType((PduContentTypes.contentTypes[0]).getBytes()); //"*/*" + } + + /* get name parameter */ + byte[] name = (byte[]) map.get(PduPart.P_NAME); + if (null != name) { + part.setName(name); + } + + /* get charset parameter */ + Integer charset = (Integer) map.get(PduPart.P_CHARSET); + if (null != charset) { + part.setCharset(charset); + } + + /* parse part's headers */ + int endPos = pduDataStream.available(); + int partHeaderLen = headerLength - (startPos - endPos); + if (partHeaderLen > 0) { + if (false == parsePartHeaders(pduDataStream, part, partHeaderLen)) { + // Parse part header faild. + return null; + } + } else if (partHeaderLen < 0) { + // Invalid length of content-type. + return null; + } + + /* TODO: check content-id, name, filename and content location, + * if not set anyone of them, generate a default content-location + */ + if ((null == part.getContentLocation()) + && (null == part.getName()) + && (null == part.getFilename()) + && (null == part.getContentId())) { + part.setContentLocation(Long.toOctalString( + System.currentTimeMillis()).getBytes()); + } + + /* get part's data */ + if (dataLength > 0) { + byte[] partData = new byte[dataLength]; + String partContentType = new String(part.getContentType()); + pduDataStream.read(partData, 0, dataLength); + if (partContentType.equalsIgnoreCase(ContentType.MMS_MULTIPART_ALTERNATIVE)) { + // parse "multipart/vnd.wap.multipart.alternative". + PduBody childBody = parseParts(new ByteArrayInputStream(partData)); + // take the first part of children. + part = childBody.getPart(0); + } else { + // Check Content-Transfer-Encoding. + byte[] partDataEncoding = part.getContentTransferEncoding(); + if (null != partDataEncoding) { + String encoding = new String(partDataEncoding); + if (encoding.equalsIgnoreCase(PduPart.P_BASE64)) { + // Decode "base64" into "binary". + partData = Base64.decodeBase64(partData); + } else if (encoding.equalsIgnoreCase(PduPart.P_QUOTED_PRINTABLE)) { + // Decode "quoted-printable" into "binary". + partData = QuotedPrintable.decodeQuotedPrintable(partData); + } else { + // "binary" is the default encoding. + } + } + if (null == partData) { + log("Decode part data error!"); + return null; + } + part.setData(partData); + } + } + + /* add this part to body */ + if (THE_FIRST_PART == checkPartPosition(part)) { + /* this is the first part */ + body.addPart(0, part); + } else { + /* add the part to the end */ + body.addPart(part); + } + } + + return body; + } + + /** + * Log status. + * + * @param text log information + */ + private static void log(String text) { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, text); + } + } + + /** + * Parse unsigned integer. + * + * @param pduDataStream pdu data input stream + * @return the integer, -1 when failed + */ + protected static int parseUnsignedInt(ByteArrayInputStream pduDataStream) { + /** + * From wap-230-wsp-20010705-a.pdf + * The maximum size of a uintvar is 32 bits. + * So it will be encoded in no more than 5 octets. + */ + assert (null != pduDataStream); + int result = 0; + int temp = pduDataStream.read(); + if (temp == -1) { + return temp; + } + + while ((temp & 0x80) != 0) { + result = result << 7; + result |= temp & 0x7F; + temp = pduDataStream.read(); + if (temp == -1) { + return temp; + } + } + + result = result << 7; + result |= temp & 0x7F; + + return result; + } + + /** + * Parse value length. + * + * @param pduDataStream pdu data input stream + * @return the integer + */ + protected static int parseValueLength(ByteArrayInputStream pduDataStream) { + /** + * From wap-230-wsp-20010705-a.pdf + * Value-length = Short-length | (Length-quote Length) + * Short-length = + * Length-quote = + * Length = Uintvar-integer + * Uintvar-integer = 1*5 OCTET + */ + assert (null != pduDataStream); + int temp = pduDataStream.read(); + assert (-1 != temp); + int first = temp & 0xFF; + + if (first <= SHORT_LENGTH_MAX) { + return first; + } else if (first == LENGTH_QUOTE) { + return parseUnsignedInt(pduDataStream); + } + + throw new RuntimeException("Value length > LENGTH_QUOTE!"); + } + + /** + * Parse encoded string value. + * + * @param pduDataStream pdu data input stream + * @return the EncodedStringValue + */ + protected static EncodedStringValue parseEncodedStringValue( + ByteArrayInputStream pduDataStream) { + /** + * From OMA-TS-MMS-ENC-V1_3-20050927-C.pdf + * Encoded-string-value = Text-string | Value-length Char-set Text-string + */ + assert (null != pduDataStream); + pduDataStream.mark(1); + EncodedStringValue returnValue = null; + int charset = 0; + int temp = pduDataStream.read(); + assert (-1 != temp); + int first = temp & 0xFF; + if (first == 0) { + return null; // Blank subject, bail. + } + + pduDataStream.reset(); + if (first < TEXT_MIN) { + parseValueLength(pduDataStream); + + charset = parseShortInteger(pduDataStream); //get the "Charset" + } + + byte[] textString = parseWapString(pduDataStream, TYPE_TEXT_STRING); + + try { + if (0 != charset) { + returnValue = new EncodedStringValue(charset, textString); + } else { + returnValue = new EncodedStringValue(textString); + } + } catch (Exception e) { + return null; + } + + return returnValue; + } + + /** + * Parse Text-String or Quoted-String. + * + * @param pduDataStream pdu data input stream + * @param stringType TYPE_TEXT_STRING or TYPE_QUOTED_STRING + * @return the string without End-of-string in byte array + */ + protected static byte[] parseWapString(ByteArrayInputStream pduDataStream, + int stringType) { + assert (null != pduDataStream); + /** + * From wap-230-wsp-20010705-a.pdf + * Text-string = [Quote] *TEXT End-of-string + * If the first character in the TEXT is in the range of 128-255, + * a Quote character must precede it. + * Otherwise the Quote character must be omitted. + * The Quote is not part of the contents. + * Quote = + * End-of-string = + * + * Quoted-string = *TEXT End-of-string + * + * Token-text = Token End-of-string + */ + + // Mark supposed beginning of Text-string + // We will have to mark again if first char is QUOTE or QUOTED_STRING_FLAG + pduDataStream.mark(1); + + // Check first char + int temp = pduDataStream.read(); + assert (-1 != temp); + if ((TYPE_QUOTED_STRING == stringType) && + (QUOTED_STRING_FLAG == temp)) { + // Mark again if QUOTED_STRING_FLAG and ignore it + pduDataStream.mark(1); + } else if ((TYPE_TEXT_STRING == stringType) && + (QUOTE == temp)) { + // Mark again if QUOTE and ignore it + pduDataStream.mark(1); + } else { + // Otherwise go back to origin + pduDataStream.reset(); + } + + // We are now definitely at the beginning of string + /** + * Return *TOKEN or *TEXT (Text-String without QUOTE, + * Quoted-String without QUOTED_STRING_FLAG and without End-of-string) + */ + return getWapString(pduDataStream, stringType); + } + + /** + * Check TOKEN data defined in RFC2616. + * + * @param ch checking data + * @return true when ch is TOKEN, false when ch is not TOKEN + */ + protected static boolean isTokenCharacter(int ch) { + /** + * Token = 1* + * separators = "("(40) | ")"(41) | "<"(60) | ">"(62) | "@"(64) + * | ","(44) | ";"(59) | ":"(58) | "\"(92) | <">(34) + * | "/"(47) | "["(91) | "]"(93) | "?"(63) | "="(61) + * | "{"(123) | "}"(125) | SP(32) | HT(9) + * CHAR = + * CTL = + * SP = + * HT = + */ + if ((ch < 33) || (ch > 126)) { + return false; + } + + switch (ch) { + case '"': /* '"' */ + case '(': /* '(' */ + case ')': /* ')' */ + case ',': /* ',' */ + case '/': /* '/' */ + case ':': /* ':' */ + case ';': /* ';' */ + case '<': /* '<' */ + case '=': /* '=' */ + case '>': /* '>' */ + case '?': /* '?' */ + case '@': /* '@' */ + case '[': /* '[' */ + case '\\': /* '\' */ + case ']': /* ']' */ + case '{': /* '{' */ + case '}': /* '}' */ + return false; + } + + return true; + } + + /** + * Check TEXT data defined in RFC2616. + * + * @param ch checking data + * @return true when ch is TEXT, false when ch is not TEXT + */ + protected static boolean isText(int ch) { + /** + * TEXT = + * CTL = + * LWS = [CRLF] 1*( SP | HT ) + * CRLF = CR LF + * CR = + * LF = + */ + if (((ch >= 32) && (ch <= 126)) || ((ch >= 128) && (ch <= 255))) { + return true; + } + + switch (ch) { + case '\t': /* '\t' */ + case '\n': /* '\n' */ + case '\r': /* '\r' */ + return true; + } + + return false; + } + + protected static byte[] getWapString(ByteArrayInputStream pduDataStream, + int stringType) { + assert (null != pduDataStream); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + int temp = pduDataStream.read(); + assert (-1 != temp); + while ((-1 != temp) && ('\0' != temp)) { + // check each of the character + if (stringType == TYPE_TOKEN_STRING) { + if (isTokenCharacter(temp)) { + out.write(temp); + } + } else { + if (isText(temp)) { + out.write(temp); + } + } + + temp = pduDataStream.read(); + assert (-1 != temp); + } + + if (out.size() > 0) { + return out.toByteArray(); + } + + return null; + } + + /** + * Extract a byte value from the input stream. + * + * @param pduDataStream pdu data input stream + * @return the byte + */ + protected static int extractByteValue(ByteArrayInputStream pduDataStream) { + assert (null != pduDataStream); + int temp = pduDataStream.read(); + assert (-1 != temp); + return temp & 0xFF; + } + + /** + * Parse Short-Integer. + * + * @param pduDataStream pdu data input stream + * @return the byte + */ + protected static int parseShortInteger(ByteArrayInputStream pduDataStream) { + /** + * From wap-230-wsp-20010705-a.pdf + * Short-integer = OCTET + * Integers in range 0-127 shall be encoded as a one + * octet value with the most significant bit set to one (1xxx xxxx) + * and with the value in the remaining least significant bits. + */ + assert (null != pduDataStream); + int temp = pduDataStream.read(); + assert (-1 != temp); + return temp & 0x7F; + } + + /** + * Parse Long-Integer. + * + * @param pduDataStream pdu data input stream + * @return long integer + */ + protected static long parseLongInteger(ByteArrayInputStream pduDataStream) { + /** + * From wap-230-wsp-20010705-a.pdf + * Long-integer = Short-length Multi-octet-integer + * The Short-length indicates the length of the Multi-octet-integer + * Multi-octet-integer = 1*30 OCTET + * The content octets shall be an unsigned integer value + * with the most significant octet encoded first (big-endian representation). + * The minimum number of octets must be used to encode the value. + * Short-length = + */ + assert (null != pduDataStream); + int temp = pduDataStream.read(); + assert (-1 != temp); + int count = temp & 0xFF; + + if (count > LONG_INTEGER_LENGTH_MAX) { + throw new RuntimeException("Octet count greater than 8 and I can't represent that!"); + } + + long result = 0; + + for (int i = 0; i < count; i++) { + temp = pduDataStream.read(); + assert (-1 != temp); + result <<= 8; + result += (temp & 0xFF); + } + + return result; + } + + /** + * Parse Integer-Value. + * + * @param pduDataStream pdu data input stream + * @return long integer + */ + protected static long parseIntegerValue(ByteArrayInputStream pduDataStream) { + /** + * From wap-230-wsp-20010705-a.pdf + * Integer-Value = Short-integer | Long-integer + */ + assert (null != pduDataStream); + pduDataStream.mark(1); + int temp = pduDataStream.read(); + assert (-1 != temp); + pduDataStream.reset(); + if (temp > SHORT_INTEGER_MAX) { + return parseShortInteger(pduDataStream); + } else { + return parseLongInteger(pduDataStream); + } + } + + /** + * To skip length of the wap value. + * + * @param pduDataStream pdu data input stream + * @param length area size + * @return the values in this area + */ + protected static int skipWapValue(ByteArrayInputStream pduDataStream, int length) { + assert (null != pduDataStream); + byte[] area = new byte[length]; + int readLen = pduDataStream.read(area, 0, length); + if (readLen < length) { //The actually read length is lower than the length + return -1; + } else { + return readLen; + } + } + + /** + * Parse content type parameters. For now we just support + * four parameters used in mms: "type", "start", "name", "charset". + * + * @param pduDataStream pdu data input stream + * @param map to store parameters of Content-Type field + * @param length length of all the parameters + */ + protected static void parseContentTypeParams(ByteArrayInputStream pduDataStream, + SparseArray map, Integer length) { + /** + * From wap-230-wsp-20010705-a.pdf + * Parameter = Typed-parameter | Untyped-parameter + * Typed-parameter = Well-known-parameter-token Typed-value + * the actual expected type of the value is implied by the well-known parameter + * Well-known-parameter-token = Integer-value + * the code values used for parameters are specified in the Assigned Numbers appendix + * Typed-value = Compact-value | Text-value + * In addition to the expected type, there may be no value. + * If the value cannot be encoded using the expected type, it shall be encoded as text. + * Compact-value = Integer-value | + * Date-value | Delta-seconds-value | Q-value | Version-value | + * Uri-value + * Untyped-parameter = Token-text Untyped-value + * the type of the value is unknown, but it shall be encoded as an integer, + * if that is possible. + * Untyped-value = Integer-value | Text-value + */ + assert (null != pduDataStream); + assert (length > 0); + + int startPos = pduDataStream.available(); + int tempPos = 0; + int lastLen = length; + while (0 < lastLen) { + int param = pduDataStream.read(); + assert (-1 != param); + lastLen--; + + switch (param) { + /** + * From rfc2387, chapter 3.1 + * The type parameter must be specified and its value is the MIME media + * type of the "root" body part. It permits a MIME user agent to + * determine the content-type without reference to the enclosed body + * part. If the value of the type parameter and the root body part's + * content-type differ then the User Agent's behavior is undefined. + * + * From wap-230-wsp-20010705-a.pdf + * type = Constrained-encoding + * Constrained-encoding = Extension-Media | Short-integer + * Extension-media = *TEXT End-of-string + */ + case PduPart.P_TYPE: + case PduPart.P_CT_MR_TYPE: + pduDataStream.mark(1); + int first = extractByteValue(pduDataStream); + pduDataStream.reset(); + if (first > TEXT_MAX) { + // Short-integer (well-known type) + int index = parseShortInteger(pduDataStream); + + if (index < PduContentTypes.contentTypes.length) { + byte[] type = (PduContentTypes.contentTypes[index]).getBytes(); + map.put(PduPart.P_TYPE, type); + } else { + //not support this type, ignore it. + } + } else { + // Text-String (extension-media) + byte[] type = parseWapString(pduDataStream, TYPE_TEXT_STRING); + if ((null != type) && (null != map)) { + map.put(PduPart.P_TYPE, type); + } + } + + tempPos = pduDataStream.available(); + lastLen = length - (startPos - tempPos); + break; + + /** + * From oma-ts-mms-conf-v1_3.pdf, chapter 10.2.3. + * Start Parameter Referring to Presentation + * + * From rfc2387, chapter 3.2 + * The start parameter, if given, is the content-ID of the compound + * object's "root". If not present the "root" is the first body part in + * the Multipart/Related entity. The "root" is the element the + * applications processes first. + * + * From wap-230-wsp-20010705-a.pdf + * start = Text-String + */ + case PduPart.P_START: + case PduPart.P_DEP_START: + byte[] start = parseWapString(pduDataStream, TYPE_TEXT_STRING); + if ((null != start) && (null != map)) { + map.put(PduPart.P_START, start); + } + + tempPos = pduDataStream.available(); + lastLen = length - (startPos - tempPos); + break; + + /** + * From oma-ts-mms-conf-v1_3.pdf + * In creation, the character set SHALL be either us-ascii + * (IANA MIBenum 3) or utf-8 (IANA MIBenum 106)[Unicode]. + * In retrieval, both us-ascii and utf-8 SHALL be supported. + * + * From wap-230-wsp-20010705-a.pdf + * charset = Well-known-charset|Text-String + * Well-known-charset = Any-charset | Integer-value + * Both are encoded using values from Character Set + * Assignments table in Assigned Numbers + * Any-charset = + * Equivalent to the special RFC2616 charset value "*" + */ + case PduPart.P_CHARSET: + pduDataStream.mark(1); + int firstValue = extractByteValue(pduDataStream); + pduDataStream.reset(); + //Check first char + if (((firstValue > TEXT_MIN) && (firstValue < TEXT_MAX)) || + (END_STRING_FLAG == firstValue)) { + //Text-String (extension-charset) + byte[] charsetStr = parseWapString(pduDataStream, TYPE_TEXT_STRING); + try { + int charsetInt = CharacterSets.getMibEnumValue( + new String(charsetStr)); + map.put(PduPart.P_CHARSET, charsetInt); + } catch (UnsupportedEncodingException e) { + // Not a well-known charset, use "*". + Log.e(LOG_TAG, Arrays.toString(charsetStr), e); + map.put(PduPart.P_CHARSET, CharacterSets.ANY_CHARSET); + } + } else { + //Well-known-charset + int charset = (int) parseIntegerValue(pduDataStream); + if (map != null) { + map.put(PduPart.P_CHARSET, charset); + } + } + + tempPos = pduDataStream.available(); + lastLen = length - (startPos - tempPos); + break; + + /** + * From oma-ts-mms-conf-v1_3.pdf + * A name for multipart object SHALL be encoded using name-parameter + * for Content-Type header in WSP multipart headers. + * + * From wap-230-wsp-20010705-a.pdf + * name = Text-String + */ + case PduPart.P_DEP_NAME: + case PduPart.P_NAME: + byte[] name = parseWapString(pduDataStream, TYPE_TEXT_STRING); + if ((null != name) && (null != map)) { + map.put(PduPart.P_NAME, name); + } + + tempPos = pduDataStream.available(); + lastLen = length - (startPos - tempPos); + break; + default: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "Not supported Content-Type parameter"); + } + if (-1 == skipWapValue(pduDataStream, lastLen)) { + Log.e(LOG_TAG, "Corrupt Content-Type"); + } else { + lastLen = 0; + } + break; + } + } + + if (0 != lastLen) { + Log.e(LOG_TAG, "Corrupt Content-Type"); + } + } + + /** + * Parse content type. + * + * @param pduDataStream pdu data input stream + * @param map to store parameters in Content-Type header field + * @return Content-Type value + */ + protected static byte[] parseContentType(ByteArrayInputStream pduDataStream, + SparseArray map) { + /** + * From wap-230-wsp-20010705-a.pdf + * Content-type-value = Constrained-media | Content-general-form + * Content-general-form = Value-length Media-type + * Media-type = (Well-known-media | Extension-Media) *(Parameter) + */ + assert (null != pduDataStream); + + byte[] contentType = null; + pduDataStream.mark(1); + int temp = pduDataStream.read(); + assert (-1 != temp); + pduDataStream.reset(); + + int cur = (temp & 0xFF); + + if (cur < TEXT_MIN) { + int length = parseValueLength(pduDataStream); + int startPos = pduDataStream.available(); + pduDataStream.mark(1); + temp = pduDataStream.read(); + assert (-1 != temp); + pduDataStream.reset(); + int first = (temp & 0xFF); + + if ((first >= TEXT_MIN) && (first <= TEXT_MAX)) { + contentType = parseWapString(pduDataStream, TYPE_TEXT_STRING); + } else if (first > TEXT_MAX) { + int index = parseShortInteger(pduDataStream); + + if (index < PduContentTypes.contentTypes.length) { //well-known type + contentType = (PduContentTypes.contentTypes[index]).getBytes(); + } else { + pduDataStream.reset(); + contentType = parseWapString(pduDataStream, TYPE_TEXT_STRING); + } + } else { + Log.e(LOG_TAG, "Corrupt content-type"); + return (PduContentTypes.contentTypes[0]).getBytes(); //"*/*" + } + + int endPos = pduDataStream.available(); + int parameterLen = length - (startPos - endPos); + if (parameterLen > 0) {//have parameters + parseContentTypeParams(pduDataStream, map, parameterLen); + } + + if (parameterLen < 0) { + Log.e(LOG_TAG, "Corrupt MMS message"); + return (PduContentTypes.contentTypes[0]).getBytes(); //"*/*" + } + } else if (cur <= TEXT_MAX) { + contentType = parseWapString(pduDataStream, TYPE_TEXT_STRING); + } else { + contentType = + (PduContentTypes.contentTypes[parseShortInteger(pduDataStream)]).getBytes(); + } + + return contentType; + } + + /** + * Parse part's headers. + * + * @param pduDataStream pdu data input stream + * @param part to store the header informations of the part + * @param length length of the headers + * @return true if parse successfully, false otherwise + */ + protected boolean parsePartHeaders(ByteArrayInputStream pduDataStream, + PduPart part, int length) { + assert (null != pduDataStream); + assert (null != part); + assert (length > 0); + + /** + * From oma-ts-mms-conf-v1_3.pdf, chapter 10.2. + * A name for multipart object SHALL be encoded using name-parameter + * for Content-Type header in WSP multipart headers. + * In decoding, name-parameter of Content-Type SHALL be used if available. + * If name-parameter of Content-Type is not available, + * filename parameter of Content-Disposition header SHALL be used if available. + * If neither name-parameter of Content-Type header nor filename parameter + * of Content-Disposition header is available, + * Content-Location header SHALL be used if available. + * + * Within SMIL part the reference to the media object parts SHALL use + * either Content-ID or Content-Location mechanism [RFC2557] + * and the corresponding WSP part headers in media object parts + * contain the corresponding definitions. + */ + int startPos = pduDataStream.available(); + int tempPos = 0; + int lastLen = length; + while (0 < lastLen) { + int header = pduDataStream.read(); + assert (-1 != header); + lastLen--; + + if (header > TEXT_MAX) { + // Number assigned headers. + switch (header) { + case PduPart.P_CONTENT_LOCATION: + /** + * From wap-230-wsp-20010705-a.pdf, chapter 8.4.2.21 + * Content-location-value = Uri-value + */ + byte[] contentLocation = parseWapString(pduDataStream, TYPE_TEXT_STRING); + if (null != contentLocation) { + part.setContentLocation(contentLocation); + } + + tempPos = pduDataStream.available(); + lastLen = length - (startPos - tempPos); + break; + case PduPart.P_CONTENT_ID: + /** + * From wap-230-wsp-20010705-a.pdf, chapter 8.4.2.21 + * Content-ID-value = Quoted-string + */ + byte[] contentId = parseWapString(pduDataStream, TYPE_QUOTED_STRING); + if (null != contentId) { + part.setContentId(contentId); + } + + tempPos = pduDataStream.available(); + lastLen = length - (startPos - tempPos); + break; + case PduPart.P_DEP_CONTENT_DISPOSITION: + case PduPart.P_CONTENT_DISPOSITION: + /* + * From wap-230-wsp-20010705-a.pdf, chapter 8.4.2.21 + * Content-disposition-value = Value-length Disposition *(Parameter) + * Disposition = Form-data | Attachment | Inline | Token-text + * Form-data = + * Attachment = + * Inline = + * + * some carrier mmsc servers do not support content_disposition + * field correctly + */ + if (mParseContentDisposition) { + int len = parseValueLength(pduDataStream); + pduDataStream.mark(1); + int thisStartPos = pduDataStream.available(); + int thisEndPos = 0; + int value = pduDataStream.read(); + + if (value == PduPart.P_DISPOSITION_FROM_DATA) { + part.setContentDisposition(PduPart.DISPOSITION_FROM_DATA); + } else if (value == PduPart.P_DISPOSITION_ATTACHMENT) { + part.setContentDisposition(PduPart.DISPOSITION_ATTACHMENT); + } else if (value == PduPart.P_DISPOSITION_INLINE) { + part.setContentDisposition(PduPart.DISPOSITION_INLINE); + } else { + pduDataStream.reset(); + /* Token-text */ + part.setContentDisposition(parseWapString(pduDataStream + , TYPE_TEXT_STRING)); + } + + /* get filename parameter and skip other parameters */ + thisEndPos = pduDataStream.available(); + if (thisStartPos - thisEndPos < len) { + value = pduDataStream.read(); + if (value == PduPart.P_FILENAME) { //filename is text-string + part.setFilename(parseWapString(pduDataStream + , TYPE_TEXT_STRING)); + } + + /* skip other parameters */ + thisEndPos = pduDataStream.available(); + if (thisStartPos - thisEndPos < len) { + int last = len - (thisStartPos - thisEndPos); + byte[] temp = new byte[last]; + pduDataStream.read(temp, 0, last); + } + } + + tempPos = pduDataStream.available(); + lastLen = length - (startPos - tempPos); + } + break; + default: + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "Not supported Part headers: " + header); + } + if (-1 == skipWapValue(pduDataStream, lastLen)) { + Log.e(LOG_TAG, "Corrupt Part headers"); + return false; + } + lastLen = 0; + break; + } + } else if ((header >= TEXT_MIN) && (header <= TEXT_MAX)) { + // Not assigned header. + byte[] tempHeader = parseWapString(pduDataStream, TYPE_TEXT_STRING); + byte[] tempValue = parseWapString(pduDataStream, TYPE_TEXT_STRING); + + // Check the header whether it is "Content-Transfer-Encoding". + if (true == + PduPart.CONTENT_TRANSFER_ENCODING + .equalsIgnoreCase(new String(tempHeader))) { + part.setContentTransferEncoding(tempValue); + } + + tempPos = pduDataStream.available(); + lastLen = length - (startPos - tempPos); + } else { + if (LOCAL_LOGV) { + Log.v(LOG_TAG, "Not supported Part headers: " + header); + } + // Skip all headers of this part. + if (-1 == skipWapValue(pduDataStream, lastLen)) { + Log.e(LOG_TAG, "Corrupt Part headers"); + return false; + } + lastLen = 0; + } + } + + if (0 != lastLen) { + Log.e(LOG_TAG, "Corrupt Part headers"); + return false; + } + + return true; + } + + /** + * Check the position of a specified part. + * + * @param part the part to be checked + * @return part position, THE_FIRST_PART when it's the + * first one, THE_LAST_PART when it's the last one. + */ + private static int checkPartPosition(PduPart part) { + assert (null != part); + if ((null == mTypeParam) && + (null == mStartParam)) { + return THE_LAST_PART; + } + + /* check part's content-id */ + if (null != mStartParam) { + byte[] contentId = part.getContentId(); + if (null != contentId) { + if (true == Arrays.equals(mStartParam, contentId)) { + return THE_FIRST_PART; + } + } + // This is not the first part, so append to end (keeping the original order) + // Check b/19607294 for details of this change + return THE_LAST_PART; + } + + /* check part's content-type */ + if (null != mTypeParam) { + byte[] contentType = part.getContentType(); + if (null != contentType) { + if (true == Arrays.equals(mTypeParam, contentType)) { + return THE_FIRST_PART; + } + } + } + + return THE_LAST_PART; + } + + /** + * Check mandatory headers of a pdu. + * + * @param headers pdu headers + * @return true if the pdu has all of the mandatory headers, false otherwise. + */ + protected static boolean checkMandatoryHeader(PduHeaders headers) { + if (null == headers) { + return false; + } + + /* get message type */ + int messageType = headers.getOctet(PduHeaders.MESSAGE_TYPE); + + /* check Mms-Version field */ + int mmsVersion = headers.getOctet(PduHeaders.MMS_VERSION); + if (0 == mmsVersion) { + // Every message should have Mms-Version field. + return false; + } + + /* check mandatory header fields */ + switch (messageType) { + case PduHeaders.MESSAGE_TYPE_SEND_REQ: + // Content-Type field. + byte[] srContentType = headers.getTextString(PduHeaders.CONTENT_TYPE); + if (null == srContentType) { + return false; + } + + // From field. + EncodedStringValue srFrom = headers.getEncodedStringValue(PduHeaders.FROM); + if (null == srFrom) { + return false; + } + + // Transaction-Id field. + byte[] srTransactionId = headers.getTextString(PduHeaders.TRANSACTION_ID); + if (null == srTransactionId) { + return false; + } + + break; + case PduHeaders.MESSAGE_TYPE_SEND_CONF: + // Response-Status field. + int scResponseStatus = headers.getOctet(PduHeaders.RESPONSE_STATUS); + if (0 == scResponseStatus) { + return false; + } + + // Transaction-Id field. + byte[] scTransactionId = headers.getTextString(PduHeaders.TRANSACTION_ID); + if (null == scTransactionId) { + return false; + } + + break; + case PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND: + // Content-Location field. + byte[] niContentLocation = headers.getTextString(PduHeaders.CONTENT_LOCATION); + if (null == niContentLocation) { + return false; + } + + // Expiry field. + long niExpiry = headers.getLongInteger(PduHeaders.EXPIRY); + if (-1 == niExpiry) { + return false; + } + + // Message-Class field. + byte[] niMessageClass = headers.getTextString(PduHeaders.MESSAGE_CLASS); + if (null == niMessageClass) { + return false; + } + + // Message-Size field. + long niMessageSize = headers.getLongInteger(PduHeaders.MESSAGE_SIZE); + if (-1 == niMessageSize) { + return false; + } + + // Transaction-Id field. + byte[] niTransactionId = headers.getTextString(PduHeaders.TRANSACTION_ID); + if (null == niTransactionId) { + return false; + } + + break; + case PduHeaders.MESSAGE_TYPE_NOTIFYRESP_IND: + // Status field. + int nriStatus = headers.getOctet(PduHeaders.STATUS); + if (0 == nriStatus) { + return false; + } + + // Transaction-Id field. + byte[] nriTransactionId = headers.getTextString(PduHeaders.TRANSACTION_ID); + if (null == nriTransactionId) { + return false; + } + + break; + case PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF: + // Content-Type field. + byte[] rcContentType = headers.getTextString(PduHeaders.CONTENT_TYPE); + if (null == rcContentType) { + return false; + } + + // Date field. + long rcDate = headers.getLongInteger(PduHeaders.DATE); + if (-1 == rcDate) { + return false; + } + + break; + case PduHeaders.MESSAGE_TYPE_DELIVERY_IND: + // Date field. + long diDate = headers.getLongInteger(PduHeaders.DATE); + if (-1 == diDate) { + return false; + } + + // Message-Id field. + byte[] diMessageId = headers.getTextString(PduHeaders.MESSAGE_ID); + if (null == diMessageId) { + return false; + } + + // Status field. + int diStatus = headers.getOctet(PduHeaders.STATUS); + if (0 == diStatus) { + return false; + } + + // To field. + EncodedStringValue[] diTo = headers.getEncodedStringValues(PduHeaders.TO); + if (null == diTo) { + return false; + } + + break; + case PduHeaders.MESSAGE_TYPE_ACKNOWLEDGE_IND: + // Transaction-Id field. + byte[] aiTransactionId = headers.getTextString(PduHeaders.TRANSACTION_ID); + if (null == aiTransactionId) { + return false; + } + + break; + case PduHeaders.MESSAGE_TYPE_READ_ORIG_IND: + // Date field. + long roDate = headers.getLongInteger(PduHeaders.DATE); + if (-1 == roDate) { + return false; + } + + // From field. + EncodedStringValue roFrom = headers.getEncodedStringValue(PduHeaders.FROM); + if (null == roFrom) { + return false; + } + + // Message-Id field. + byte[] roMessageId = headers.getTextString(PduHeaders.MESSAGE_ID); + if (null == roMessageId) { + return false; + } + + // Read-Status field. + int roReadStatus = headers.getOctet(PduHeaders.READ_STATUS); + if (0 == roReadStatus) { + return false; + } + + // To field. + EncodedStringValue[] roTo = headers.getEncodedStringValues(PduHeaders.TO); + if (null == roTo) { + return false; + } + + break; + case PduHeaders.MESSAGE_TYPE_READ_REC_IND: + // From field. + EncodedStringValue rrFrom = headers.getEncodedStringValue(PduHeaders.FROM); + if (null == rrFrom) { + return false; + } + + // Message-Id field. + byte[] rrMessageId = headers.getTextString(PduHeaders.MESSAGE_ID); + if (null == rrMessageId) { + return false; + } + + // Read-Status field. + int rrReadStatus = headers.getOctet(PduHeaders.READ_STATUS); + if (0 == rrReadStatus) { + return false; + } + + // To field. + EncodedStringValue[] rrTo = headers.getEncodedStringValues(PduHeaders.TO); + if (null == rrTo) { + return false; + } + + break; + default: + // Parser doesn't support this message type in this version. + return false; + } + + return true; + } +} diff --git a/src/com/android/messaging/mmslib/pdu/PduPart.java b/src/com/android/messaging/mmslib/pdu/PduPart.java new file mode 100644 index 0000000..dcdb7a6 --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/PduPart.java @@ -0,0 +1,389 @@ +/* + * Copyright (C) 2007-2008 Esmertec AG. + * Copyright (C) 2007-2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import android.net.Uri; +import android.util.SparseArray; + +/** + * The pdu part. + */ +public class PduPart { + /** + * Well-Known Parameters. + */ + public static final int P_Q = 0x80; + public static final int P_CHARSET = 0x81; + public static final int P_LEVEL = 0x82; + public static final int P_TYPE = 0x83; + public static final int P_DEP_NAME = 0x85; + public static final int P_DEP_FILENAME = 0x86; + public static final int P_DIFFERENCES = 0x87; + public static final int P_PADDING = 0x88; + // This value of "TYPE" s used with Content-Type: multipart/related + public static final int P_CT_MR_TYPE = 0x89; + public static final int P_DEP_START = 0x8A; + public static final int P_DEP_START_INFO = 0x8B; + public static final int P_DEP_COMMENT = 0x8C; + public static final int P_DEP_DOMAIN = 0x8D; + public static final int P_MAX_AGE = 0x8E; + public static final int P_DEP_PATH = 0x8F; + public static final int P_SECURE = 0x90; + public static final int P_SEC = 0x91; + public static final int P_MAC = 0x92; + public static final int P_CREATION_DATE = 0x93; + public static final int P_MODIFICATION_DATE = 0x94; + public static final int P_READ_DATE = 0x95; + public static final int P_SIZE = 0x96; + public static final int P_NAME = 0x97; + public static final int P_FILENAME = 0x98; + public static final int P_START = 0x99; + public static final int P_START_INFO = 0x9A; + public static final int P_COMMENT = 0x9B; + public static final int P_DOMAIN = 0x9C; + public static final int P_PATH = 0x9D; + + /** + * Header field names. + */ + public static final int P_CONTENT_TYPE = 0x91; + public static final int P_CONTENT_LOCATION = 0x8E; + public static final int P_CONTENT_ID = 0xC0; + public static final int P_DEP_CONTENT_DISPOSITION = 0xAE; + public static final int P_CONTENT_DISPOSITION = 0xC5; + // The next header is unassigned header, use reserved header(0x48) value. + public static final int P_CONTENT_TRANSFER_ENCODING = 0xC8; + + /** + * Content=Transfer-Encoding string. + */ + public static final String CONTENT_TRANSFER_ENCODING = + "Content-Transfer-Encoding"; + + /** + * Value of Content-Transfer-Encoding. + */ + public static final String P_BINARY = "binary"; + public static final String P_7BIT = "7bit"; + public static final String P_8BIT = "8bit"; + public static final String P_BASE64 = "base64"; + public static final String P_QUOTED_PRINTABLE = "quoted-printable"; + + /** + * Value of disposition can be set to PduPart when the value is octet in + * the PDU. + * "from-data" instead of Form-data. + * "attachment" instead of Attachment. + * "inline" instead of Inline. + */ + static final byte[] DISPOSITION_FROM_DATA = "from-data".getBytes(); + static final byte[] DISPOSITION_ATTACHMENT = "attachment".getBytes(); + static final byte[] DISPOSITION_INLINE = "inline".getBytes(); + + /** + * Content-Disposition value. + */ + public static final int P_DISPOSITION_FROM_DATA = 0x80; + public static final int P_DISPOSITION_ATTACHMENT = 0x81; + public static final int P_DISPOSITION_INLINE = 0x82; + + /** + * Header of part. + */ + private SparseArray mPartHeader = null; + + /** + * Data uri. + */ + private Uri mUri = null; + + /** + * Part data. + */ + private byte[] mPartData = null; + + private static final String TAG = "PduPart"; + + /** + * Empty Constructor. + */ + public PduPart() { + mPartHeader = new SparseArray(); + } + + /** + * Set part data. The data are stored as byte array. + * + * @param data the data + */ + public void setData(final byte[] data) { + mPartData = data; + } + + /** + * @return The part data or null if the data wasn't set or + * the data is stored as Uri. + * @see #getDataUri + */ + public byte[] getData() { + return mPartData; + } + + /** + * Set data uri. The data are stored as Uri. + * + * @param uri the uri + */ + public void setDataUri(final Uri uri) { + mUri = uri; + } + + /** + * @return The Uri of the part data or null if the data wasn't set or + * the data is stored as byte array. + * @see #getData + */ + public Uri getDataUri() { + return mUri; + } + + /** + * Set Content-id value + * + * @param contentId the content-id value + * @throws NullPointerException if the value is null. + */ + public void setContentId(final byte[] contentId) { + if ((contentId == null) || (contentId.length == 0)) { + throw new IllegalArgumentException( + "Content-Id may not be null or empty."); + } + + if ((contentId.length > 1) + && ((char) contentId[0] == '<') + && ((char) contentId[contentId.length - 1] == '>')) { + mPartHeader.put(P_CONTENT_ID, contentId); + return; + } + + // Insert beginning '<' and trailing '>' for Content-Id. + final byte[] buffer = new byte[contentId.length + 2]; + buffer[0] = (byte) (0xff & '<'); + buffer[buffer.length - 1] = (byte) (0xff & '>'); + System.arraycopy(contentId, 0, buffer, 1, contentId.length); + mPartHeader.put(P_CONTENT_ID, buffer); + } + + /** + * Get Content-id value. + * + * @return the value + */ + public byte[] getContentId() { + return (byte[]) mPartHeader.get(P_CONTENT_ID); + } + + /** + * Set Char-set value. + * + * @param charset the value + */ + public void setCharset(final int charset) { + mPartHeader.put(P_CHARSET, charset); + } + + /** + * Get Char-set value + * + * @return the charset value. Return 0 if charset was not set. + */ + public int getCharset() { + final Integer charset = (Integer) mPartHeader.get(P_CHARSET); + if (charset == null) { + return 0; + } else { + return charset.intValue(); + } + } + + /** + * Set Content-Location value. + * + * @param contentLocation the value + * @throws NullPointerException if the value is null. + */ + public void setContentLocation(final byte[] contentLocation) { + if (contentLocation == null) { + throw new NullPointerException("null content-location"); + } + + mPartHeader.put(P_CONTENT_LOCATION, contentLocation); + } + + /** + * Get Content-Location value. + * + * @return the value + * return PduPart.disposition[0] instead of (Form-data). + * return PduPart.disposition[1] instead of (Attachment). + * return PduPart.disposition[2] instead of (Inline). + */ + public byte[] getContentLocation() { + return (byte[]) mPartHeader.get(P_CONTENT_LOCATION); + } + + /** + * Set Content-Disposition value. + * Use PduPart.disposition[0] instead of (Form-data). + * Use PduPart.disposition[1] instead of (Attachment). + * Use PduPart.disposition[2] instead of (Inline). + * + * @param contentDisposition the value + * @throws NullPointerException if the value is null. + */ + public void setContentDisposition(final byte[] contentDisposition) { + if (contentDisposition == null) { + throw new NullPointerException("null content-disposition"); + } + + mPartHeader.put(P_CONTENT_DISPOSITION, contentDisposition); + } + + /** + * Get Content-Disposition value. + * + * @return the value + */ + public byte[] getContentDisposition() { + return (byte[]) mPartHeader.get(P_CONTENT_DISPOSITION); + } + + /** + * Set Content-Type value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setContentType(final byte[] contentType) { + if (contentType == null) { + throw new NullPointerException("null content-type"); + } + + mPartHeader.put(P_CONTENT_TYPE, contentType); + } + + /** + * Get Content-Type value of part. + * + * @return the value + */ + public byte[] getContentType() { + return (byte[]) mPartHeader.get(P_CONTENT_TYPE); + } + + /** + * Set Content-Transfer-Encoding value + * + * @param contentId the content-id value + * @throws NullPointerException if the value is null. + */ + public void setContentTransferEncoding(final byte[] contentTransferEncoding) { + if (contentTransferEncoding == null) { + throw new NullPointerException("null content-transfer-encoding"); + } + + mPartHeader.put(P_CONTENT_TRANSFER_ENCODING, contentTransferEncoding); + } + + /** + * Get Content-Transfer-Encoding value. + * + * @return the value + */ + public byte[] getContentTransferEncoding() { + return (byte[]) mPartHeader.get(P_CONTENT_TRANSFER_ENCODING); + } + + /** + * Set Content-type parameter: name. + * + * @param name the name value + * @throws NullPointerException if the value is null. + */ + public void setName(final byte[] name) { + if (null == name) { + throw new NullPointerException("null content-id"); + } + + mPartHeader.put(P_NAME, name); + } + + /** + * Get content-type parameter: name. + * + * @return the name + */ + public byte[] getName() { + return (byte[]) mPartHeader.get(P_NAME); + } + + /** + * Get Content-disposition parameter: filename + * + * @param fileName the filename value + * @throws NullPointerException if the value is null. + */ + public void setFilename(final byte[] fileName) { + if (null == fileName) { + throw new NullPointerException("null content-id"); + } + + mPartHeader.put(P_FILENAME, fileName); + } + + /** + * Set Content-disposition parameter: filename + * + * @return the filename + */ + public byte[] getFilename() { + return (byte[]) mPartHeader.get(P_FILENAME); + } + + public String generateLocation() { + // Assumption: At least one of the content-location / name / filename + // or content-id should be set. This is guaranteed by the PduParser + // for incoming messages and by MM composer for outgoing messages. + byte[] location = (byte[]) mPartHeader.get(P_NAME); + if (null == location) { + location = (byte[]) mPartHeader.get(P_FILENAME); + + if (null == location) { + location = (byte[]) mPartHeader.get(P_CONTENT_LOCATION); + } + } + + if (null == location) { + final byte[] contentId = (byte[]) mPartHeader.get(P_CONTENT_ID); + return "cid:" + new String(contentId); + } else { + return new String(location); + } + } +} + diff --git a/src/com/android/messaging/mmslib/pdu/PduPersister.java b/src/com/android/messaging/mmslib/pdu/PduPersister.java new file mode 100644 index 0000000..ceb6a85 --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/PduPersister.java @@ -0,0 +1,1683 @@ +/* + * Copyright (C) 2007-2008 Esmertec AG. + * Copyright (C) 2007-2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteException; +import android.net.Uri; +import android.provider.MediaStore; +import android.provider.Telephony.Mms; +import android.provider.Telephony.Mms.Addr; +import android.provider.Telephony.Mms.Part; +import android.provider.Telephony.MmsSms; +import android.provider.Telephony.MmsSms.PendingMessages; +import android.support.v4.util.ArrayMap; +import android.support.v4.util.SimpleArrayMap; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; +import android.util.Log; +import android.util.SparseArray; +import android.util.SparseIntArray; + +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.mmslib.InvalidHeaderValueException; +import com.android.messaging.mmslib.MmsException; +import com.android.messaging.mmslib.SqliteWrapper; +import com.android.messaging.mmslib.util.DownloadDrmHelper; +import com.android.messaging.mmslib.util.DrmConvertSession; +import com.android.messaging.mmslib.util.PduCache; +import com.android.messaging.mmslib.util.PduCacheEntry; +import com.android.messaging.sms.MmsSmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Map; + +/** + * This class is the high-level manager of PDU storage. + */ +public class PduPersister { + private static final String TAG = "PduPersister"; + private static final boolean LOCAL_LOGV = false; + + /** + * The uri of temporary drm objects. + */ + public static final String TEMPORARY_DRM_OBJECT_URI = + "content://mms/" + Long.MAX_VALUE + "/part"; + + /** + * Indicate that we transiently failed to process a MM. + */ + public static final int PROC_STATUS_TRANSIENT_FAILURE = 1; + + /** + * Indicate that we permanently failed to process a MM. + */ + public static final int PROC_STATUS_PERMANENTLY_FAILURE = 2; + + /** + * Indicate that we have successfully processed a MM. + */ + public static final int PROC_STATUS_COMPLETED = 3; + + public static final String BEGIN_VCARD = "BEGIN:VCARD"; + + private static PduPersister sPersister; + + private static final PduCache PDU_CACHE_INSTANCE; + + private static final int[] ADDRESS_FIELDS = new int[]{ + PduHeaders.BCC, + PduHeaders.CC, + PduHeaders.FROM, + PduHeaders.TO + }; + + public static final String[] PDU_PROJECTION = new String[]{ + Mms._ID, + Mms.MESSAGE_BOX, + Mms.THREAD_ID, + Mms.RETRIEVE_TEXT, + Mms.SUBJECT, + Mms.CONTENT_LOCATION, + Mms.CONTENT_TYPE, + Mms.MESSAGE_CLASS, + Mms.MESSAGE_ID, + Mms.RESPONSE_TEXT, + Mms.TRANSACTION_ID, + Mms.CONTENT_CLASS, + Mms.DELIVERY_REPORT, + Mms.MESSAGE_TYPE, + Mms.MMS_VERSION, + Mms.PRIORITY, + Mms.READ_REPORT, + Mms.READ_STATUS, + Mms.REPORT_ALLOWED, + Mms.RETRIEVE_STATUS, + Mms.STATUS, + Mms.DATE, + Mms.DELIVERY_TIME, + Mms.EXPIRY, + Mms.MESSAGE_SIZE, + Mms.SUBJECT_CHARSET, + Mms.RETRIEVE_TEXT_CHARSET, + Mms.READ, + Mms.SEEN, + }; + + public static final int PDU_COLUMN_ID = 0; + public static final int PDU_COLUMN_MESSAGE_BOX = 1; + public static final int PDU_COLUMN_THREAD_ID = 2; + public static final int PDU_COLUMN_RETRIEVE_TEXT = 3; + public static final int PDU_COLUMN_SUBJECT = 4; + public static final int PDU_COLUMN_CONTENT_LOCATION = 5; + public static final int PDU_COLUMN_CONTENT_TYPE = 6; + public static final int PDU_COLUMN_MESSAGE_CLASS = 7; + public static final int PDU_COLUMN_MESSAGE_ID = 8; + public static final int PDU_COLUMN_RESPONSE_TEXT = 9; + public static final int PDU_COLUMN_TRANSACTION_ID = 10; + public static final int PDU_COLUMN_CONTENT_CLASS = 11; + public static final int PDU_COLUMN_DELIVERY_REPORT = 12; + public static final int PDU_COLUMN_MESSAGE_TYPE = 13; + public static final int PDU_COLUMN_MMS_VERSION = 14; + public static final int PDU_COLUMN_PRIORITY = 15; + public static final int PDU_COLUMN_READ_REPORT = 16; + public static final int PDU_COLUMN_READ_STATUS = 17; + public static final int PDU_COLUMN_REPORT_ALLOWED = 18; + public static final int PDU_COLUMN_RETRIEVE_STATUS = 19; + public static final int PDU_COLUMN_STATUS = 20; + public static final int PDU_COLUMN_DATE = 21; + public static final int PDU_COLUMN_DELIVERY_TIME = 22; + public static final int PDU_COLUMN_EXPIRY = 23; + public static final int PDU_COLUMN_MESSAGE_SIZE = 24; + public static final int PDU_COLUMN_SUBJECT_CHARSET = 25; + public static final int PDU_COLUMN_RETRIEVE_TEXT_CHARSET = 26; + public static final int PDU_COLUMN_READ = 27; + public static final int PDU_COLUMN_SEEN = 28; + + private static final String[] PART_PROJECTION = new String[] { + Part._ID, + Part.CHARSET, + Part.CONTENT_DISPOSITION, + Part.CONTENT_ID, + Part.CONTENT_LOCATION, + Part.CONTENT_TYPE, + Part.FILENAME, + Part.NAME, + Part.TEXT + }; + + private static final int PART_COLUMN_ID = 0; + private static final int PART_COLUMN_CHARSET = 1; + private static final int PART_COLUMN_CONTENT_DISPOSITION = 2; + private static final int PART_COLUMN_CONTENT_ID = 3; + private static final int PART_COLUMN_CONTENT_LOCATION = 4; + private static final int PART_COLUMN_CONTENT_TYPE = 5; + private static final int PART_COLUMN_FILENAME = 6; + private static final int PART_COLUMN_NAME = 7; + private static final int PART_COLUMN_TEXT = 8; + + private static final SimpleArrayMap MESSAGE_BOX_MAP; + + // These map are used for convenience in persist() and load(). + private static final SparseIntArray CHARSET_COLUMN_INDEX_MAP; + + private static final SparseIntArray ENCODED_STRING_COLUMN_INDEX_MAP; + + private static final SparseIntArray TEXT_STRING_COLUMN_INDEX_MAP; + + private static final SparseIntArray OCTET_COLUMN_INDEX_MAP; + + private static final SparseIntArray LONG_COLUMN_INDEX_MAP; + + private static final SparseArray CHARSET_COLUMN_NAME_MAP; + + private static final SparseArray ENCODED_STRING_COLUMN_NAME_MAP; + + private static final SparseArray TEXT_STRING_COLUMN_NAME_MAP; + + private static final SparseArray OCTET_COLUMN_NAME_MAP; + + private static final SparseArray LONG_COLUMN_NAME_MAP; + + static { + MESSAGE_BOX_MAP = new SimpleArrayMap(); + MESSAGE_BOX_MAP.put(Mms.Inbox.CONTENT_URI, Mms.MESSAGE_BOX_INBOX); + MESSAGE_BOX_MAP.put(Mms.Sent.CONTENT_URI, Mms.MESSAGE_BOX_SENT); + MESSAGE_BOX_MAP.put(Mms.Draft.CONTENT_URI, Mms.MESSAGE_BOX_DRAFTS); + MESSAGE_BOX_MAP.put(Mms.Outbox.CONTENT_URI, Mms.MESSAGE_BOX_OUTBOX); + + CHARSET_COLUMN_INDEX_MAP = new SparseIntArray(); + CHARSET_COLUMN_INDEX_MAP.put(PduHeaders.SUBJECT, PDU_COLUMN_SUBJECT_CHARSET); + CHARSET_COLUMN_INDEX_MAP.put(PduHeaders.RETRIEVE_TEXT, PDU_COLUMN_RETRIEVE_TEXT_CHARSET); + + CHARSET_COLUMN_NAME_MAP = new SparseArray(); + CHARSET_COLUMN_NAME_MAP.put(PduHeaders.SUBJECT, Mms.SUBJECT_CHARSET); + CHARSET_COLUMN_NAME_MAP.put(PduHeaders.RETRIEVE_TEXT, Mms.RETRIEVE_TEXT_CHARSET); + + // Encoded string field code -> column index/name map. + ENCODED_STRING_COLUMN_INDEX_MAP = new SparseIntArray(); + ENCODED_STRING_COLUMN_INDEX_MAP.put(PduHeaders.RETRIEVE_TEXT, PDU_COLUMN_RETRIEVE_TEXT); + ENCODED_STRING_COLUMN_INDEX_MAP.put(PduHeaders.SUBJECT, PDU_COLUMN_SUBJECT); + + ENCODED_STRING_COLUMN_NAME_MAP = new SparseArray(); + ENCODED_STRING_COLUMN_NAME_MAP.put(PduHeaders.RETRIEVE_TEXT, Mms.RETRIEVE_TEXT); + ENCODED_STRING_COLUMN_NAME_MAP.put(PduHeaders.SUBJECT, Mms.SUBJECT); + + // Text string field code -> column index/name map. + TEXT_STRING_COLUMN_INDEX_MAP = new SparseIntArray(); + TEXT_STRING_COLUMN_INDEX_MAP.put(PduHeaders.CONTENT_LOCATION, PDU_COLUMN_CONTENT_LOCATION); + TEXT_STRING_COLUMN_INDEX_MAP.put(PduHeaders.CONTENT_TYPE, PDU_COLUMN_CONTENT_TYPE); + TEXT_STRING_COLUMN_INDEX_MAP.put(PduHeaders.MESSAGE_CLASS, PDU_COLUMN_MESSAGE_CLASS); + TEXT_STRING_COLUMN_INDEX_MAP.put(PduHeaders.MESSAGE_ID, PDU_COLUMN_MESSAGE_ID); + TEXT_STRING_COLUMN_INDEX_MAP.put(PduHeaders.RESPONSE_TEXT, PDU_COLUMN_RESPONSE_TEXT); + TEXT_STRING_COLUMN_INDEX_MAP.put(PduHeaders.TRANSACTION_ID, PDU_COLUMN_TRANSACTION_ID); + + TEXT_STRING_COLUMN_NAME_MAP = new SparseArray(); + TEXT_STRING_COLUMN_NAME_MAP.put(PduHeaders.CONTENT_LOCATION, Mms.CONTENT_LOCATION); + TEXT_STRING_COLUMN_NAME_MAP.put(PduHeaders.CONTENT_TYPE, Mms.CONTENT_TYPE); + TEXT_STRING_COLUMN_NAME_MAP.put(PduHeaders.MESSAGE_CLASS, Mms.MESSAGE_CLASS); + TEXT_STRING_COLUMN_NAME_MAP.put(PduHeaders.MESSAGE_ID, Mms.MESSAGE_ID); + TEXT_STRING_COLUMN_NAME_MAP.put(PduHeaders.RESPONSE_TEXT, Mms.RESPONSE_TEXT); + TEXT_STRING_COLUMN_NAME_MAP.put(PduHeaders.TRANSACTION_ID, Mms.TRANSACTION_ID); + + // Octet field code -> column index/name map. + OCTET_COLUMN_INDEX_MAP = new SparseIntArray(); + OCTET_COLUMN_INDEX_MAP.put(PduHeaders.CONTENT_CLASS, PDU_COLUMN_CONTENT_CLASS); + OCTET_COLUMN_INDEX_MAP.put(PduHeaders.DELIVERY_REPORT, PDU_COLUMN_DELIVERY_REPORT); + OCTET_COLUMN_INDEX_MAP.put(PduHeaders.MESSAGE_TYPE, PDU_COLUMN_MESSAGE_TYPE); + OCTET_COLUMN_INDEX_MAP.put(PduHeaders.MMS_VERSION, PDU_COLUMN_MMS_VERSION); + OCTET_COLUMN_INDEX_MAP.put(PduHeaders.PRIORITY, PDU_COLUMN_PRIORITY); + OCTET_COLUMN_INDEX_MAP.put(PduHeaders.READ_REPORT, PDU_COLUMN_READ_REPORT); + OCTET_COLUMN_INDEX_MAP.put(PduHeaders.READ_STATUS, PDU_COLUMN_READ_STATUS); + OCTET_COLUMN_INDEX_MAP.put(PduHeaders.REPORT_ALLOWED, PDU_COLUMN_REPORT_ALLOWED); + OCTET_COLUMN_INDEX_MAP.put(PduHeaders.RETRIEVE_STATUS, PDU_COLUMN_RETRIEVE_STATUS); + OCTET_COLUMN_INDEX_MAP.put(PduHeaders.STATUS, PDU_COLUMN_STATUS); + + OCTET_COLUMN_NAME_MAP = new SparseArray(); + OCTET_COLUMN_NAME_MAP.put(PduHeaders.CONTENT_CLASS, Mms.CONTENT_CLASS); + OCTET_COLUMN_NAME_MAP.put(PduHeaders.DELIVERY_REPORT, Mms.DELIVERY_REPORT); + OCTET_COLUMN_NAME_MAP.put(PduHeaders.MESSAGE_TYPE, Mms.MESSAGE_TYPE); + OCTET_COLUMN_NAME_MAP.put(PduHeaders.MMS_VERSION, Mms.MMS_VERSION); + OCTET_COLUMN_NAME_MAP.put(PduHeaders.PRIORITY, Mms.PRIORITY); + OCTET_COLUMN_NAME_MAP.put(PduHeaders.READ_REPORT, Mms.READ_REPORT); + OCTET_COLUMN_NAME_MAP.put(PduHeaders.READ_STATUS, Mms.READ_STATUS); + OCTET_COLUMN_NAME_MAP.put(PduHeaders.REPORT_ALLOWED, Mms.REPORT_ALLOWED); + OCTET_COLUMN_NAME_MAP.put(PduHeaders.RETRIEVE_STATUS, Mms.RETRIEVE_STATUS); + OCTET_COLUMN_NAME_MAP.put(PduHeaders.STATUS, Mms.STATUS); + + // Long field code -> column index/name map. + LONG_COLUMN_INDEX_MAP = new SparseIntArray(); + LONG_COLUMN_INDEX_MAP.put(PduHeaders.DATE, PDU_COLUMN_DATE); + LONG_COLUMN_INDEX_MAP.put(PduHeaders.DELIVERY_TIME, PDU_COLUMN_DELIVERY_TIME); + LONG_COLUMN_INDEX_MAP.put(PduHeaders.EXPIRY, PDU_COLUMN_EXPIRY); + LONG_COLUMN_INDEX_MAP.put(PduHeaders.MESSAGE_SIZE, PDU_COLUMN_MESSAGE_SIZE); + + LONG_COLUMN_NAME_MAP = new SparseArray(); + LONG_COLUMN_NAME_MAP.put(PduHeaders.DATE, Mms.DATE); + LONG_COLUMN_NAME_MAP.put(PduHeaders.DELIVERY_TIME, Mms.DELIVERY_TIME); + LONG_COLUMN_NAME_MAP.put(PduHeaders.EXPIRY, Mms.EXPIRY); + LONG_COLUMN_NAME_MAP.put(PduHeaders.MESSAGE_SIZE, Mms.MESSAGE_SIZE); + + PDU_CACHE_INSTANCE = PduCache.getInstance(); + } + + private final Context mContext; + + private final ContentResolver mContentResolver; + + private PduPersister(final Context context) { + mContext = context; + mContentResolver = context.getContentResolver(); + } + + /** Get(or create if not exist) an instance of PduPersister */ + public static PduPersister getPduPersister(final Context context) { + if ((sPersister == null) || !context.equals(sPersister.mContext)) { + sPersister = new PduPersister(context); + } + if (LOCAL_LOGV) { + LogUtil.v(TAG, "PduPersister getPduPersister"); + } + + return sPersister; + } + + private void setEncodedStringValueToHeaders( + final Cursor c, final int columnIndex, + final PduHeaders headers, final int mapColumn) { + final String s = c.getString(columnIndex); + if ((s != null) && (s.length() > 0)) { + final int charsetColumnIndex = CHARSET_COLUMN_INDEX_MAP.get(mapColumn); + final int charset = c.getInt(charsetColumnIndex); + final EncodedStringValue value = new EncodedStringValue( + charset, getBytes(s)); + headers.setEncodedStringValue(value, mapColumn); + } + } + + private void setTextStringToHeaders( + final Cursor c, final int columnIndex, + final PduHeaders headers, final int mapColumn) { + final String s = c.getString(columnIndex); + if (s != null) { + headers.setTextString(getBytes(s), mapColumn); + } + } + + private void setOctetToHeaders( + final Cursor c, final int columnIndex, + final PduHeaders headers, final int mapColumn) throws InvalidHeaderValueException { + if (!c.isNull(columnIndex)) { + final int b = c.getInt(columnIndex); + headers.setOctet(b, mapColumn); + } + } + + private void setLongToHeaders( + final Cursor c, final int columnIndex, + final PduHeaders headers, final int mapColumn) { + if (!c.isNull(columnIndex)) { + final long l = c.getLong(columnIndex); + headers.setLongInteger(l, mapColumn); + } + } + + private Integer getIntegerFromPartColumn(final Cursor c, final int columnIndex) { + if (!c.isNull(columnIndex)) { + return c.getInt(columnIndex); + } + return null; + } + + private byte[] getByteArrayFromPartColumn(final Cursor c, final int columnIndex) { + if (!c.isNull(columnIndex)) { + return getBytes(c.getString(columnIndex)); + } + return null; + } + + private PduPart[] loadParts(final long msgId) throws MmsException { + final Cursor c = SqliteWrapper.query(mContext, mContentResolver, + Uri.parse("content://mms/" + msgId + "/part"), + PART_PROJECTION, null, null, null); + + PduPart[] parts = null; + + try { + if ((c == null) || (c.getCount() == 0)) { + if (LOCAL_LOGV) { + LogUtil.v(TAG, "loadParts(" + msgId + "): no part to load."); + } + return null; + } + + final int partCount = c.getCount(); + int partIdx = 0; + parts = new PduPart[partCount]; + while (c.moveToNext()) { + final PduPart part = new PduPart(); + final Integer charset = getIntegerFromPartColumn( + c, PART_COLUMN_CHARSET); + if (charset != null) { + part.setCharset(charset); + } + + final byte[] contentDisposition = getByteArrayFromPartColumn( + c, PART_COLUMN_CONTENT_DISPOSITION); + if (contentDisposition != null) { + part.setContentDisposition(contentDisposition); + } + + final byte[] contentId = getByteArrayFromPartColumn( + c, PART_COLUMN_CONTENT_ID); + if (contentId != null) { + part.setContentId(contentId); + } + + final byte[] contentLocation = getByteArrayFromPartColumn( + c, PART_COLUMN_CONTENT_LOCATION); + if (contentLocation != null) { + part.setContentLocation(contentLocation); + } + + final byte[] contentType = getByteArrayFromPartColumn( + c, PART_COLUMN_CONTENT_TYPE); + if (contentType != null) { + part.setContentType(contentType); + } else { + throw new MmsException("Content-Type must be set."); + } + + final byte[] fileName = getByteArrayFromPartColumn( + c, PART_COLUMN_FILENAME); + if (fileName != null) { + part.setFilename(fileName); + } + + final byte[] name = getByteArrayFromPartColumn( + c, PART_COLUMN_NAME); + if (name != null) { + part.setName(name); + } + + // Construct a Uri for this part. + final long partId = c.getLong(PART_COLUMN_ID); + final Uri partURI = Uri.parse("content://mms/part/" + partId); + part.setDataUri(partURI); + + // For images/audio/video, we won't keep their data in Part + // because their renderer accept Uri as source. + final String type = toIsoString(contentType); + if (!ContentType.isImageType(type) + && !ContentType.isAudioType(type) + && !ContentType.isVideoType(type)) { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + InputStream is = null; + + // Store simple string values directly in the database instead of an + // external file. This makes the text searchable and retrieval slightly + // faster. + if (ContentType.TEXT_PLAIN.equals(type) || ContentType.APP_SMIL.equals(type) + || ContentType.TEXT_HTML.equals(type)) { + final String text = c.getString(PART_COLUMN_TEXT); + final byte[] blob = new EncodedStringValue( + charset != null ? charset : CharacterSets.DEFAULT_CHARSET, + text != null ? text : "") + .getTextString(); + baos.write(blob, 0, blob.length); + } else { + + try { + is = mContentResolver.openInputStream(partURI); + + final byte[] buffer = new byte[256]; + int len = is.read(buffer); + while (len >= 0) { + baos.write(buffer, 0, len); + len = is.read(buffer); + } + } catch (final IOException e) { + Log.e(TAG, "Failed to load part data", e); + c.close(); + throw new MmsException(e); + } finally { + if (is != null) { + try { + is.close(); + } catch (final IOException e) { + Log.e(TAG, "Failed to close stream", e); + } // Ignore + } + } + } + part.setData(baos.toByteArray()); + } + parts[partIdx++] = part; + } + } finally { + if (c != null) { + c.close(); + } + } + + return parts; + } + + private void loadAddress(final long msgId, final PduHeaders headers) { + final Cursor c = SqliteWrapper.query(mContext, mContentResolver, + Uri.parse("content://mms/" + msgId + "/addr"), + new String[]{Addr.ADDRESS, Addr.CHARSET, Addr.TYPE}, + null, null, null); + + if (c != null) { + try { + while (c.moveToNext()) { + final String addr = c.getString(0); + if (!TextUtils.isEmpty(addr)) { + final int addrType = c.getInt(2); + switch (addrType) { + case PduHeaders.FROM: + headers.setEncodedStringValue( + new EncodedStringValue(c.getInt(1), getBytes(addr)), + addrType); + break; + case PduHeaders.TO: + case PduHeaders.CC: + case PduHeaders.BCC: + headers.appendEncodedStringValue( + new EncodedStringValue(c.getInt(1), getBytes(addr)), + addrType); + break; + default: + Log.e(TAG, "Unknown address type: " + addrType); + break; + } + } + } + } finally { + c.close(); + } + } + } + + /** + * Load a PDU from a given cursor + * + * @param c The cursor + * @return A parsed PDU from the database row + */ + public GenericPdu load(final Cursor c) throws MmsException { + final PduHeaders headers = new PduHeaders(); + final long msgId = c.getLong(PDU_COLUMN_ID); + // Fill in the headers from the PDU columns + loadHeadersFromCursor(c, headers); + // Load address information of the MM. + loadAddress(msgId, headers); + // Load parts for the PDU body + final int msgType = headers.getOctet(PduHeaders.MESSAGE_TYPE); + final PduBody body = loadBody(msgId, msgType); + return createPdu(msgType, headers, body); + } + + /** + * Load a PDU from storage by given Uri. + * + * @param uri The Uri of the PDU to be loaded. + * @return A generic PDU object, it may be cast to dedicated PDU. + * @throws MmsException Failed to load some fields of a PDU. + */ + public GenericPdu load(final Uri uri) throws MmsException { + GenericPdu pdu = null; + PduCacheEntry cacheEntry = null; + int msgBox = 0; + final long threadId = -1; + try { + synchronized (PDU_CACHE_INSTANCE) { + if (PDU_CACHE_INSTANCE.isUpdating(uri)) { + if (LOCAL_LOGV) { + LogUtil.v(TAG, "load: " + uri + " blocked by isUpdating()"); + } + try { + PDU_CACHE_INSTANCE.wait(); + } catch (final InterruptedException e) { + Log.e(TAG, "load: ", e); + } + } + + // Check if the pdu is already loaded + cacheEntry = PDU_CACHE_INSTANCE.get(uri); + if (cacheEntry != null) { + return cacheEntry.getPdu(); + } + + // Tell the cache to indicate to other callers that this item + // is currently being updated. + PDU_CACHE_INSTANCE.setUpdating(uri, true); + } + + final Cursor c = SqliteWrapper.query(mContext, mContentResolver, uri, + PDU_PROJECTION, null, null, null); + final PduHeaders headers = new PduHeaders(); + final long msgId = ContentUris.parseId(uri); + + try { + if ((c == null) || (c.getCount() != 1) || !c.moveToFirst()) { + return null; // MMS not found + } + + msgBox = c.getInt(PDU_COLUMN_MESSAGE_BOX); + //threadId = c.getLong(PDU_COLUMN_THREAD_ID); + loadHeadersFromCursor(c, headers); + } finally { + if (c != null) { + c.close(); + } + } + + // Check whether 'msgId' has been assigned a valid value. + if (msgId == -1L) { + throw new MmsException("Error! ID of the message: -1."); + } + + // Load address information of the MM. + loadAddress(msgId, headers); + + final int msgType = headers.getOctet(PduHeaders.MESSAGE_TYPE); + final PduBody body = loadBody(msgId, msgType); + pdu = createPdu(msgType, headers, body); + } finally { + synchronized (PDU_CACHE_INSTANCE) { + if (pdu != null) { + Assert.isNull(PDU_CACHE_INSTANCE.get(uri), "Pdu exists for " + uri); + // Update the cache entry with the real info + cacheEntry = new PduCacheEntry(pdu, msgBox, threadId); + PDU_CACHE_INSTANCE.put(uri, cacheEntry); + } + PDU_CACHE_INSTANCE.setUpdating(uri, false); + PDU_CACHE_INSTANCE.notifyAll(); // tell anybody waiting on this entry to go ahead + } + } + return pdu; + } + + private void loadHeadersFromCursor(final Cursor c, final PduHeaders headers) + throws InvalidHeaderValueException { + for (int i = ENCODED_STRING_COLUMN_INDEX_MAP.size(); --i >= 0; ) { + setEncodedStringValueToHeaders( + c, ENCODED_STRING_COLUMN_INDEX_MAP.valueAt(i), headers, + ENCODED_STRING_COLUMN_INDEX_MAP.keyAt(i)); + } + for (int i = TEXT_STRING_COLUMN_INDEX_MAP.size(); --i >= 0; ) { + setTextStringToHeaders( + c, TEXT_STRING_COLUMN_INDEX_MAP.valueAt(i), headers, + TEXT_STRING_COLUMN_INDEX_MAP.keyAt(i)); + } + for (int i = OCTET_COLUMN_INDEX_MAP.size(); --i >= 0; ) { + setOctetToHeaders( + c, OCTET_COLUMN_INDEX_MAP.valueAt(i), headers, + OCTET_COLUMN_INDEX_MAP.keyAt(i)); + } + for (int i = LONG_COLUMN_INDEX_MAP.size(); --i >= 0; ) { + setLongToHeaders( + c, LONG_COLUMN_INDEX_MAP.valueAt(i), headers, + LONG_COLUMN_INDEX_MAP.keyAt(i)); + } + } + + private GenericPdu createPdu(final int msgType, final PduHeaders headers, final PduBody body) + throws MmsException { + switch (msgType) { + case PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND: + return new NotificationInd(headers); + case PduHeaders.MESSAGE_TYPE_DELIVERY_IND: + return new DeliveryInd(headers); + case PduHeaders.MESSAGE_TYPE_READ_ORIG_IND: + return new ReadOrigInd(headers); + case PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF: + return new RetrieveConf(headers, body); + case PduHeaders.MESSAGE_TYPE_SEND_REQ: + return new SendReq(headers, body); + case PduHeaders.MESSAGE_TYPE_ACKNOWLEDGE_IND: + return new AcknowledgeInd(headers); + case PduHeaders.MESSAGE_TYPE_NOTIFYRESP_IND: + return new NotifyRespInd(headers); + case PduHeaders.MESSAGE_TYPE_READ_REC_IND: + return new ReadRecInd(headers); + case PduHeaders.MESSAGE_TYPE_SEND_CONF: + case PduHeaders.MESSAGE_TYPE_FORWARD_REQ: + case PduHeaders.MESSAGE_TYPE_FORWARD_CONF: + case PduHeaders.MESSAGE_TYPE_MBOX_STORE_REQ: + case PduHeaders.MESSAGE_TYPE_MBOX_STORE_CONF: + case PduHeaders.MESSAGE_TYPE_MBOX_VIEW_REQ: + case PduHeaders.MESSAGE_TYPE_MBOX_VIEW_CONF: + case PduHeaders.MESSAGE_TYPE_MBOX_UPLOAD_REQ: + case PduHeaders.MESSAGE_TYPE_MBOX_UPLOAD_CONF: + case PduHeaders.MESSAGE_TYPE_MBOX_DELETE_REQ: + case PduHeaders.MESSAGE_TYPE_MBOX_DELETE_CONF: + case PduHeaders.MESSAGE_TYPE_MBOX_DESCR: + case PduHeaders.MESSAGE_TYPE_DELETE_REQ: + case PduHeaders.MESSAGE_TYPE_DELETE_CONF: + case PduHeaders.MESSAGE_TYPE_CANCEL_REQ: + case PduHeaders.MESSAGE_TYPE_CANCEL_CONF: + throw new MmsException( + "Unsupported PDU type: " + Integer.toHexString(msgType)); + + default: + throw new MmsException( + "Unrecognized PDU type: " + Integer.toHexString(msgType)); + } + } + + private PduBody loadBody(final long msgId, final int msgType) throws MmsException { + final PduBody body = new PduBody(); + + // For PDU which type is M_retrieve.conf or Send.req, we should + // load multiparts and put them into the body of the PDU. + if ((msgType == PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF) + || (msgType == PduHeaders.MESSAGE_TYPE_SEND_REQ)) { + final PduPart[] parts = loadParts(msgId); + if (parts != null) { + final int partsNum = parts.length; + for (int i = 0; i < partsNum; i++) { + body.addPart(parts[i]); + } + } + } + + return body; + } + + private void persistAddress( + final long msgId, final int type, final EncodedStringValue[] array) { + final ContentValues values = new ContentValues(3); + + for (final EncodedStringValue addr : array) { + values.clear(); // Clear all values first. + values.put(Addr.ADDRESS, toIsoString(addr.getTextString())); + values.put(Addr.CHARSET, addr.getCharacterSet()); + values.put(Addr.TYPE, type); + + final Uri uri = Uri.parse("content://mms/" + msgId + "/addr"); + SqliteWrapper.insert(mContext, mContentResolver, uri, values); + } + } + + private static String getPartContentType(final PduPart part) { + return part.getContentType() == null ? null : toIsoString(part.getContentType()); + } + + private static void getValues(final PduPart part, final ContentValues values) { + byte[] bytes = part.getFilename(); + if (bytes != null) { + values.put(Part.FILENAME, new String(bytes)); + } + + bytes = part.getName(); + if (bytes != null) { + values.put(Part.NAME, new String(bytes)); + } + + bytes = part.getContentDisposition(); + if (bytes != null) { + values.put(Part.CONTENT_DISPOSITION, toIsoString(bytes)); + } + + bytes = part.getContentId(); + if (bytes != null) { + values.put(Part.CONTENT_ID, toIsoString(bytes)); + } + + bytes = part.getContentLocation(); + if (bytes != null) { + values.put(Part.CONTENT_LOCATION, toIsoString(bytes)); + } + } + + public Uri persistPart(final PduPart part, final long msgId, + final Map preOpenedFiles) throws MmsException { + final Uri uri = Uri.parse("content://mms/" + msgId + "/part"); + final ContentValues values = new ContentValues(8); + + final int charset = part.getCharset(); + if (charset != 0) { + values.put(Part.CHARSET, charset); + } + + String contentType = getPartContentType(part); + final byte[] data = part.getData(); + + if (LOCAL_LOGV) { + LogUtil.v(TAG, "PduPersister.persistPart part: " + uri + " contentType: " + + contentType); + } + + if (contentType != null) { + // There is no "image/jpg" in Android (and it's an invalid mimetype). + // Change it to "image/jpeg" + if (ContentType.IMAGE_JPG.equals(contentType)) { + contentType = ContentType.IMAGE_JPEG; + } + + // On somes phones, a vcard comes in as text/plain instead of text/v-card. + // Fix it if necessary. + if (ContentType.TEXT_PLAIN.equals(contentType) && data != null) { + // There might be a more efficient way to just check the beginning of the string + // without encoding the whole thing, but we're concerned that with various + // characters sets, just comparing the byte data to BEGIN_VCARD would not be + // reliable. + final String encodedDataString = new EncodedStringValue(charset, data).getString(); + if (encodedDataString != null && encodedDataString.startsWith(BEGIN_VCARD)) { + contentType = ContentType.TEXT_VCARD; + part.setContentType(contentType.getBytes()); + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "PduPersister.persistPart part: " + uri + " contentType: " + + contentType + " changing to vcard"); + } + } + } + + values.put(Part.CONTENT_TYPE, contentType); + // To ensure the SMIL part is always the first part. + if (ContentType.APP_SMIL.equals(contentType)) { + values.put(Part.SEQ, -1); + } + } else { + throw new MmsException("MIME type of the part must be set."); + } + + getValues(part, values); + + Uri res = null; + + try { + res = SqliteWrapper.insert(mContext, mContentResolver, uri, values); + } catch (IllegalStateException e) { + // Currently the MMS provider throws an IllegalStateException when it's out of space + LogUtil.e(TAG, "SqliteWrapper.insert threw: ", e); + } + + if (res == null) { + throw new MmsException("Failed to persist part, return null."); + } + + persistData(part, res, contentType, preOpenedFiles); + // After successfully store the data, we should update + // the dataUri of the part. + part.setDataUri(res); + + return res; + } + + /** + * Save data of the part into storage. The source data may be given + * by a byte[] or a Uri. If it's a byte[], directly save it + * into storage, otherwise load source data from the dataUri and then + * save it. If the data is an image, we may scale down it according + * to user preference. + * + * @param part The PDU part which contains data to be saved. + * @param uri The URI of the part. + * @param contentType The MIME type of the part. + * @param preOpenedFiles if not null, a map of preopened InputStreams for the parts. + * @throws MmsException Cannot find source data or error occurred + * while saving the data. + */ + private void persistData(final PduPart part, final Uri uri, + final String contentType, final Map preOpenedFiles) + throws MmsException { + OutputStream os = null; + InputStream is = null; + DrmConvertSession drmConvertSession = null; + Uri dataUri = null; + String path = null; + + try { + final byte[] data = part.getData(); + final int charset = part.getCharset(); + if (ContentType.TEXT_PLAIN.equals(contentType) + || ContentType.APP_SMIL.equals(contentType) + || ContentType.TEXT_HTML.equals(contentType)) { + // Some phone could send MMS with a text part having empty data + // Let's just skip those parts. + // EncodedStringValue() throws NPE if data is empty + if (data != null) { + final ContentValues cv = new ContentValues(); + cv.put(Mms.Part.TEXT, new EncodedStringValue(charset, data).getString()); + if (mContentResolver.update(uri, cv, null, null) != 1) { + throw new MmsException("unable to update " + uri.toString()); + } + } + } else { + final boolean isDrm = DownloadDrmHelper.isDrmConvertNeeded(contentType); + if (isDrm) { + if (uri != null) { + try { + path = convertUriToPath(mContext, uri); + if (LOCAL_LOGV) { + LogUtil.v(TAG, "drm uri: " + uri + " path: " + path); + } + final File f = new File(path); + final long len = f.length(); + if (LOCAL_LOGV) { + LogUtil.v(TAG, "drm path: " + path + " len: " + len); + } + if (len > 0) { + // we're not going to re-persist and re-encrypt an already + // converted drm file + return; + } + } catch (final Exception e) { + Log.e(TAG, "Can't get file info for: " + part.getDataUri(), e); + } + } + // We haven't converted the file yet, start the conversion + drmConvertSession = DrmConvertSession.open(mContext, contentType); + if (drmConvertSession == null) { + throw new MmsException("Mimetype " + contentType + + " can not be converted."); + } + } + // uri can look like: + // content://mms/part/98 + os = mContentResolver.openOutputStream(uri); + if (os == null) { + throw new MmsException("Failed to create output stream on " + uri); + } + if (data == null) { + dataUri = part.getDataUri(); + if ((dataUri == null) || (dataUri == uri)) { + Log.w(TAG, "Can't find data for this part."); + return; + } + // dataUri can look like: + // content://com.google.android.gallery3d.provider/picasa/item/5720646660183715 + if (preOpenedFiles != null && preOpenedFiles.containsKey(dataUri)) { + is = preOpenedFiles.get(dataUri); + } + if (is == null) { + is = mContentResolver.openInputStream(dataUri); + } + if (is == null) { + throw new MmsException("Failed to create input stream on " + dataUri); + } + if (LOCAL_LOGV) { + LogUtil.v(TAG, "Saving data to: " + uri); + } + + final byte[] buffer = new byte[8192]; + for (int len = 0; (len = is.read(buffer)) != -1; ) { + if (!isDrm) { + os.write(buffer, 0, len); + } else { + final byte[] convertedData = drmConvertSession.convert(buffer, len); + if (convertedData != null) { + os.write(convertedData, 0, convertedData.length); + } else { + throw new MmsException("Error converting drm data."); + } + } + } + } else { + if (LOCAL_LOGV) { + LogUtil.v(TAG, "Saving data to: " + uri); + } + if (!isDrm) { + os.write(data); + } else { + dataUri = uri; + final byte[] convertedData = drmConvertSession.convert(data, data.length); + if (convertedData != null) { + os.write(convertedData, 0, convertedData.length); + } else { + throw new MmsException("Error converting drm data."); + } + } + } + } + } catch (final SQLiteException e) { + Log.e(TAG, "Failed with SQLiteException.", e); + throw new MmsException(e); + } catch (final FileNotFoundException e) { + Log.e(TAG, "Failed to open Input/Output stream.", e); + throw new MmsException(e); + } catch (final IOException e) { + Log.e(TAG, "Failed to read/write data.", e); + throw new MmsException(e); + } finally { + if (os != null) { + try { + os.close(); + } catch (final IOException e) { + Log.e(TAG, "IOException while closing: " + os, e); + } // Ignore + } + if (is != null) { + try { + is.close(); + } catch (final IOException e) { + Log.e(TAG, "IOException while closing: " + is, e); + } // Ignore + } + if (drmConvertSession != null) { + drmConvertSession.close(path); + + // Reset the permissions on the encrypted part file so everyone has only read + // permission. + final File f = new File(path); + final ContentValues values = new ContentValues(0); + SqliteWrapper.update(mContext, mContentResolver, + Uri.parse("content://mms/resetFilePerm/" + f.getName()), + values, null, null); + } + } + } + + /** + * This method expects uri in the following format + * content://media// (or) + * file://sdcard/test.mp4 + * http://test.com/test.mp4 + * + * Here shall be "video" or "audio" or "images" + * the index of the content in given table + */ + public static String convertUriToPath(final Context context, final Uri uri) { + String path = null; + if (null != uri) { + final String scheme = uri.getScheme(); + if (null == scheme || scheme.equals("") || + scheme.equals(ContentResolver.SCHEME_FILE)) { + path = uri.getPath(); + + } else if (scheme.equals("http")) { + path = uri.toString(); + + } else if (scheme.equals(ContentResolver.SCHEME_CONTENT)) { + final String[] projection = new String[] {MediaStore.MediaColumns.DATA}; + Cursor cursor = null; + try { + cursor = context.getContentResolver().query(uri, projection, null, + null, null); + if (null == cursor || 0 == cursor.getCount() || !cursor.moveToFirst()) { + throw new IllegalArgumentException("Given Uri could not be found" + + " in media store"); + } + final int pathIndex = + cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA); + path = cursor.getString(pathIndex); + } catch (final SQLiteException e) { + throw new IllegalArgumentException("Given Uri is not formatted in a way " + + "so that it can be found in media store."); + } finally { + if (null != cursor) { + cursor.close(); + } + } + } else { + throw new IllegalArgumentException("Given Uri scheme is not supported"); + } + } + return path; + } + + private void updateAddress( + final long msgId, final int type, final EncodedStringValue[] array) { + // Delete old address information and then insert new ones. + SqliteWrapper.delete(mContext, mContentResolver, + Uri.parse("content://mms/" + msgId + "/addr"), + Addr.TYPE + "=" + type, null); + + persistAddress(msgId, type, array); + } + + /** + * Update headers of a SendReq. + * + * @param uri The PDU which need to be updated. + * @param pdu New headers. + * @throws MmsException Bad URI or updating failed. + */ + public void updateHeaders(final Uri uri, final SendReq sendReq) { + synchronized (PDU_CACHE_INSTANCE) { + // If the cache item is getting updated, wait until it's done updating before + // purging it. + if (PDU_CACHE_INSTANCE.isUpdating(uri)) { + if (LOCAL_LOGV) { + LogUtil.v(TAG, "updateHeaders: " + uri + " blocked by isUpdating()"); + } + try { + PDU_CACHE_INSTANCE.wait(); + } catch (final InterruptedException e) { + Log.e(TAG, "updateHeaders: ", e); + } + } + } + PDU_CACHE_INSTANCE.purge(uri); + + final ContentValues values = new ContentValues(10); + final byte[] contentType = sendReq.getContentType(); + if (contentType != null) { + values.put(Mms.CONTENT_TYPE, toIsoString(contentType)); + } + + final long date = sendReq.getDate(); + if (date != -1) { + values.put(Mms.DATE, date); + } + + final int deliveryReport = sendReq.getDeliveryReport(); + if (deliveryReport != 0) { + values.put(Mms.DELIVERY_REPORT, deliveryReport); + } + + final long expiry = sendReq.getExpiry(); + if (expiry != -1) { + values.put(Mms.EXPIRY, expiry); + } + + final byte[] msgClass = sendReq.getMessageClass(); + if (msgClass != null) { + values.put(Mms.MESSAGE_CLASS, toIsoString(msgClass)); + } + + final int priority = sendReq.getPriority(); + if (priority != 0) { + values.put(Mms.PRIORITY, priority); + } + + final int readReport = sendReq.getReadReport(); + if (readReport != 0) { + values.put(Mms.READ_REPORT, readReport); + } + + final byte[] transId = sendReq.getTransactionId(); + if (transId != null) { + values.put(Mms.TRANSACTION_ID, toIsoString(transId)); + } + + final EncodedStringValue subject = sendReq.getSubject(); + if (subject != null) { + values.put(Mms.SUBJECT, toIsoString(subject.getTextString())); + values.put(Mms.SUBJECT_CHARSET, subject.getCharacterSet()); + } else { + values.put(Mms.SUBJECT, ""); + } + + final long messageSize = sendReq.getMessageSize(); + if (messageSize > 0) { + values.put(Mms.MESSAGE_SIZE, messageSize); + } + + final PduHeaders headers = sendReq.getPduHeaders(); + final HashSet recipients = new HashSet(); + for (final int addrType : ADDRESS_FIELDS) { + EncodedStringValue[] array = null; + if (addrType == PduHeaders.FROM) { + final EncodedStringValue v = headers.getEncodedStringValue(addrType); + if (v != null) { + array = new EncodedStringValue[1]; + array[0] = v; + } + } else { + array = headers.getEncodedStringValues(addrType); + } + + if (array != null) { + final long msgId = ContentUris.parseId(uri); + updateAddress(msgId, addrType, array); + if (addrType == PduHeaders.TO) { + for (final EncodedStringValue v : array) { + if (v != null) { + recipients.add(v.getString()); + } + } + } + } + } + if (!recipients.isEmpty()) { + final long threadId = MmsSmsUtils.Threads.getOrCreateThreadId(mContext, recipients); + values.put(Mms.THREAD_ID, threadId); + } + + SqliteWrapper.update(mContext, mContentResolver, uri, values, null, null); + } + + + private void updatePart(final Uri uri, final PduPart part, + final Map preOpenedFiles) + throws MmsException { + final ContentValues values = new ContentValues(7); + + final int charset = part.getCharset(); + if (charset != 0) { + values.put(Part.CHARSET, charset); + } + + String contentType = null; + if (part.getContentType() != null) { + contentType = toIsoString(part.getContentType()); + values.put(Part.CONTENT_TYPE, contentType); + } else { + throw new MmsException("MIME type of the part must be set."); + } + + getValues(part, values); + + SqliteWrapper.update(mContext, mContentResolver, uri, values, null, null); + + // Only update the data when: + // 1. New binary data supplied or + // 2. The Uri of the part is different from the current one. + if ((part.getData() != null) + || (uri != part.getDataUri())) { + persistData(part, uri, contentType, preOpenedFiles); + } + } + + /** + * Update all parts of a PDU. + * + * @param uri The PDU which need to be updated. + * @param body New message body of the PDU. + * @param preOpenedFiles if not null, a map of preopened InputStreams for the parts. + * @throws MmsException Bad URI or updating failed. + */ + public void updateParts(final Uri uri, final PduBody body, + final Map preOpenedFiles) + throws MmsException { + try { + PduCacheEntry cacheEntry; + synchronized (PDU_CACHE_INSTANCE) { + if (PDU_CACHE_INSTANCE.isUpdating(uri)) { + if (LOCAL_LOGV) { + LogUtil.v(TAG, "updateParts: " + uri + " blocked by isUpdating()"); + } + try { + PDU_CACHE_INSTANCE.wait(); + } catch (final InterruptedException e) { + Log.e(TAG, "updateParts: ", e); + } + cacheEntry = PDU_CACHE_INSTANCE.get(uri); + if (cacheEntry != null) { + ((MultimediaMessagePdu) cacheEntry.getPdu()).setBody(body); + } + } + // Tell the cache to indicate to other callers that this item + // is currently being updated. + PDU_CACHE_INSTANCE.setUpdating(uri, true); + } + + final ArrayList toBeCreated = new ArrayList(); + final ArrayMap toBeUpdated = new ArrayMap(); + + final int partsNum = body.getPartsNum(); + final StringBuilder filter = new StringBuilder().append('('); + for (int i = 0; i < partsNum; i++) { + final PduPart part = body.getPart(i); + final Uri partUri = part.getDataUri(); + if ((partUri == null) || !partUri.getAuthority().startsWith("mms")) { + toBeCreated.add(part); + } else { + toBeUpdated.put(partUri, part); + + // Don't use 'i > 0' to determine whether we should append + // 'AND' since 'i = 0' may be skipped in another branch. + if (filter.length() > 1) { + filter.append(" AND "); + } + + filter.append(Part._ID); + filter.append("!="); + DatabaseUtils.appendEscapedSQLString(filter, partUri.getLastPathSegment()); + } + } + filter.append(')'); + + final long msgId = ContentUris.parseId(uri); + + // Remove the parts which doesn't exist anymore. + SqliteWrapper.delete(mContext, mContentResolver, + Uri.parse(Mms.CONTENT_URI + "/" + msgId + "/part"), + filter.length() > 2 ? filter.toString() : null, null); + + // Create new parts which didn't exist before. + for (final PduPart part : toBeCreated) { + persistPart(part, msgId, preOpenedFiles); + } + + // Update the modified parts. + for (final Map.Entry e : toBeUpdated.entrySet()) { + updatePart(e.getKey(), e.getValue(), preOpenedFiles); + } + } finally { + synchronized (PDU_CACHE_INSTANCE) { + PDU_CACHE_INSTANCE.setUpdating(uri, false); + PDU_CACHE_INSTANCE.notifyAll(); + } + } + } + + /** + * Persist a PDU object to specific location in the storage. + * + * @param pdu The PDU object to be stored. + * @param uri Where to store the given PDU object. + * @param subId Subscription id associated with this message. + * @param subPhoneNumber TODO + * @param preOpenedFiles if not null, a map of preopened InputStreams for the parts. + * @return A Uri which can be used to access the stored PDU. + */ + public Uri persist(final GenericPdu pdu, final Uri uri, final int subId, + final String subPhoneNumber, final Map preOpenedFiles) + throws MmsException { + if (uri == null) { + throw new MmsException("Uri may not be null."); + } + long msgId = -1; + try { + msgId = ContentUris.parseId(uri); + } catch (final NumberFormatException e) { + // the uri ends with "inbox" or something else like that + } + final boolean existingUri = msgId != -1; + + if (!existingUri && MESSAGE_BOX_MAP.get(uri) == null) { + throw new MmsException( + "Bad destination, must be one of " + + "content://mms/inbox, content://mms/sent, " + + "content://mms/drafts, content://mms/outbox, " + + "content://mms/temp." + ); + } + synchronized (PDU_CACHE_INSTANCE) { + // If the cache item is getting updated, wait until it's done updating before + // purging it. + if (PDU_CACHE_INSTANCE.isUpdating(uri)) { + if (LOCAL_LOGV) { + LogUtil.v(TAG, "persist: " + uri + " blocked by isUpdating()"); + } + try { + PDU_CACHE_INSTANCE.wait(); + } catch (final InterruptedException e) { + Log.e(TAG, "persist1: ", e); + } + } + } + PDU_CACHE_INSTANCE.purge(uri); + + final PduHeaders header = pdu.getPduHeaders(); + PduBody body = null; + ContentValues values = new ContentValues(); + + // Mark new messages as seen in the telephony database so that we don't have to + // do a global "set all messages as seen" since that occasionally seems to be + // problematic (i.e. very slow). See bug 18189471. + values.put(Mms.SEEN, 1); + + //Set> set; + + for (int i = ENCODED_STRING_COLUMN_NAME_MAP.size(); --i >= 0; ) { + final int field = ENCODED_STRING_COLUMN_NAME_MAP.keyAt(i); + final EncodedStringValue encodedString = header.getEncodedStringValue(field); + if (encodedString != null) { + final String charsetColumn = CHARSET_COLUMN_NAME_MAP.get(field); + values.put(ENCODED_STRING_COLUMN_NAME_MAP.valueAt(i), + toIsoString(encodedString.getTextString())); + values.put(charsetColumn, encodedString.getCharacterSet()); + } + } + + for (int i = TEXT_STRING_COLUMN_NAME_MAP.size(); --i >= 0; ) { + final byte[] text = header.getTextString(TEXT_STRING_COLUMN_NAME_MAP.keyAt(i)); + if (text != null) { + values.put(TEXT_STRING_COLUMN_NAME_MAP.valueAt(i), toIsoString(text)); + } + } + + for (int i = OCTET_COLUMN_NAME_MAP.size(); --i >= 0; ) { + final int b = header.getOctet(OCTET_COLUMN_NAME_MAP.keyAt(i)); + if (b != 0) { + values.put(OCTET_COLUMN_NAME_MAP.valueAt(i), b); + } + } + + for (int i = LONG_COLUMN_NAME_MAP.size(); --i >= 0; ) { + final long l = header.getLongInteger(LONG_COLUMN_NAME_MAP.keyAt(i)); + if (l != -1L) { + values.put(LONG_COLUMN_NAME_MAP.valueAt(i), l); + } + } + + final SparseArray addressMap = + new SparseArray(ADDRESS_FIELDS.length); + // Save address information. + for (final int addrType : ADDRESS_FIELDS) { + EncodedStringValue[] array = null; + if (addrType == PduHeaders.FROM) { + final EncodedStringValue v = header.getEncodedStringValue(addrType); + if (v != null) { + array = new EncodedStringValue[1]; + array[0] = v; + } + } else { + array = header.getEncodedStringValues(addrType); + } + addressMap.put(addrType, array); + } + + final HashSet recipients = new HashSet(); + final int msgType = pdu.getMessageType(); + // Here we only allocate thread ID for M-Notification.ind, + // M-Retrieve.conf and M-Send.req. + // Some of other PDU types may be allocated a thread ID outside + // this scope. + if ((msgType == PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND) + || (msgType == PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF) + || (msgType == PduHeaders.MESSAGE_TYPE_SEND_REQ)) { + switch (msgType) { + case PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND: + case PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF: + loadRecipients(PduHeaders.FROM, recipients, addressMap); + + // For received messages (whether group MMS is enabled or not) we want to + // associate this message with the thread composed of all the recipients + // EXCLUDING our own number. This includes the person who sent the + // message (the FROM field above) in addition to the other people the message + // was addressed TO (or CC fields to address group messaging compatibility + // issues with devices that place numbers in this field). Typically our own + // number is in the TO/CC field so we have to remove it in loadRecipients. + checkAndLoadToCcRecipients(recipients, addressMap, subPhoneNumber); + break; + case PduHeaders.MESSAGE_TYPE_SEND_REQ: + loadRecipients(PduHeaders.TO, recipients, addressMap); + break; + } + long threadId = -1L; + if (!recipients.isEmpty()) { + // Given all the recipients associated with this message, find (or create) the + // correct thread. + threadId = MmsSmsUtils.Threads.getOrCreateThreadId(mContext, recipients); + } else { + LogUtil.w(TAG, "PduPersister.persist No recipients; persisting PDU to thread: " + + threadId); + } + values.put(Mms.THREAD_ID, threadId); + } + + // Save parts first to avoid inconsistent message is loaded + // while saving the parts. + final long dummyId = System.currentTimeMillis(); // Dummy ID of the msg. + + // Figure out if this PDU is a text-only message + boolean textOnly = true; + + // Get body if the PDU is a RetrieveConf or SendReq. + if (pdu instanceof MultimediaMessagePdu) { + body = ((MultimediaMessagePdu) pdu).getBody(); + // Start saving parts if necessary. + if (body != null) { + final int partsNum = body.getPartsNum(); + if (LOCAL_LOGV) { + LogUtil.v(TAG, "PduPersister.persist partsNum: " + partsNum); + } + if (partsNum > 2) { + // For a text-only message there will be two parts: 1-the SMIL, 2-the text. + // Down a few lines below we're checking to make sure we've only got SMIL or + // text. We also have to check then we don't have more than two parts. + // Otherwise, a slideshow with two text slides would be marked as textOnly. + textOnly = false; + } + for (int i = 0; i < partsNum; i++) { + final PduPart part = body.getPart(i); + persistPart(part, dummyId, preOpenedFiles); + + // If we've got anything besides text/plain or SMIL part, then we've got + // an mms message with some other type of attachment. + final String contentType = getPartContentType(part); + if (LOCAL_LOGV) { + LogUtil.v(TAG, "PduPersister.persist part: " + i + " contentType: " + + contentType); + } + if (contentType != null && !ContentType.APP_SMIL.equals(contentType) + && !ContentType.TEXT_PLAIN.equals(contentType)) { + textOnly = false; + } + } + } + } + // Record whether this mms message is a simple plain text or not. This is a hint for the + // UI. + if (OsUtil.isAtLeastJB_MR1()) { + values.put(Mms.TEXT_ONLY, textOnly ? 1 : 0); + } + + if (OsUtil.isAtLeastL_MR1()) { + values.put(Mms.SUBSCRIPTION_ID, subId); + } else { + Assert.equals(ParticipantData.DEFAULT_SELF_SUB_ID, subId); + } + + Uri res = null; + if (existingUri) { + res = uri; + SqliteWrapper.update(mContext, mContentResolver, res, values, null, null); + } else { + res = SqliteWrapper.insert(mContext, mContentResolver, uri, values); + if (res == null) { + throw new MmsException("persist() failed: return null."); + } + // Get the real ID of the PDU and update all parts which were + // saved with the dummy ID. + msgId = ContentUris.parseId(res); + } + + values = new ContentValues(1); + values.put(Part.MSG_ID, msgId); + SqliteWrapper.update(mContext, mContentResolver, + Uri.parse("content://mms/" + dummyId + "/part"), + values, null, null); + // We should return the longest URI of the persisted PDU, for + // example, if input URI is "content://mms/inbox" and the _ID of + // persisted PDU is '8', we should return "content://mms/inbox/8" + // instead of "content://mms/8". + // TODO: Should the MmsProvider be responsible for this??? + if (!existingUri) { + res = Uri.parse(uri + "/" + msgId); + } + + // Save address information. + for (final int addrType : ADDRESS_FIELDS) { + final EncodedStringValue[] array = addressMap.get(addrType); + if (array != null) { + persistAddress(msgId, addrType, array); + } + } + + return res; + } + + /** + * For a given address type, extract the recipients from the headers. + * + * @param addressType can be PduHeaders.FROM or PduHeaders.TO + * @param recipients a HashSet that is loaded with the recipients from the FROM or TO + * headers + * @param addressMap a HashMap of the addresses from the ADDRESS_FIELDS header + */ + private void loadRecipients(final int addressType, final HashSet recipients, + final SparseArray addressMap) { + final EncodedStringValue[] array = addressMap.get(addressType); + if (array == null) { + return; + } + for (final EncodedStringValue v : array) { + if (v != null) { + final String number = v.getString(); + if (!recipients.contains(number)) { + // Only add numbers which aren't already included. + recipients.add(number); + } + } + } + } + + /** + * For a given address type, extract the recipients from the headers. + * + * @param recipients a HashSet that is loaded with the recipients from the FROM or TO + * headers + * @param addressMap a HashMap of the addresses from the ADDRESS_FIELDS header + * @param selfNumber self phone number + */ + private void checkAndLoadToCcRecipients(final HashSet recipients, + final SparseArray addressMap, final String selfNumber) { + final EncodedStringValue[] arrayTo = addressMap.get(PduHeaders.TO); + final EncodedStringValue[] arrayCc = addressMap.get(PduHeaders.CC); + final ArrayList numbers = new ArrayList(); + if (arrayTo != null) { + for (final EncodedStringValue v : arrayTo) { + if (v != null) { + numbers.add(v.getString()); + } + } + } + if (arrayCc != null) { + for (final EncodedStringValue v : arrayCc) { + if (v != null) { + numbers.add(v.getString()); + } + } + } + for (final String number : numbers) { + // Only add numbers which aren't my own number. + if (TextUtils.isEmpty(selfNumber) || !PhoneNumberUtils.compare(number, selfNumber)) { + if (!recipients.contains(number)) { + // Only add numbers which aren't already included. + recipients.add(number); + } + } + } + } + + /** + * Move a PDU object from one location to another. + * + * @param from Specify the PDU object to be moved. + * @param to The destination location, should be one of the following: + * "content://mms/inbox", "content://mms/sent", + * "content://mms/drafts", "content://mms/outbox", + * "content://mms/trash". + * @return New Uri of the moved PDU. + * @throws MmsException Error occurred while moving the message. + */ + public Uri move(final Uri from, final Uri to) throws MmsException { + // Check whether the 'msgId' has been assigned a valid value. + final long msgId = ContentUris.parseId(from); + if (msgId == -1L) { + throw new MmsException("Error! ID of the message: -1."); + } + + // Get corresponding int value of destination box. + final Integer msgBox = MESSAGE_BOX_MAP.get(to); + if (msgBox == null) { + throw new MmsException( + "Bad destination, must be one of " + + "content://mms/inbox, content://mms/sent, " + + "content://mms/drafts, content://mms/outbox, " + + "content://mms/temp." + ); + } + + final ContentValues values = new ContentValues(1); + values.put(Mms.MESSAGE_BOX, msgBox); + SqliteWrapper.update(mContext, mContentResolver, from, values, null, null); + return ContentUris.withAppendedId(to, msgId); + } + + /** + * Wrap a byte[] into a String. + */ + public static String toIsoString(final byte[] bytes) { + try { + return new String(bytes, CharacterSets.MIMENAME_ISO_8859_1); + } catch (final UnsupportedEncodingException e) { + // Impossible to reach here! + Log.e(TAG, "ISO_8859_1 must be supported!", e); + return ""; + } + } + + /** + * Unpack a given String into a byte[]. + */ + public static byte[] getBytes(final String data) { + try { + return data.getBytes(CharacterSets.MIMENAME_ISO_8859_1); + } catch (final UnsupportedEncodingException e) { + // Impossible to reach here! + Log.e(TAG, "ISO_8859_1 must be supported!", e); + return new byte[0]; + } + } + + /** + * Remove all objects in the temporary path. + */ + public void release() { + final Uri uri = Uri.parse(TEMPORARY_DRM_OBJECT_URI); + SqliteWrapper.delete(mContext, mContentResolver, uri, null, null); + } + + /** + * Find all messages to be sent or downloaded before certain time. + */ + public Cursor getPendingMessages(final long dueTime) { + final Uri.Builder uriBuilder = PendingMessages.CONTENT_URI.buildUpon(); + uriBuilder.appendQueryParameter("protocol", "mms"); + + final String selection = PendingMessages.ERROR_TYPE + " < ?" + + " AND " + PendingMessages.DUE_TIME + " <= ?"; + + final String[] selectionArgs = new String[] { + String.valueOf(MmsSms.ERR_TYPE_GENERIC_PERMANENT), + String.valueOf(dueTime) + }; + + return SqliteWrapper.query(mContext, mContentResolver, + uriBuilder.build(), null, selection, selectionArgs, + PendingMessages.DUE_TIME); + } +} diff --git a/src/com/android/messaging/mmslib/pdu/QuotedPrintable.java b/src/com/android/messaging/mmslib/pdu/QuotedPrintable.java new file mode 100644 index 0000000..1ce9dc1 --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/QuotedPrintable.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import java.io.ByteArrayOutputStream; + +public class QuotedPrintable { + private static byte ESCAPE_CHAR = '='; + + /** + * Decodes an array quoted-printable characters into an array of original bytes. + * Escaped characters are converted back to their original representation. + * + *

+ * This function implements a subset of + * quoted-printable encoding specification (rule #1 and rule #2) + * as defined in RFC 1521. + *

+ * + * @param bytes array of quoted-printable characters + * @return array of original bytes, + * null if quoted-printable decoding is unsuccessful. + */ + public static final byte[] decodeQuotedPrintable(byte[] bytes) { + if (bytes == null) { + return null; + } + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + for (int i = 0; i < bytes.length; i++) { + int b = bytes[i]; + if (b == ESCAPE_CHAR) { + try { + if ('\r' == (char) bytes[i + 1] && + '\n' == (char) bytes[i + 2]) { + i += 2; + continue; + } + int u = Character.digit((char) bytes[++i], 16); + int l = Character.digit((char) bytes[++i], 16); + if (u == -1 || l == -1) { + return null; + } + buffer.write((char) ((u << 4) + l)); + } catch (ArrayIndexOutOfBoundsException e) { + return null; + } + } else { + buffer.write(b); + } + } + return buffer.toByteArray(); + } +} diff --git a/src/com/android/messaging/mmslib/pdu/ReadOrigInd.java b/src/com/android/messaging/mmslib/pdu/ReadOrigInd.java new file mode 100644 index 0000000..198e4c3 --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/ReadOrigInd.java @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import com.android.messaging.mmslib.InvalidHeaderValueException; + +public class ReadOrigInd extends GenericPdu { + /** + * Empty constructor. + * Since the Pdu corresponding to this class is constructed + * by the Proxy-Relay server, this class is only instantiated + * by the Pdu Parser. + * + * @throws InvalidHeaderValueException if error occurs. + */ + public ReadOrigInd() throws InvalidHeaderValueException { + super(); + setMessageType(PduHeaders.MESSAGE_TYPE_READ_ORIG_IND); + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + ReadOrigInd(PduHeaders headers) { + super(headers); + } + + /** + * Get Date value. + * + * @return the value + */ + public long getDate() { + return mPduHeaders.getLongInteger(PduHeaders.DATE); + } + + /** + * Set Date value. + * + * @param value the value + */ + public void setDate(long value) { + mPduHeaders.setLongInteger(value, PduHeaders.DATE); + } + + /** + * Get From value. + * From-value = Value-length + * (Address-present-token Encoded-string-value | Insert-address-token) + * + * @return the value + */ + public EncodedStringValue getFrom() { + return mPduHeaders.getEncodedStringValue(PduHeaders.FROM); + } + + /** + * Set From value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setFrom(EncodedStringValue value) { + mPduHeaders.setEncodedStringValue(value, PduHeaders.FROM); + } + + /** + * Get Message-ID value. + * + * @return the value + */ + public byte[] getMessageId() { + return mPduHeaders.getTextString(PduHeaders.MESSAGE_ID); + } + + /** + * Set Message-ID value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setMessageId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.MESSAGE_ID); + } + + /** + * Get X-MMS-Read-status value. + * + * @return the value + */ + public int getReadStatus() { + return mPduHeaders.getOctet(PduHeaders.READ_STATUS); + } + + /** + * Set X-MMS-Read-status value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setReadStatus(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.READ_STATUS); + } + + /** + * Get To value. + * + * @return the value + */ + public EncodedStringValue[] getTo() { + return mPduHeaders.getEncodedStringValues(PduHeaders.TO); + } + + /** + * Set To value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setTo(EncodedStringValue[] value) { + mPduHeaders.setEncodedStringValues(value, PduHeaders.TO); + } + + /* + * Optional, not supported header fields: + * + * public byte[] getApplicId() {return null;} + * public void setApplicId(byte[] value) {} + * + * public byte[] getAuxApplicId() {return null;} + * public void getAuxApplicId(byte[] value) {} + * + * public byte[] getReplyApplicId() {return 0x00;} + * public void setReplyApplicId(byte[] value) {} + */ +} diff --git a/src/com/android/messaging/mmslib/pdu/ReadRecInd.java b/src/com/android/messaging/mmslib/pdu/ReadRecInd.java new file mode 100644 index 0000000..c1a9ae4 --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/ReadRecInd.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import com.android.messaging.mmslib.InvalidHeaderValueException; + +public class ReadRecInd extends GenericPdu { + /** + * Constructor, used when composing a M-ReadRec.ind pdu. + * + * @param from the from value + * @param messageId the message ID value + * @param mmsVersion current viersion of mms + * @param readStatus the read status value + * @param to the to value + * @throws InvalidHeaderValueException if parameters are invalid. + * @throws NullPointerException if messageId or to is null. + */ + public ReadRecInd(EncodedStringValue from, + byte[] messageId, + int mmsVersion, + int readStatus, + EncodedStringValue[] to) throws InvalidHeaderValueException { + super(); + setMessageType(PduHeaders.MESSAGE_TYPE_READ_REC_IND); + setFrom(from); + setMessageId(messageId); + setMmsVersion(mmsVersion); + setTo(to); + setReadStatus(readStatus); + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + ReadRecInd(PduHeaders headers) { + super(headers); + } + + /** + * Get Date value. + * + * @return the value + */ + public long getDate() { + return mPduHeaders.getLongInteger(PduHeaders.DATE); + } + + /** + * Set Date value. + * + * @param value the value + */ + public void setDate(long value) { + mPduHeaders.setLongInteger(value, PduHeaders.DATE); + } + + /** + * Get Message-ID value. + * + * @return the value + */ + public byte[] getMessageId() { + return mPduHeaders.getTextString(PduHeaders.MESSAGE_ID); + } + + /** + * Set Message-ID value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setMessageId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.MESSAGE_ID); + } + + /** + * Get To value. + * + * @return the value + */ + public EncodedStringValue[] getTo() { + return mPduHeaders.getEncodedStringValues(PduHeaders.TO); + } + + /** + * Set To value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setTo(EncodedStringValue[] value) { + mPduHeaders.setEncodedStringValues(value, PduHeaders.TO); + } + + /** + * Get X-MMS-Read-status value. + * + * @return the value + */ + public int getReadStatus() { + return mPduHeaders.getOctet(PduHeaders.READ_STATUS); + } + + /** + * Set X-MMS-Read-status value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setReadStatus(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.READ_STATUS); + } + + /* + * Optional, not supported header fields: + * + * public byte[] getApplicId() {return null;} + * public void setApplicId(byte[] value) {} + * + * public byte[] getAuxApplicId() {return null;} + * public void getAuxApplicId(byte[] value) {} + * + * public byte[] getReplyApplicId() {return 0x00;} + * public void setReplyApplicId(byte[] value) {} + */ +} diff --git a/src/com/android/messaging/mmslib/pdu/RetrieveConf.java b/src/com/android/messaging/mmslib/pdu/RetrieveConf.java new file mode 100644 index 0000000..9e0faed --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/RetrieveConf.java @@ -0,0 +1,305 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import com.android.messaging.mmslib.InvalidHeaderValueException; + +/** + * M-Retrive.conf Pdu. + */ +public class RetrieveConf extends MultimediaMessagePdu { + /** + * Empty constructor. + * Since the Pdu corresponding to this class is constructed + * by the Proxy-Relay server, this class is only instantiated + * by the Pdu Parser. + * + * @throws InvalidHeaderValueException if error occurs. + */ + public RetrieveConf() throws InvalidHeaderValueException { + super(); + setMessageType(PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF); + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + RetrieveConf(PduHeaders headers) { + super(headers); + } + + /** + * Constructor with given headers and body + * + * @param headers Headers for this PDU. + * @param body Body of this PDu. + */ + RetrieveConf(PduHeaders headers, PduBody body) { + super(headers, body); + } + + /** + * Get CC value. + * + * @return the value + */ + public EncodedStringValue[] getCc() { + return mPduHeaders.getEncodedStringValues(PduHeaders.CC); + } + + /** + * Add a "CC" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void addCc(EncodedStringValue value) { + mPduHeaders.appendEncodedStringValue(value, PduHeaders.CC); + } + + /** + * Get Content-type value. + * + * @return the value + */ + public byte[] getContentType() { + return mPduHeaders.getTextString(PduHeaders.CONTENT_TYPE); + } + + /** + * Set Content-type value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setContentType(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.CONTENT_TYPE); + } + + /** + * Get X-Mms-Delivery-Report value. + * + * @return the value + */ + public int getDeliveryReport() { + return mPduHeaders.getOctet(PduHeaders.DELIVERY_REPORT); + } + + /** + * Set X-Mms-Delivery-Report value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setDeliveryReport(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.DELIVERY_REPORT); + } + + /** + * Get From value. + * From-value = Value-length + * (Address-present-token Encoded-string-value | Insert-address-token) + * + * @return the value + */ + public EncodedStringValue getFrom() { + return mPduHeaders.getEncodedStringValue(PduHeaders.FROM); + } + + /** + * Set From value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setFrom(EncodedStringValue value) { + mPduHeaders.setEncodedStringValue(value, PduHeaders.FROM); + } + + /** + * Get X-Mms-Message-Class value. + * Message-class-value = Class-identifier | Token-text + * Class-identifier = Personal | Advertisement | Informational | Auto + * + * @return the value + */ + public byte[] getMessageClass() { + return mPduHeaders.getTextString(PduHeaders.MESSAGE_CLASS); + } + + /** + * Set X-Mms-Message-Class value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setMessageClass(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.MESSAGE_CLASS); + } + + /** + * Get Message-ID value. + * + * @return the value + */ + public byte[] getMessageId() { + return mPduHeaders.getTextString(PduHeaders.MESSAGE_ID); + } + + /** + * Set Message-ID value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setMessageId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.MESSAGE_ID); + } + + /** + * Get X-Mms-Read-Report value. + * + * @return the value + */ + public int getReadReport() { + return mPduHeaders.getOctet(PduHeaders.READ_REPORT); + } + + /** + * Set X-Mms-Read-Report value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setReadReport(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.READ_REPORT); + } + + /** + * Get X-Mms-Retrieve-Status value. + * + * @return the value + */ + public int getRetrieveStatus() { + /* + * If the header is not there, assuming it is OK status. + * Some carriers may choose to not send this header. + */ + return mPduHeaders.hasHeader(PduHeaders.RETRIEVE_STATUS) ? + mPduHeaders.getOctet(PduHeaders.RETRIEVE_STATUS) : PduHeaders.RETRIEVE_STATUS_OK; + } + + /** + * Set X-Mms-Retrieve-Status value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setRetrieveStatus(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.RETRIEVE_STATUS); + } + + /** + * Get X-Mms-Retrieve-Text value. + * + * @return the value + */ + public EncodedStringValue getRetrieveText() { + return mPduHeaders.getEncodedStringValue(PduHeaders.RETRIEVE_TEXT); + } + + /** + * Set X-Mms-Retrieve-Text value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setRetrieveText(EncodedStringValue value) { + mPduHeaders.setEncodedStringValue(value, PduHeaders.RETRIEVE_TEXT); + } + + /** + * Get X-Mms-Transaction-Id. + * + * @return the value + */ + public byte[] getTransactionId() { + return mPduHeaders.getTextString(PduHeaders.TRANSACTION_ID); + } + + /** + * Set X-Mms-Transaction-Id. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setTransactionId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.TRANSACTION_ID); + } + + /* + * Optional, not supported header fields: + * + * public byte[] getApplicId() {return null;} + * public void setApplicId(byte[] value) {} + * + * public byte[] getAuxApplicId() {return null;} + * public void getAuxApplicId(byte[] value) {} + * + * public byte getContentClass() {return 0x00;} + * public void setApplicId(byte value) {} + * + * public byte getDrmContent() {return 0x00;} + * public void setDrmContent(byte value) {} + * + * public byte getDistributionIndicator() {return 0x00;} + * public void setDistributionIndicator(byte value) {} + * + * public PreviouslySentByValue getPreviouslySentBy() {return null;} + * public void setPreviouslySentBy(PreviouslySentByValue value) {} + * + * public PreviouslySentDateValue getPreviouslySentDate() {} + * public void setPreviouslySentDate(PreviouslySentDateValue value) {} + * + * public MmFlagsValue getMmFlags() {return null;} + * public void setMmFlags(MmFlagsValue value) {} + * + * public MmStateValue getMmState() {return null;} + * public void getMmState(MmStateValue value) {} + * + * public byte[] getReplaceId() {return 0x00;} + * public void setReplaceId(byte[] value) {} + * + * public byte[] getReplyApplicId() {return 0x00;} + * public void setReplyApplicId(byte[] value) {} + * + * public byte getReplyCharging() {return 0x00;} + * public void setReplyCharging(byte value) {} + * + * public byte getReplyChargingDeadline() {return 0x00;} + * public void setReplyChargingDeadline(byte value) {} + * + * public byte[] getReplyChargingId() {return 0x00;} + * public void setReplyChargingId(byte[] value) {} + * + * public long getReplyChargingSize() {return 0;} + * public void setReplyChargingSize(long value) {} + */ +} diff --git a/src/com/android/messaging/mmslib/pdu/SendConf.java b/src/com/android/messaging/mmslib/pdu/SendConf.java new file mode 100644 index 0000000..cf1399e --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/SendConf.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import com.android.messaging.mmslib.InvalidHeaderValueException; + +public class SendConf extends GenericPdu { + /** + * Empty constructor. + * Since the Pdu corresponding to this class is constructed + * by the Proxy-Relay server, this class is only instantiated + * by the Pdu Parser. + * + * @throws InvalidHeaderValueException if error occurs. + */ + public SendConf() throws InvalidHeaderValueException { + super(); + setMessageType(PduHeaders.MESSAGE_TYPE_SEND_CONF); + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + SendConf(PduHeaders headers) { + super(headers); + } + + /** + * Get Message-ID value. + * + * @return the value + */ + public byte[] getMessageId() { + return mPduHeaders.getTextString(PduHeaders.MESSAGE_ID); + } + + /** + * Set Message-ID value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setMessageId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.MESSAGE_ID); + } + + /** + * Get X-Mms-Response-Status. + * + * @return the value + */ + public int getResponseStatus() { + return mPduHeaders.getOctet(PduHeaders.RESPONSE_STATUS); + } + + /** + * Set X-Mms-Response-Status. + * + * @param value the values + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setResponseStatus(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.RESPONSE_STATUS); + } + + /** + * Get X-Mms-Transaction-Id field value. + * + * @return the X-Mms-Report-Allowed value + */ + public byte[] getTransactionId() { + return mPduHeaders.getTextString(PduHeaders.TRANSACTION_ID); + } + + /** + * Set X-Mms-Transaction-Id field value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setTransactionId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.TRANSACTION_ID); + } + + /* + * Optional, not supported header fields: + * + * public byte[] getContentLocation() {return null;} + * public void setContentLocation(byte[] value) {} + * + * public EncodedStringValue getResponseText() {return null;} + * public void setResponseText(EncodedStringValue value) {} + * + * public byte getStoreStatus() {return 0x00;} + * public void setStoreStatus(byte value) {} + * + * public byte[] getStoreStatusText() {return null;} + * public void setStoreStatusText(byte[] value) {} + */ +} diff --git a/src/com/android/messaging/mmslib/pdu/SendReq.java b/src/com/android/messaging/mmslib/pdu/SendReq.java new file mode 100644 index 0000000..d173c0b --- /dev/null +++ b/src/com/android/messaging/mmslib/pdu/SendReq.java @@ -0,0 +1,346 @@ +/* + * Copyright (C) 2007-2008 Esmertec AG. + * Copyright (C) 2007-2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.pdu; + +import android.util.Log; + +import com.android.messaging.mmslib.InvalidHeaderValueException; + +public class SendReq extends MultimediaMessagePdu { + private static final String TAG = "SendReq"; + + public SendReq() { + super(); + + try { + setMessageType(PduHeaders.MESSAGE_TYPE_SEND_REQ); + setMmsVersion(PduHeaders.CURRENT_MMS_VERSION); + // TODO: Content-type must be decided according to whether + // SMIL part present. + setContentType("application/vnd.wap.multipart.related".getBytes()); + setFrom(new EncodedStringValue(PduHeaders.FROM_INSERT_ADDRESS_TOKEN_STR.getBytes())); + setTransactionId(generateTransactionId()); + } catch (InvalidHeaderValueException e) { + // Impossible to reach here since all headers we set above are valid. + Log.e(TAG, "Unexpected InvalidHeaderValueException.", e); + throw new RuntimeException(e); + } + } + + private byte[] generateTransactionId() { + String transactionId = "T" + Long.toHexString(System.currentTimeMillis()); + return transactionId.getBytes(); + } + + /** + * Constructor, used when composing a M-Send.req pdu. + * + * @param contentType the content type value + * @param from the from value + * @param mmsVersion current viersion of mms + * @param transactionId the transaction-id value + * @throws InvalidHeaderValueException if parameters are invalid. + * NullPointerException if contentType, form or + * transactionId is null. + */ + public SendReq(byte[] contentType, + EncodedStringValue from, + int mmsVersion, + byte[] transactionId) throws InvalidHeaderValueException { + super(); + setMessageType(PduHeaders.MESSAGE_TYPE_SEND_REQ); + setContentType(contentType); + setFrom(from); + setMmsVersion(mmsVersion); + setTransactionId(transactionId); + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + SendReq(PduHeaders headers) { + super(headers); + } + + /** + * Constructor with given headers and body + * + * @param headers Headers for this PDU. + * @param body Body of this PDu. + */ + SendReq(PduHeaders headers, PduBody body) { + super(headers, body); + } + + /** + * Get Bcc value. + * + * @return the value + */ + public EncodedStringValue[] getBcc() { + return mPduHeaders.getEncodedStringValues(PduHeaders.BCC); + } + + /** + * Add a "BCC" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void addBcc(EncodedStringValue value) { + mPduHeaders.appendEncodedStringValue(value, PduHeaders.BCC); + } + + /** + * Set "BCC" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setBcc(EncodedStringValue[] value) { + mPduHeaders.setEncodedStringValues(value, PduHeaders.BCC); + } + + /** + * Get CC value. + * + * @return the value + */ + public EncodedStringValue[] getCc() { + return mPduHeaders.getEncodedStringValues(PduHeaders.CC); + } + + /** + * Add a "CC" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void addCc(EncodedStringValue value) { + mPduHeaders.appendEncodedStringValue(value, PduHeaders.CC); + } + + /** + * Set "CC" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setCc(EncodedStringValue[] value) { + mPduHeaders.setEncodedStringValues(value, PduHeaders.CC); + } + + /** + * Get Content-type value. + * + * @return the value + */ + public byte[] getContentType() { + return mPduHeaders.getTextString(PduHeaders.CONTENT_TYPE); + } + + /** + * Set Content-type value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setContentType(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.CONTENT_TYPE); + } + + /** + * Get X-Mms-Delivery-Report value. + * + * @return the value + */ + public int getDeliveryReport() { + return mPduHeaders.getOctet(PduHeaders.DELIVERY_REPORT); + } + + /** + * Set X-Mms-Delivery-Report value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setDeliveryReport(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.DELIVERY_REPORT); + } + + /** + * Get X-Mms-Expiry value. + * + * Expiry-value = Value-length + * (Absolute-token Date-value | Relative-token Delta-seconds-value) + * + * @return the value + */ + public long getExpiry() { + return mPduHeaders.getLongInteger(PduHeaders.EXPIRY); + } + + /** + * Set X-Mms-Expiry value. + * + * @param value the value + */ + public void setExpiry(long value) { + mPduHeaders.setLongInteger(value, PduHeaders.EXPIRY); + } + + /** + * Get X-Mms-MessageSize value. + * + * Expiry-value = size of message + * + * @return the value + */ + public long getMessageSize() { + return mPduHeaders.getLongInteger(PduHeaders.MESSAGE_SIZE); + } + + /** + * Set X-Mms-MessageSize value. + * + * @param value the value + */ + public void setMessageSize(long value) { + mPduHeaders.setLongInteger(value, PduHeaders.MESSAGE_SIZE); + } + + /** + * Get X-Mms-Message-Class value. + * Message-class-value = Class-identifier | Token-text + * Class-identifier = Personal | Advertisement | Informational | Auto + * + * @return the value + */ + public byte[] getMessageClass() { + return mPduHeaders.getTextString(PduHeaders.MESSAGE_CLASS); + } + + /** + * Set X-Mms-Message-Class value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setMessageClass(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.MESSAGE_CLASS); + } + + /** + * Get X-Mms-Read-Report value. + * + * @return the value + */ + public int getReadReport() { + return mPduHeaders.getOctet(PduHeaders.READ_REPORT); + } + + /** + * Set X-Mms-Read-Report value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setReadReport(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.READ_REPORT); + } + + /** + * Set "To" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setTo(EncodedStringValue[] value) { + mPduHeaders.setEncodedStringValues(value, PduHeaders.TO); + } + + /** + * Get X-Mms-Transaction-Id field value. + * + * @return the X-Mms-Report-Allowed value + */ + public byte[] getTransactionId() { + return mPduHeaders.getTextString(PduHeaders.TRANSACTION_ID); + } + + /** + * Set X-Mms-Transaction-Id field value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setTransactionId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.TRANSACTION_ID); + } + + /* + * Optional, not supported header fields: + * + * public byte getAdaptationAllowed() {return 0}; + * public void setAdaptationAllowed(btye value) {}; + * + * public byte[] getApplicId() {return null;} + * public void setApplicId(byte[] value) {} + * + * public byte[] getAuxApplicId() {return null;} + * public void getAuxApplicId(byte[] value) {} + * + * public byte getContentClass() {return 0x00;} + * public void setApplicId(byte value) {} + * + * public long getDeliveryTime() {return 0}; + * public void setDeliveryTime(long value) {}; + * + * public byte getDrmContent() {return 0x00;} + * public void setDrmContent(byte value) {} + * + * public MmFlagsValue getMmFlags() {return null;} + * public void setMmFlags(MmFlagsValue value) {} + * + * public MmStateValue getMmState() {return null;} + * public void getMmState(MmStateValue value) {} + * + * public byte[] getReplyApplicId() {return 0x00;} + * public void setReplyApplicId(byte[] value) {} + * + * public byte getReplyCharging() {return 0x00;} + * public void setReplyCharging(byte value) {} + * + * public byte getReplyChargingDeadline() {return 0x00;} + * public void setReplyChargingDeadline(byte value) {} + * + * public byte[] getReplyChargingId() {return 0x00;} + * public void setReplyChargingId(byte[] value) {} + * + * public long getReplyChargingSize() {return 0;} + * public void setReplyChargingSize(long value) {} + * + * public byte[] getReplyApplicId() {return 0x00;} + * public void setReplyApplicId(byte[] value) {} + * + * public byte getStore() {return 0x00;} + * public void setStore(byte value) {} + */ +} diff --git a/src/com/android/messaging/mmslib/util/AbstractCache.java b/src/com/android/messaging/mmslib/util/AbstractCache.java new file mode 100644 index 0000000..db98bb9 --- /dev/null +++ b/src/com/android/messaging/mmslib/util/AbstractCache.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2008 Esmertec AG. + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.util; + +import android.support.v4.util.SimpleArrayMap; +import android.util.Log; + +public abstract class AbstractCache { + private static final String TAG = "AbstractCache"; + private static final boolean LOCAL_LOGV = false; + + private static final int MAX_CACHED_ITEMS = 500; + + private final SimpleArrayMap> mCacheMap; + + protected AbstractCache() { + mCacheMap = new SimpleArrayMap>(); + } + + public boolean put(K key, V value) { + if (LOCAL_LOGV) { + Log.v(TAG, "Trying to put " + key + " into cache."); + } + + if (mCacheMap.size() >= MAX_CACHED_ITEMS) { + // TODO: Should remove the oldest or least hit cached entry + // and then cache the new one. + if (LOCAL_LOGV) { + Log.v(TAG, "Failed! size limitation reached."); + } + return false; + } + + if (key != null) { + CacheEntry cacheEntry = new CacheEntry(); + cacheEntry.value = value; + mCacheMap.put(key, cacheEntry); + + if (LOCAL_LOGV) { + Log.v(TAG, key + " cached, " + mCacheMap.size() + " items total."); + } + return true; + } + return false; + } + + public V get(K key) { + if (LOCAL_LOGV) { + Log.v(TAG, "Trying to get " + key + " from cache."); + } + + if (key != null) { + CacheEntry cacheEntry = mCacheMap.get(key); + if (cacheEntry != null) { + cacheEntry.hit++; + if (LOCAL_LOGV) { + Log.v(TAG, key + " hit " + cacheEntry.hit + " times."); + } + return cacheEntry.value; + } + } + return null; + } + + public V purge(K key) { + if (LOCAL_LOGV) { + Log.v(TAG, "Trying to purge " + key); + } + + CacheEntry v = mCacheMap.remove(key); + + if (LOCAL_LOGV) { + Log.v(TAG, mCacheMap.size() + " items cached."); + } + + return v != null ? v.value : null; + } + + public void purgeAll() { + if (LOCAL_LOGV) { + Log.v(TAG, "Purging cache, " + mCacheMap.size() + + " items dropped."); + } + mCacheMap.clear(); + } + + public int size() { + return mCacheMap.size(); + } + + private static class CacheEntry { + + int hit; + + V value; + } +} diff --git a/src/com/android/messaging/mmslib/util/DownloadDrmHelper.java b/src/com/android/messaging/mmslib/util/DownloadDrmHelper.java new file mode 100644 index 0000000..c38b179 --- /dev/null +++ b/src/com/android/messaging/mmslib/util/DownloadDrmHelper.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.android.messaging.mmslib.util; + +import android.content.Context; +import android.drm.DrmManagerClient; +import android.util.Log; + +public class DownloadDrmHelper { + private static final String TAG = "DownloadDrmHelper"; + + /** The MIME type of special DRM files */ + public static final String MIMETYPE_DRM_MESSAGE = "application/vnd.oma.drm.message"; + + /** The extensions of special DRM files */ + public static final String EXTENSION_DRM_MESSAGE = ".dm"; + + public static final String EXTENSION_INTERNAL_FWDL = ".fl"; + + /** + * Checks if the Media Type is a DRM Media Type + * + * @param drmManagerClient A DrmManagerClient + * @param mimetype Media Type to check + * @return True if the Media Type is DRM else false + */ + public static boolean isDrmMimeType(Context context, String mimetype) { + boolean result = false; + if (context != null) { + try { + DrmManagerClient drmClient = new DrmManagerClient(context); + if (drmClient != null && mimetype != null && mimetype.length() > 0) { + result = drmClient.canHandle("", mimetype); + } + } catch (IllegalArgumentException e) { + Log.w(TAG, + "DrmManagerClient instance could not be created, context is Illegal."); + } catch (IllegalStateException e) { + Log.w(TAG, "DrmManagerClient didn't initialize properly."); + } + } + return result; + } + + /** + * Checks if the Media Type needs to be DRM converted + * + * @param mimetype Media type of the content + * @return True if convert is needed else false + */ + public static boolean isDrmConvertNeeded(String mimetype) { + return MIMETYPE_DRM_MESSAGE.equals(mimetype); + } + + /** + * Modifies the file extension for a DRM Forward Lock file NOTE: This + * function shouldn't be called if the file shouldn't be DRM converted + */ + public static String modifyDrmFwLockFileExtension(String filename) { + if (filename != null) { + int extensionIndex; + extensionIndex = filename.lastIndexOf("."); + if (extensionIndex != -1) { + filename = filename.substring(0, extensionIndex); + } + filename = filename.concat(EXTENSION_INTERNAL_FWDL); + } + return filename; + } + + /** + * Gets the original mime type of DRM protected content. + * + * @param context The context + * @param path Path to the file + * @param containingMime The current mime type of of the file i.e. the + * containing mime type + * @return The original mime type of the file if DRM protected else the + * currentMime + */ + public static String getOriginalMimeType(Context context, String path, String containingMime) { + String result = containingMime; + DrmManagerClient drmClient = new DrmManagerClient(context); + try { + if (drmClient.canHandle(path, null)) { + result = drmClient.getOriginalMimeType(path); + } + } catch (IllegalArgumentException ex) { + Log.w(TAG, + "Can't get original mime type since path is null or empty string."); + } catch (IllegalStateException ex) { + Log.w(TAG, "DrmManagerClient didn't initialize properly."); + } + return result; + } +} diff --git a/src/com/android/messaging/mmslib/util/DrmConvertSession.java b/src/com/android/messaging/mmslib/util/DrmConvertSession.java new file mode 100644 index 0000000..604e391 --- /dev/null +++ b/src/com/android/messaging/mmslib/util/DrmConvertSession.java @@ -0,0 +1,174 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.android.messaging.mmslib.util; + +import android.content.Context; +import android.drm.DrmConvertedStatus; +import android.drm.DrmManagerClient; +import android.util.Log; + +import com.android.messaging.mmslib.Downloads; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.RandomAccessFile; + + +public class DrmConvertSession { + private DrmManagerClient mDrmClient; + private int mConvertSessionId; + private static final String TAG = "DrmConvertSession"; + + private DrmConvertSession(DrmManagerClient drmClient, int convertSessionId) { + mDrmClient = drmClient; + mConvertSessionId = convertSessionId; + } + + /** + * Start of converting a file. + * + * @param context The context of the application running the convert session. + * @param mimeType Mimetype of content that shall be converted. + * @return A convert session or null in case an error occurs. + */ + public static DrmConvertSession open(Context context, String mimeType) { + DrmManagerClient drmClient = null; + int convertSessionId = -1; + if (context != null && mimeType != null && !mimeType.equals("")) { + try { + drmClient = new DrmManagerClient(context); + try { + convertSessionId = drmClient.openConvertSession(mimeType); + } catch (IllegalArgumentException e) { + Log.w(TAG, "Conversion of Mimetype: " + mimeType + + " is not supported.", e); + } catch (IllegalStateException e) { + Log.w(TAG, "Could not access Open DrmFramework.", e); + } + } catch (IllegalArgumentException e) { + Log.w(TAG, + "DrmManagerClient instance could not be created, context is Illegal."); + } catch (IllegalStateException e) { + Log.w(TAG, "DrmManagerClient didn't initialize properly."); + } + } + + if (drmClient == null || convertSessionId < 0) { + return null; + } else { + return new DrmConvertSession(drmClient, convertSessionId); + } + } + + /** + * Convert a buffer of data to protected format. + * + * @param buffer Buffer filled with data to convert. + * @param size The number of bytes that shall be converted. + * @return A Buffer filled with converted data, if execution is ok, in all + * other case null. + */ + public byte[] convert(byte[] inBuffer, int size) { + byte[] result = null; + if (inBuffer != null) { + DrmConvertedStatus convertedStatus = null; + try { + if (size != inBuffer.length) { + byte[] buf = new byte[size]; + System.arraycopy(inBuffer, 0, buf, 0, size); + convertedStatus = mDrmClient.convertData(mConvertSessionId, buf); + } else { + convertedStatus = mDrmClient.convertData(mConvertSessionId, inBuffer); + } + + if (convertedStatus != null && + convertedStatus.statusCode == DrmConvertedStatus.STATUS_OK && + convertedStatus.convertedData != null) { + result = convertedStatus.convertedData; + } + } catch (IllegalArgumentException e) { + Log.w(TAG, "Buffer with data to convert is illegal. Convertsession: " + + mConvertSessionId, e); + } catch (IllegalStateException e) { + Log.w(TAG, "Could not convert data. Convertsession: " + + mConvertSessionId, e); + } + } else { + throw new IllegalArgumentException("Parameter inBuffer is null"); + } + return result; + } + + /** + * Ends a conversion session of a file. + * + * @param fileName The filename of the converted file. + * @return Downloads.Impl.STATUS_SUCCESS if execution is ok. + * Downloads.Impl.STATUS_FILE_ERROR in case converted file can not + * be accessed. Downloads.Impl.STATUS_NOT_ACCEPTABLE if a problem + * occurs when accessing drm framework. + * Downloads.Impl.STATUS_UNKNOWN_ERROR if a general error occurred. + */ + public int close(String filename) { + DrmConvertedStatus convertedStatus = null; + int result = Downloads.Impl.STATUS_UNKNOWN_ERROR; + if (mDrmClient != null && mConvertSessionId >= 0) { + try { + convertedStatus = mDrmClient.closeConvertSession(mConvertSessionId); + if (convertedStatus == null || + convertedStatus.statusCode != DrmConvertedStatus.STATUS_OK || + convertedStatus.convertedData == null) { + result = Downloads.Impl.STATUS_NOT_ACCEPTABLE; + } else { + RandomAccessFile rndAccessFile = null; + try { + rndAccessFile = new RandomAccessFile(filename, "rw"); + rndAccessFile.seek(convertedStatus.offset); + rndAccessFile.write(convertedStatus.convertedData); + result = Downloads.Impl.STATUS_SUCCESS; + } catch (FileNotFoundException e) { + result = Downloads.Impl.STATUS_FILE_ERROR; + Log.w(TAG, "File: " + filename + " could not be found.", e); + } catch (IOException e) { + result = Downloads.Impl.STATUS_FILE_ERROR; + Log.w(TAG, "Could not access File: " + filename + " .", e); + } catch (IllegalArgumentException e) { + result = Downloads.Impl.STATUS_FILE_ERROR; + Log.w(TAG, "Could not open file in mode: rw", e); + } catch (SecurityException e) { + Log.w(TAG, "Access to File: " + filename + + " was denied denied by SecurityManager.", e); + } finally { + if (rndAccessFile != null) { + try { + rndAccessFile.close(); + } catch (IOException e) { + result = Downloads.Impl.STATUS_FILE_ERROR; + Log.w(TAG, "Failed to close File:" + filename + + ".", e); + } + } + } + } + } catch (IllegalStateException e) { + Log.w(TAG, "Could not close convertsession. Convertsession: " + + mConvertSessionId, e); + } + } + return result; + } +} diff --git a/src/com/android/messaging/mmslib/util/PduCache.java b/src/com/android/messaging/mmslib/util/PduCache.java new file mode 100644 index 0000000..9a400c0 --- /dev/null +++ b/src/com/android/messaging/mmslib/util/PduCache.java @@ -0,0 +1,262 @@ +/* + * Copyright (C) 2008 Esmertec AG. + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.util; + +import android.content.ContentUris; +import android.content.UriMatcher; +import android.net.Uri; +import android.provider.Telephony.Mms; +import android.support.v4.util.SimpleArrayMap; +import android.util.Log; +import android.util.SparseArray; + +import java.util.HashSet; + +public final class PduCache extends AbstractCache { + private static final String TAG = "PduCache"; + private static final boolean LOCAL_LOGV = false; + + private static final int MMS_ALL = 0; + private static final int MMS_ALL_ID = 1; + private static final int MMS_INBOX = 2; + private static final int MMS_INBOX_ID = 3; + private static final int MMS_SENT = 4; + private static final int MMS_SENT_ID = 5; + private static final int MMS_DRAFTS = 6; + private static final int MMS_DRAFTS_ID = 7; + private static final int MMS_OUTBOX = 8; + private static final int MMS_OUTBOX_ID = 9; + private static final int MMS_CONVERSATION = 10; + private static final int MMS_CONVERSATION_ID = 11; + + private static final UriMatcher URI_MATCHER; + private static final SparseArray MATCH_TO_MSGBOX_ID_MAP; + + private static PduCache sInstance; + + static { + URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH); + URI_MATCHER.addURI("mms", null, MMS_ALL); + URI_MATCHER.addURI("mms", "#", MMS_ALL_ID); + URI_MATCHER.addURI("mms", "inbox", MMS_INBOX); + URI_MATCHER.addURI("mms", "inbox/#", MMS_INBOX_ID); + URI_MATCHER.addURI("mms", "sent", MMS_SENT); + URI_MATCHER.addURI("mms", "sent/#", MMS_SENT_ID); + URI_MATCHER.addURI("mms", "drafts", MMS_DRAFTS); + URI_MATCHER.addURI("mms", "drafts/#", MMS_DRAFTS_ID); + URI_MATCHER.addURI("mms", "outbox", MMS_OUTBOX); + URI_MATCHER.addURI("mms", "outbox/#", MMS_OUTBOX_ID); + URI_MATCHER.addURI("mms-sms", "conversations", MMS_CONVERSATION); + URI_MATCHER.addURI("mms-sms", "conversations/#", MMS_CONVERSATION_ID); + + MATCH_TO_MSGBOX_ID_MAP = new SparseArray(); + MATCH_TO_MSGBOX_ID_MAP.put(MMS_INBOX, Mms.MESSAGE_BOX_INBOX); + MATCH_TO_MSGBOX_ID_MAP.put(MMS_SENT, Mms.MESSAGE_BOX_SENT); + MATCH_TO_MSGBOX_ID_MAP.put(MMS_DRAFTS, Mms.MESSAGE_BOX_DRAFTS); + MATCH_TO_MSGBOX_ID_MAP.put(MMS_OUTBOX, Mms.MESSAGE_BOX_OUTBOX); + } + + private final SparseArray> mMessageBoxes; + private final SimpleArrayMap> mThreads; + private final HashSet mUpdating; + + private PduCache() { + mMessageBoxes = new SparseArray>(); + mThreads = new SimpleArrayMap>(); + mUpdating = new HashSet(); + } + + public static final synchronized PduCache getInstance() { + if (sInstance == null) { + if (LOCAL_LOGV) { + Log.v(TAG, "Constructing new PduCache instance."); + } + sInstance = new PduCache(); + } + return sInstance; + } + + @Override + public synchronized boolean put(Uri uri, PduCacheEntry entry) { + int msgBoxId = entry.getMessageBox(); + HashSet msgBox = mMessageBoxes.get(msgBoxId); + if (msgBox == null) { + msgBox = new HashSet(); + mMessageBoxes.put(msgBoxId, msgBox); + } + + long threadId = entry.getThreadId(); + HashSet thread = mThreads.get(threadId); + if (thread == null) { + thread = new HashSet(); + mThreads.put(threadId, thread); + } + + Uri finalKey = normalizeKey(uri); + boolean result = super.put(finalKey, entry); + if (result) { + msgBox.add(finalKey); + thread.add(finalKey); + } + setUpdating(uri, false); + return result; + } + + public synchronized void setUpdating(Uri uri, boolean updating) { + if (updating) { + mUpdating.add(uri); + } else { + mUpdating.remove(uri); + } + } + + public synchronized boolean isUpdating(Uri uri) { + return mUpdating.contains(uri); + } + + @Override + public synchronized PduCacheEntry purge(Uri uri) { + int match = URI_MATCHER.match(uri); + switch (match) { + case MMS_ALL_ID: + return purgeSingleEntry(uri); + case MMS_INBOX_ID: + case MMS_SENT_ID: + case MMS_DRAFTS_ID: + case MMS_OUTBOX_ID: + String msgId = uri.getLastPathSegment(); + return purgeSingleEntry(Uri.withAppendedPath(Mms.CONTENT_URI, msgId)); + // Implicit batch of purge, return null. + case MMS_ALL: + case MMS_CONVERSATION: + purgeAll(); + return null; + case MMS_INBOX: + case MMS_SENT: + case MMS_DRAFTS: + case MMS_OUTBOX: + purgeByMessageBox(MATCH_TO_MSGBOX_ID_MAP.get(match)); + return null; + case MMS_CONVERSATION_ID: + purgeByThreadId(ContentUris.parseId(uri)); + return null; + default: + return null; + } + } + + private PduCacheEntry purgeSingleEntry(Uri key) { + mUpdating.remove(key); + PduCacheEntry entry = super.purge(key); + if (entry != null) { + removeFromThreads(key, entry); + removeFromMessageBoxes(key, entry); + return entry; + } + return null; + } + + @Override + public synchronized void purgeAll() { + super.purgeAll(); + + mMessageBoxes.clear(); + mThreads.clear(); + mUpdating.clear(); + } + + /** + * @param uri The Uri to be normalized. + * @return Uri The normalized key of cached entry. + */ + private Uri normalizeKey(Uri uri) { + int match = URI_MATCHER.match(uri); + Uri normalizedKey = null; + + switch (match) { + case MMS_ALL_ID: + normalizedKey = uri; + break; + case MMS_INBOX_ID: + case MMS_SENT_ID: + case MMS_DRAFTS_ID: + case MMS_OUTBOX_ID: + String msgId = uri.getLastPathSegment(); + normalizedKey = Uri.withAppendedPath(Mms.CONTENT_URI, msgId); + break; + default: + return null; + } + + if (LOCAL_LOGV) { + Log.v(TAG, uri + " -> " + normalizedKey); + } + return normalizedKey; + } + + private void purgeByMessageBox(Integer msgBoxId) { + if (LOCAL_LOGV) { + Log.v(TAG, "Purge cache in message box: " + msgBoxId); + } + + if (msgBoxId != null) { + HashSet msgBox = mMessageBoxes.get(msgBoxId); + mMessageBoxes.remove(msgBoxId); + if (msgBox != null) { + for (Uri key : msgBox) { + mUpdating.remove(key); + PduCacheEntry entry = super.purge(key); + if (entry != null) { + removeFromThreads(key, entry); + } + } + } + } + } + + private void removeFromThreads(Uri key, PduCacheEntry entry) { + HashSet thread = mThreads.get(entry.getThreadId()); + if (thread != null) { + thread.remove(key); + } + } + + private void purgeByThreadId(long threadId) { + if (LOCAL_LOGV) { + Log.v(TAG, "Purge cache in thread: " + threadId); + } + + HashSet thread = mThreads.remove(threadId); + if (thread != null) { + for (Uri key : thread) { + mUpdating.remove(key); + PduCacheEntry entry = super.purge(key); + if (entry != null) { + removeFromMessageBoxes(key, entry); + } + } + } + } + + private void removeFromMessageBoxes(Uri key, PduCacheEntry entry) { + HashSet msgBox = mThreads.get(Long.valueOf(entry.getMessageBox())); + if (msgBox != null) { + msgBox.remove(key); + } + } +} diff --git a/src/com/android/messaging/mmslib/util/PduCacheEntry.java b/src/com/android/messaging/mmslib/util/PduCacheEntry.java new file mode 100644 index 0000000..b287f00 --- /dev/null +++ b/src/com/android/messaging/mmslib/util/PduCacheEntry.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2008 Esmertec AG. + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.mmslib.util; + +import com.android.messaging.mmslib.pdu.GenericPdu; + +public final class PduCacheEntry { + private final GenericPdu mPdu; + private final int mMessageBox; + private final long mThreadId; + + public PduCacheEntry(GenericPdu pdu, int msgBox, long threadId) { + mPdu = pdu; + mMessageBox = msgBox; + mThreadId = threadId; + } + + public GenericPdu getPdu() { + return mPdu; + } + + public int getMessageBox() { + return mMessageBox; + } + + public long getThreadId() { + return mThreadId; + } +} diff --git a/src/com/android/messaging/receiver/AbortMmsWapPushReceiver.java b/src/com/android/messaging/receiver/AbortMmsWapPushReceiver.java new file mode 100644 index 0000000..9e09a2a --- /dev/null +++ b/src/com/android/messaging/receiver/AbortMmsWapPushReceiver.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.provider.Telephony; + +import com.android.messaging.util.ContentType; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +/** + * This receiver is used to abort MMS WAP broadcasts pre-KLP when SMS is enabled. + */ +public class AbortMmsWapPushReceiver extends BroadcastReceiver { + @Override + public void onReceive(final Context context, final Intent intent) { + if (Telephony.Sms.Intents.WAP_PUSH_RECEIVED_ACTION.equals(intent.getAction()) + && ContentType.MMS_MESSAGE.equals(intent.getType())) { + // If we are enabled, it's our job to stop the broadcast from continuing. This + // receiver is not used on KLP but we do an extra check here just to make sure. + if (!OsUtil.isAtLeastKLP() && PhoneUtils.getDefault().isSmsEnabled()) { + abortBroadcast(); + } + } + } +} diff --git a/src/com/android/messaging/receiver/AbortSmsReceiver.java b/src/com/android/messaging/receiver/AbortSmsReceiver.java new file mode 100644 index 0000000..f4491d8 --- /dev/null +++ b/src/com/android/messaging/receiver/AbortSmsReceiver.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +/** + * This receiver is used to abort SMS broadcasts pre-KLP when SMS is enabled. + */ +public final class AbortSmsReceiver extends BroadcastReceiver { + + @Override + public void onReceive(final Context context, final Intent intent) { + // If we are enabled, it's our job to stop the broadcast from continuing. This + // receiver is not used on KLP but we do an extra check here just to make sure. + if (!OsUtil.isAtLeastKLP() && PhoneUtils.getDefault().isSmsEnabled()) { + if (!SmsReceiver.shouldIgnoreMessage(intent)) { + abortBroadcast(); + } + } + } +} diff --git a/src/com/android/messaging/receiver/BootAndPackageReplacedReceiver.java b/src/com/android/messaging/receiver/BootAndPackageReplacedReceiver.java new file mode 100644 index 0000000..be0d296 --- /dev/null +++ b/src/com/android/messaging/receiver/BootAndPackageReplacedReceiver.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import com.android.messaging.BugleApplication; +import com.android.messaging.Factory; +import com.android.messaging.datamodel.action.UpdateMessageNotificationAction; +import com.android.messaging.util.BuglePrefsKeys; +import com.android.messaging.util.LogUtil; + +/** + * Receives notification of boot completion and package replacement + */ +public class BootAndPackageReplacedReceiver extends BroadcastReceiver { + @Override + public void onReceive(final Context context, final Intent intent) { + if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction()) + || Intent.ACTION_MY_PACKAGE_REPLACED.equals(intent.getAction())) { + // Repost unseen notifications + Factory.get().getApplicationPrefs().putLong( + BuglePrefsKeys.LATEST_NOTIFICATION_MESSAGE_TIMESTAMP, Long.MIN_VALUE); + UpdateMessageNotificationAction.updateMessageNotification(); + + BugleApplication.updateAppConfig(context); + } else { + LogUtil.i(LogUtil.BUGLE_TAG, "BootAndPackageReplacedReceiver got unexpected action: " + + intent.getAction()); + } + } +} + diff --git a/src/com/android/messaging/receiver/DefaultSmsSubscriptionChangeReceiver.java b/src/com/android/messaging/receiver/DefaultSmsSubscriptionChangeReceiver.java new file mode 100644 index 0000000..d5153a0 --- /dev/null +++ b/src/com/android/messaging/receiver/DefaultSmsSubscriptionChangeReceiver.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import com.android.messaging.datamodel.ParticipantRefresh; + +/** + * Responds to default SMS subscription selection changes from system Settings. + */ +public class DefaultSmsSubscriptionChangeReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + ParticipantRefresh.refreshSelfParticipants(); + } +} diff --git a/src/com/android/messaging/receiver/MmsWapPushDeliverReceiver.java b/src/com/android/messaging/receiver/MmsWapPushDeliverReceiver.java new file mode 100644 index 0000000..a5c247c --- /dev/null +++ b/src/com/android/messaging/receiver/MmsWapPushDeliverReceiver.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.provider.Telephony; + +import com.android.messaging.util.ContentType; +import com.android.messaging.util.PhoneUtils; + +/** + * Class that handles MMS WAP push intent from telephony on KLP+ Devices. + */ +public class MmsWapPushDeliverReceiver extends BroadcastReceiver { + + @Override + public void onReceive(final Context context, final Intent intent) { + if (Telephony.Sms.Intents.WAP_PUSH_DELIVER_ACTION.equals(intent.getAction()) + && ContentType.MMS_MESSAGE.equals(intent.getType())) { + // Always convert negative subIds into -1 + int subId = PhoneUtils.getDefault().getEffectiveIncomingSubIdFromSystem( + intent, MmsWapPushReceiver.EXTRA_SUBSCRIPTION); + byte[] data = intent.getByteArrayExtra(MmsWapPushReceiver.EXTRA_DATA); + MmsWapPushReceiver.mmsReceived(subId, data); + } + } +} diff --git a/src/com/android/messaging/receiver/MmsWapPushReceiver.java b/src/com/android/messaging/receiver/MmsWapPushReceiver.java new file mode 100644 index 0000000..29cf0db --- /dev/null +++ b/src/com/android/messaging/receiver/MmsWapPushReceiver.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.provider.Telephony; + +import com.android.messaging.datamodel.action.ReceiveMmsMessageAction; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.PhoneUtils; + +/** + * Class that handles MMS WAP push intent from telephony on pre-KLP Devices. + */ +public class MmsWapPushReceiver extends BroadcastReceiver { + static final String EXTRA_SUBSCRIPTION = "subscription"; + static final String EXTRA_DATA = "data"; + + @Override + public void onReceive(final Context context, final Intent intent) { + if (Telephony.Sms.Intents.WAP_PUSH_RECEIVED_ACTION.equals(intent.getAction()) + && ContentType.MMS_MESSAGE.equals(intent.getType())) { + if (PhoneUtils.getDefault().isSmsEnabled()) { + // Always convert negative subIds into -1 + final int subId = PhoneUtils.getDefault().getEffectiveIncomingSubIdFromSystem( + intent, MmsWapPushReceiver.EXTRA_SUBSCRIPTION); + final byte[] data = intent.getByteArrayExtra(MmsWapPushReceiver.EXTRA_DATA); + mmsReceived(subId, data); + } + } + } + + static void mmsReceived(final int subId, final byte[] data) { + if (!PhoneUtils.getDefault().isSmsEnabled()) { + return; + } + + final ReceiveMmsMessageAction action = new ReceiveMmsMessageAction(subId, data); + action.start(); + } +} + diff --git a/src/com/android/messaging/receiver/NotificationReceiver.java b/src/com/android/messaging/receiver/NotificationReceiver.java new file mode 100644 index 0000000..bbb847d --- /dev/null +++ b/src/com/android/messaging/receiver/NotificationReceiver.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.action.MarkAsSeenAction; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.ConversationIdSet; +import com.android.messaging.util.LogUtil; + +// NotificationReceiver is used to handle delete intents from notifications. When a user +// clears all notifications or swipes a bugle notification away, the intent we pass in as +// the delete intent will get handled here. +public class NotificationReceiver extends BroadcastReceiver { + // Logging + public static final String TAG = LogUtil.BUGLE_TAG; + public static final boolean VERBOSE = false; + + @Override + public void onReceive(final Context context, final Intent intent) { + if (VERBOSE) { + LogUtil.v(TAG, "NotificationReceiver.onReceive: intent " + intent); + } + if (intent.getAction().equals(UIIntents.ACTION_RESET_NOTIFICATIONS)) { + final String conversationIdSetString = + intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID_SET); + final int notificationTargets = intent.getIntExtra( + UIIntents.UI_INTENT_EXTRA_NOTIFICATIONS_UPDATE, BugleNotifications.UPDATE_ALL); + if (conversationIdSetString == null) { + BugleNotifications.markAllMessagesAsSeen(); + } else { + for (final String conversationId : + ConversationIdSet.createSet(conversationIdSetString)) { + MarkAsSeenAction.markAsSeen(conversationId); + BugleNotifications.resetLastMessageDing(conversationId); + } + } + } + } +} \ No newline at end of file diff --git a/src/com/android/messaging/receiver/SendStatusReceiver.java b/src/com/android/messaging/receiver/SendStatusReceiver.java new file mode 100644 index 0000000..fc0e8c9 --- /dev/null +++ b/src/com/android/messaging/receiver/SendStatusReceiver.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.telephony.SmsMessage; + +import com.android.messaging.datamodel.action.ProcessDeliveryReportAction; +import com.android.messaging.datamodel.action.ProcessDownloadedMmsAction; +import com.android.messaging.datamodel.action.ProcessSentMessageAction; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.sms.SmsSender; +import com.android.messaging.util.LogUtil; + +/** + * The SMS sent and delivery intent receiver. + * + * This class just simply forwards the intents to proper recipients for actual handling. + */ +public class SendStatusReceiver extends BroadcastReceiver { + public static final String MESSAGE_SENT_ACTION = + "com.android.messaging.receiver.SendStatusReceiver.MESSAGE_SENT"; + public static final String MESSAGE_DELIVERED_ACTION = + "com.android.messaging.receiver.SendStatusReceiver.MESSAGE_DELIVERED"; + public static final String MMS_SENT_ACTION = + "com.android.messaging.receiver.SendStatusReceiver.MMS_SENT"; + public static final String MMS_DOWNLOADED_ACTION = + "com.android.messaging.receiver.SendStatusReceiver.MMS_DOWNLOADED"; + + // Defined by platform, but no constant provided. See docs for SmsManager.sendTextMessage. + public static final String EXTRA_ERROR_CODE = "errorCode"; + + public static final String EXTRA_PART_ID = "partId"; + public static final String EXTRA_SUB_ID = "subId"; + + public static final int NO_ERROR_CODE = 0; + public static final int NO_PART_ID = -1; + + @Override + public void onReceive(final Context context, final Intent intent) { + // This will be called on the main thread (so it should exit quickly) + final String action = intent.getAction(); + final int resultCode = getResultCode(); + if (MESSAGE_SENT_ACTION.equals(action)) { + final Uri requestId = intent.getData(); + SmsSender.setResult( + requestId, + resultCode, + intent.getIntExtra(EXTRA_ERROR_CODE, NO_ERROR_CODE), + intent.getIntExtra(EXTRA_PART_ID, NO_PART_ID), + intent.getIntExtra(EXTRA_SUB_ID, ParticipantData.DEFAULT_SELF_SUB_ID)); + } else if (MMS_SENT_ACTION.equals(action)) { + final Uri messageUri = intent.getData(); + ProcessSentMessageAction.processMmsSent(resultCode, messageUri, + intent.getExtras()); + } else if (MMS_DOWNLOADED_ACTION.equals(action)) { + ProcessDownloadedMmsAction.processMessageDownloaded(resultCode, + intent.getExtras()); + } else if (MESSAGE_DELIVERED_ACTION.equals(action)) { + final SmsMessage smsMessage = MmsUtils.getSmsMessageFromDeliveryReport(intent); + final Uri smsMessageUri = intent.getData(); + if (smsMessage == null) { + LogUtil.e(LogUtil.BUGLE_TAG, "SendStatusReceiver: empty report message"); + return; + } + int status = 0; + try { + status = smsMessage.getStatus(); + } catch (final NullPointerException e) { + // Sometimes, SmsMessage.mWrappedSmsMessage is null causing NPE when we access + // the methods on it although the SmsMessage itself is not null. + LogUtil.e(LogUtil.BUGLE_TAG, "SendStatusReceiver: NPE inside SmsMessage"); + return; + } + ProcessDeliveryReportAction.deliveryReportReceived(smsMessageUri, status); + } + } +} diff --git a/src/com/android/messaging/receiver/SmsDeliverReceiver.java b/src/com/android/messaging/receiver/SmsDeliverReceiver.java new file mode 100644 index 0000000..6a9b66c --- /dev/null +++ b/src/com/android/messaging/receiver/SmsDeliverReceiver.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +/** + * Class that receives incoming SMS messages on KLP+ Devices. + */ +public final class SmsDeliverReceiver extends BroadcastReceiver { + @Override + public void onReceive(final Context context, final Intent intent) { + SmsReceiver.deliverSmsIntent(context, intent); + } +} diff --git a/src/com/android/messaging/receiver/SmsReceiver.java b/src/com/android/messaging/receiver/SmsReceiver.java new file mode 100644 index 0000000..db9b4bb --- /dev/null +++ b/src/com/android/messaging/receiver/SmsReceiver.java @@ -0,0 +1,375 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.receiver; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.provider.Telephony; +import android.provider.Telephony.Sms; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.NotificationCompat.Builder; +import android.support.v4.app.NotificationCompat.Style; +import android.support.v4.app.NotificationManagerCompat; + +import java.util.ArrayList; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.BugleNotifications; +import com.android.messaging.datamodel.MessageNotificationState; +import com.android.messaging.datamodel.NoConfirmationSmsSendService; +import com.android.messaging.datamodel.action.ReceiveSmsMessageAction; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PendingIntentConstants; +import com.android.messaging.util.PhoneUtils; + +/** + * Class that receives incoming SMS messages through android.provider.Telephony.SMS_RECEIVED + * + * This class serves two purposes: + * - Process phone verification SMS messages + * - Handle SMS messages when the user has enabled us to be the default SMS app (Pre-KLP) + */ +public final class SmsReceiver extends BroadcastReceiver { + private static final String TAG = LogUtil.BUGLE_TAG; + + private static ArrayList sIgnoreSmsPatterns; + + /** + * Enable or disable the SmsReceiver as appropriate. Pre-KLP we use this receiver for + * receiving incoming SMS messages. For KLP+ this receiver is not used when running as the + * primary user and the SmsDeliverReceiver is used for receiving incoming SMS messages. + * When running as a secondary user, this receiver is still used to trigger the incoming + * notification. + */ + public static void updateSmsReceiveHandler(final Context context) { + boolean smsReceiverEnabled; + boolean mmsWapPushReceiverEnabled; + boolean respondViaMessageEnabled; + boolean broadcastAbortEnabled; + + if (OsUtil.isAtLeastKLP()) { + // When we're running as the secondary user, we don't get the new SMS_DELIVER intent, + // only the primary user receives that. As secondary, we need to go old-school and + // listen for the SMS_RECEIVED intent. For the secondary user, use this SmsReceiver + // for both sms and mms notification. For the primary user on KLP (and above), we don't + // use the SmsReceiver. + smsReceiverEnabled = OsUtil.isSecondaryUser(); + // On KLP use the new deliver event for mms + mmsWapPushReceiverEnabled = false; + // On KLP we need to always enable this handler to show in the list of sms apps + respondViaMessageEnabled = true; + // On KLP we don't need to abort the broadcast + broadcastAbortEnabled = false; + } else { + // On JB we use the sms receiver for both sms/mms delivery + final boolean carrierSmsEnabled = PhoneUtils.getDefault().isSmsEnabled(); + smsReceiverEnabled = carrierSmsEnabled; + + // On JB we use the mms receiver when sms/mms is enabled + mmsWapPushReceiverEnabled = carrierSmsEnabled; + // On JB this is dynamic to make sure we don't show in dialer if sms is disabled + respondViaMessageEnabled = carrierSmsEnabled; + // On JB we need to abort broadcasts if SMS is enabled + broadcastAbortEnabled = carrierSmsEnabled; + } + + final PackageManager packageManager = context.getPackageManager(); + final boolean logv = LogUtil.isLoggable(TAG, LogUtil.VERBOSE); + if (smsReceiverEnabled) { + if (logv) { + LogUtil.v(TAG, "Enabling SMS message receiving"); + } + packageManager.setComponentEnabledSetting( + new ComponentName(context, SmsReceiver.class), + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP); + + } else { + if (logv) { + LogUtil.v(TAG, "Disabling SMS message receiving"); + } + packageManager.setComponentEnabledSetting( + new ComponentName(context, SmsReceiver.class), + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); + } + if (mmsWapPushReceiverEnabled) { + if (logv) { + LogUtil.v(TAG, "Enabling MMS message receiving"); + } + packageManager.setComponentEnabledSetting( + new ComponentName(context, MmsWapPushReceiver.class), + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP); + } else { + if (logv) { + LogUtil.v(TAG, "Disabling MMS message receiving"); + } + packageManager.setComponentEnabledSetting( + new ComponentName(context, MmsWapPushReceiver.class), + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); + } + if (broadcastAbortEnabled) { + if (logv) { + LogUtil.v(TAG, "Enabling SMS/MMS broadcast abort"); + } + packageManager.setComponentEnabledSetting( + new ComponentName(context, AbortSmsReceiver.class), + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP); + packageManager.setComponentEnabledSetting( + new ComponentName(context, AbortMmsWapPushReceiver.class), + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP); + } else { + if (logv) { + LogUtil.v(TAG, "Disabling SMS/MMS broadcast abort"); + } + packageManager.setComponentEnabledSetting( + new ComponentName(context, AbortSmsReceiver.class), + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); + packageManager.setComponentEnabledSetting( + new ComponentName(context, AbortMmsWapPushReceiver.class), + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); + } + if (respondViaMessageEnabled) { + if (logv) { + LogUtil.v(TAG, "Enabling respond via message intent"); + } + packageManager.setComponentEnabledSetting( + new ComponentName(context, NoConfirmationSmsSendService.class), + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP); + } else { + if (logv) { + LogUtil.v(TAG, "Disabling respond via message intent"); + } + packageManager.setComponentEnabledSetting( + new ComponentName(context, NoConfirmationSmsSendService.class), + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); + } + } + + private static final String EXTRA_ERROR_CODE = "errorCode"; + private static final String EXTRA_SUB_ID = "subscription"; + + public static void deliverSmsIntent(final Context context, final Intent intent) { + final android.telephony.SmsMessage[] messages = getMessagesFromIntent(intent); + + // Check messages for validity + if (messages == null || messages.length < 1) { + LogUtil.e(TAG, "processReceivedSms: null or zero or ignored message"); + return; + } + + final int errorCode = intent.getIntExtra(EXTRA_ERROR_CODE, 0); + // Always convert negative subIds into -1 + int subId = PhoneUtils.getDefault().getEffectiveIncomingSubIdFromSystem( + intent, EXTRA_SUB_ID); + deliverSmsMessages(context, subId, errorCode, messages); + if (MmsUtils.isDumpSmsEnabled()) { + final String format = null; + DebugUtils.dumpSms(messages[0].getTimestampMillis(), messages, format); + } + } + + public static void deliverSmsMessages(final Context context, final int subId, + final int errorCode, final android.telephony.SmsMessage[] messages) { + final ContentValues messageValues = + MmsUtils.parseReceivedSmsMessage(context, messages, errorCode); + + LogUtil.v(TAG, "SmsReceiver.deliverSmsMessages"); + + final long nowInMillis = System.currentTimeMillis(); + final long receivedTimestampMs = MmsUtils.getMessageDate(messages[0], nowInMillis); + + messageValues.put(Sms.Inbox.DATE, receivedTimestampMs); + // Default to unread and unseen for us but ReceiveSmsMessageAction will override + // seen for the telephony db. + messageValues.put(Sms.Inbox.READ, 0); + messageValues.put(Sms.Inbox.SEEN, 0); + if (OsUtil.isAtLeastL_MR1()) { + messageValues.put(Sms.SUBSCRIPTION_ID, subId); + } + + if (messages[0].getMessageClass() == android.telephony.SmsMessage.MessageClass.CLASS_0 || + DebugUtils.debugClassZeroSmsEnabled()) { + Factory.get().getUIIntents().launchClassZeroActivity(context, messageValues); + } else { + final ReceiveSmsMessageAction action = new ReceiveSmsMessageAction(messageValues); + action.start(); + } + } + + @Override + public void onReceive(final Context context, final Intent intent) { + LogUtil.v(TAG, "SmsReceiver.onReceive " + intent); + // On KLP+ we only take delivery of SMS messages in SmsDeliverReceiver. + if (PhoneUtils.getDefault().isSmsEnabled()) { + final String action = intent.getAction(); + if (OsUtil.isSecondaryUser() && + (Telephony.Sms.Intents.SMS_RECEIVED_ACTION.equals(action) || + // TODO: update this with the actual constant from Telephony + "android.provider.Telephony.MMS_DOWNLOADED".equals(action))) { + postNewMessageSecondaryUserNotification(); + } else if (!OsUtil.isAtLeastKLP()) { + deliverSmsIntent(context, intent); + } + } + } + + private static class SecondaryUserNotificationState extends MessageNotificationState { + SecondaryUserNotificationState() { + super(null); + } + + @Override + protected Style build(Builder builder) { + return null; + } + + @Override + public boolean getNotificationVibrate() { + return true; + } + } + + public static void postNewMessageSecondaryUserNotification() { + final Context context = Factory.get().getApplicationContext(); + final Resources resources = context.getResources(); + final PendingIntent pendingIntent = UIIntents.get() + .getPendingIntentForSecondaryUserNewMessageNotification(context); + + final NotificationCompat.Builder builder = new NotificationCompat.Builder(context); + builder.setContentTitle(resources.getString(R.string.secondary_user_new_message_title)) + .setTicker(resources.getString(R.string.secondary_user_new_message_ticker)) + .setSmallIcon(R.drawable.ic_sms_light) + // Returning PRIORITY_HIGH causes L to put up a HUD notification. Without it, the ticker + // isn't displayed. + .setPriority(Notification.PRIORITY_HIGH) + .setContentIntent(pendingIntent); + + final NotificationCompat.BigTextStyle bigTextStyle = + new NotificationCompat.BigTextStyle(builder); + bigTextStyle.bigText(resources.getString(R.string.secondary_user_new_message_title)); + final Notification notification = bigTextStyle.build(); + + final NotificationManagerCompat notificationManager = + NotificationManagerCompat.from(Factory.get().getApplicationContext()); + + int defaults = Notification.DEFAULT_LIGHTS; + if (BugleNotifications.shouldVibrate(new SecondaryUserNotificationState())) { + defaults |= Notification.DEFAULT_VIBRATE; + } + notification.defaults = defaults; + + notificationManager.notify(getNotificationTag(), + PendingIntentConstants.SMS_SECONDARY_USER_NOTIFICATION_ID, notification); + } + + /** + * Cancel the notification + */ + public static void cancelSecondaryUserNotification() { + final NotificationManagerCompat notificationManager = + NotificationManagerCompat.from(Factory.get().getApplicationContext()); + notificationManager.cancel(getNotificationTag(), + PendingIntentConstants.SMS_SECONDARY_USER_NOTIFICATION_ID); + } + + private static String getNotificationTag() { + return Factory.get().getApplicationContext().getPackageName() + ":secondaryuser"; + } + + /** + * Compile all of the patterns we check for to ignore system SMS messages. + */ + private static void compileIgnoreSmsPatterns() { + // Get the pattern set from GServices + final String smsIgnoreRegex = BugleGservices.get().getString( + BugleGservicesKeys.SMS_IGNORE_MESSAGE_REGEX, + BugleGservicesKeys.SMS_IGNORE_MESSAGE_REGEX_DEFAULT); + if (smsIgnoreRegex != null) { + final String[] ignoreSmsExpressions = smsIgnoreRegex.split("\n"); + if (ignoreSmsExpressions.length != 0) { + sIgnoreSmsPatterns = new ArrayList(); + for (int i = 0; i < ignoreSmsExpressions.length; i++) { + try { + sIgnoreSmsPatterns.add(Pattern.compile(ignoreSmsExpressions[i])); + } catch (PatternSyntaxException e) { + LogUtil.e(TAG, "compileIgnoreSmsPatterns: Skipping bad expression: " + + ignoreSmsExpressions[i]); + } + } + } + } + } + + /** + * Get the SMS messages from the specified SMS intent. + * @return the messages. If there is an error or the message should be ignored, return null. + */ + public static android.telephony.SmsMessage[] getMessagesFromIntent(Intent intent) { + final android.telephony.SmsMessage[] messages = Sms.Intents.getMessagesFromIntent(intent); + + // Check messages for validity + if (messages == null || messages.length < 1) { + return null; + } + // Sometimes, SmsMessage.mWrappedSmsMessage is null causing NPE when we access + // the methods on it although the SmsMessage itself is not null. So do this check + // before we do anything on the parsed SmsMessages. + try { + final String messageBody = messages[0].getDisplayMessageBody(); + if (messageBody != null) { + // Compile patterns if necessary + if (sIgnoreSmsPatterns == null) { + compileIgnoreSmsPatterns(); + } + // Check against filters + for (final Pattern pattern : sIgnoreSmsPatterns) { + if (pattern.matcher(messageBody).matches()) { + return null; + } + } + } + } catch (final NullPointerException e) { + LogUtil.e(TAG, "shouldIgnoreMessage: NPE inside SmsMessage"); + return null; + } + return messages; + } + + + /** + * Check the specified SMS intent to see if the message should be ignored + * @return true if the message should be ignored + */ + public static boolean shouldIgnoreMessage(Intent intent) { + return getMessagesFromIntent(intent) == null; + } +} diff --git a/src/com/android/messaging/receiver/StorageStatusReceiver.java b/src/com/android/messaging/receiver/StorageStatusReceiver.java new file mode 100644 index 0000000..bee899c --- /dev/null +++ b/src/com/android/messaging/receiver/StorageStatusReceiver.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import com.android.messaging.sms.SmsStorageStatusManager; + +/** + * Receiver that listens on storage status changes + */ +public class StorageStatusReceiver extends BroadcastReceiver { + + @Override + public void onReceive(final Context context, final Intent intent) { + if (Intent.ACTION_DEVICE_STORAGE_LOW.equals(intent.getAction())) { + SmsStorageStatusManager.handleStorageLow(); + } else if (Intent.ACTION_DEVICE_STORAGE_OK.equals(intent.getAction())) { + SmsStorageStatusManager.handleStorageOk(); + } + } +} diff --git a/src/com/android/messaging/sms/ApnDatabase.java b/src/com/android/messaging/sms/ApnDatabase.java new file mode 100644 index 0000000..a8d0d0c --- /dev/null +++ b/src/com/android/messaging/sms/ApnDatabase.java @@ -0,0 +1,374 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.sms; + +import android.content.ContentValues; +import android.content.Context; +import android.content.res.Resources; +import android.content.res.XmlResourceParser; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteOpenHelper; +import android.provider.Telephony; +import android.text.TextUtils; +import android.util.Log; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.PhoneUtils; +import com.google.common.collect.Lists; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/* + * Database helper class for looking up APNs. This database has a single table + * which stores the APNs that are initially created from an xml file. + */ +public class ApnDatabase extends SQLiteOpenHelper { + private static final int DB_VERSION = 3; // added sub_id columns + + private static final String TAG = LogUtil.BUGLE_TAG; + + private static final boolean DEBUG = false; + + private static Context sContext; + private static ApnDatabase sApnDatabase; + + private static final String APN_DATABASE_NAME = "apn.db"; + + /** table for carrier APN's */ + public static final String APN_TABLE = "apn"; + + // APN table + private static final String APN_TABLE_SQL = + "CREATE TABLE " + APN_TABLE + + "(_id INTEGER PRIMARY KEY," + + Telephony.Carriers.NAME + " TEXT," + + Telephony.Carriers.NUMERIC + " TEXT," + + Telephony.Carriers.MCC + " TEXT," + + Telephony.Carriers.MNC + " TEXT," + + Telephony.Carriers.APN + " TEXT," + + Telephony.Carriers.USER + " TEXT," + + Telephony.Carriers.SERVER + " TEXT," + + Telephony.Carriers.PASSWORD + " TEXT," + + Telephony.Carriers.PROXY + " TEXT," + + Telephony.Carriers.PORT + " TEXT," + + Telephony.Carriers.MMSPROXY + " TEXT," + + Telephony.Carriers.MMSPORT + " TEXT," + + Telephony.Carriers.MMSC + " TEXT," + + Telephony.Carriers.AUTH_TYPE + " INTEGER," + + Telephony.Carriers.TYPE + " TEXT," + + Telephony.Carriers.CURRENT + " INTEGER," + + Telephony.Carriers.PROTOCOL + " TEXT," + + Telephony.Carriers.ROAMING_PROTOCOL + " TEXT," + + Telephony.Carriers.CARRIER_ENABLED + " BOOLEAN," + + Telephony.Carriers.BEARER + " INTEGER," + + Telephony.Carriers.MVNO_TYPE + " TEXT," + + Telephony.Carriers.MVNO_MATCH_DATA + " TEXT," + + Telephony.Carriers.SUBSCRIPTION_ID + " INTEGER DEFAULT " + + ParticipantData.DEFAULT_SELF_SUB_ID + ");"; + + public static final String[] APN_PROJECTION = { + Telephony.Carriers.TYPE, // 0 + Telephony.Carriers.MMSC, // 1 + Telephony.Carriers.MMSPROXY, // 2 + Telephony.Carriers.MMSPORT, // 3 + Telephony.Carriers._ID, // 4 + Telephony.Carriers.CURRENT, // 5 + Telephony.Carriers.NUMERIC, // 6 + Telephony.Carriers.NAME, // 7 + Telephony.Carriers.MCC, // 8 + Telephony.Carriers.MNC, // 9 + Telephony.Carriers.APN, // 10 + Telephony.Carriers.SUBSCRIPTION_ID // 11 + }; + + public static final int COLUMN_TYPE = 0; + public static final int COLUMN_MMSC = 1; + public static final int COLUMN_MMSPROXY = 2; + public static final int COLUMN_MMSPORT = 3; + public static final int COLUMN_ID = 4; + public static final int COLUMN_CURRENT = 5; + public static final int COLUMN_NUMERIC = 6; + public static final int COLUMN_NAME = 7; + public static final int COLUMN_MCC = 8; + public static final int COLUMN_MNC = 9; + public static final int COLUMN_APN = 10; + public static final int COLUMN_SUB_ID = 11; + + public static final String[] APN_FULL_PROJECTION = { + Telephony.Carriers.NAME, + Telephony.Carriers.MCC, + Telephony.Carriers.MNC, + Telephony.Carriers.APN, + Telephony.Carriers.USER, + Telephony.Carriers.SERVER, + Telephony.Carriers.PASSWORD, + Telephony.Carriers.PROXY, + Telephony.Carriers.PORT, + Telephony.Carriers.MMSC, + Telephony.Carriers.MMSPROXY, + Telephony.Carriers.MMSPORT, + Telephony.Carriers.AUTH_TYPE, + Telephony.Carriers.TYPE, + Telephony.Carriers.PROTOCOL, + Telephony.Carriers.ROAMING_PROTOCOL, + Telephony.Carriers.CARRIER_ENABLED, + Telephony.Carriers.BEARER, + Telephony.Carriers.MVNO_TYPE, + Telephony.Carriers.MVNO_MATCH_DATA, + Telephony.Carriers.CURRENT, + Telephony.Carriers.SUBSCRIPTION_ID, + }; + + private static final String CURRENT_SELECTION = Telephony.Carriers.CURRENT + " NOT NULL"; + + /** + * ApnDatabase is initialized asynchronously from the application.onCreate + * To ensure that it works in a testing environment it needs to never access the factory context + */ + public static void initializeAppContext(final Context context) { + sContext = context; + } + + private ApnDatabase() { + super(sContext, APN_DATABASE_NAME, null, DB_VERSION); + if (DEBUG) { + LogUtil.d(TAG, "ApnDatabase constructor"); + } + } + + public static ApnDatabase getApnDatabase() { + if (sApnDatabase == null) { + sApnDatabase = new ApnDatabase(); + } + return sApnDatabase; + } + + public static boolean doesDatabaseExist() { + final File dbFile = sContext.getDatabasePath(APN_DATABASE_NAME); + return dbFile.exists(); + } + + @Override + public void onCreate(final SQLiteDatabase db) { + if (DEBUG) { + LogUtil.d(TAG, "ApnDatabase onCreate"); + } + // Build the table using defaults (apn info bundled with the app) + rebuildTables(db); + } + + /** + * Get a copy of user changes in the old table + * + * @return The list of user changed apns + */ + public static List loadUserDataFromOldTable(final SQLiteDatabase db) { + Cursor cursor = null; + try { + cursor = db.query(APN_TABLE, + APN_FULL_PROJECTION, CURRENT_SELECTION, + null/*selectionArgs*/, + null/*groupBy*/, null/*having*/, null/*orderBy*/); + if (cursor != null) { + final List result = Lists.newArrayList(); + while (cursor.moveToNext()) { + final ContentValues row = cursorToValues(cursor); + if (row != null) { + result.add(row); + } + } + return result; + } + } catch (final SQLiteException e) { + LogUtil.w(TAG, "ApnDatabase.loadUserDataFromOldTable: no old user data: " + e, e); + } finally { + if (cursor != null) { + cursor.close(); + } + } + return null; + } + + private static final String[] ID_PROJECTION = new String[]{Telephony.Carriers._ID}; + + private static final String ID_SELECTION = Telephony.Carriers._ID + "=?"; + + /** + * Store use changes of old table into the new apn table + * + * @param data The user changes + */ + public static void saveUserDataFromOldTable( + final SQLiteDatabase db, final List data) { + if (data == null || data.size() < 1) { + return; + } + for (final ContentValues row : data) { + // Build query from the row data. It is an exact match, column by column, + // except the CURRENT column + final StringBuilder selectionBuilder = new StringBuilder(); + final ArrayList selectionArgs = Lists.newArrayList(); + for (final String key : row.keySet()) { + if (!Telephony.Carriers.CURRENT.equals(key)) { + if (selectionBuilder.length() > 0) { + selectionBuilder.append(" AND "); + } + final String value = row.getAsString(key); + if (TextUtils.isEmpty(value)) { + selectionBuilder.append(key).append(" IS NULL"); + } else { + selectionBuilder.append(key).append("=?"); + selectionArgs.add(value); + } + } + } + Cursor cursor = null; + try { + cursor = db.query(APN_TABLE, + ID_PROJECTION, + selectionBuilder.toString(), + selectionArgs.toArray(new String[0]), + null/*groupBy*/, null/*having*/, null/*orderBy*/); + if (cursor != null && cursor.moveToFirst()) { + db.update(APN_TABLE, row, ID_SELECTION, new String[]{cursor.getString(0)}); + } else { + // User APN does not exist, insert into the new table + row.put(Telephony.Carriers.NUMERIC, + PhoneUtils.canonicalizeMccMnc( + row.getAsString(Telephony.Carriers.MCC), + row.getAsString(Telephony.Carriers.MNC)) + ); + db.insert(APN_TABLE, null/*nullColumnHack*/, row); + } + } catch (final SQLiteException e) { + LogUtil.e(TAG, "ApnDatabase.saveUserDataFromOldTable: query error " + e, e); + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + } + + // Convert Cursor to ContentValues + private static ContentValues cursorToValues(final Cursor cursor) { + final int columnCount = cursor.getColumnCount(); + if (columnCount > 0) { + final ContentValues result = new ContentValues(); + for (int i = 0; i < columnCount; i++) { + final String name = cursor.getColumnName(i); + final String value = cursor.getString(i); + result.put(name, value); + } + return result; + } + return null; + } + + @Override + public void onOpen(final SQLiteDatabase db) { + super.onOpen(db); + if (DEBUG) { + LogUtil.d(TAG, "ApnDatabase onOpen"); + } + } + + @Override + public void close() { + super.close(); + if (DEBUG) { + LogUtil.d(TAG, "ApnDatabase close"); + } + } + + private void rebuildTables(final SQLiteDatabase db) { + if (DEBUG) { + LogUtil.d(TAG, "ApnDatabase rebuildTables"); + } + db.execSQL("DROP TABLE IF EXISTS " + APN_TABLE + ";"); + db.execSQL(APN_TABLE_SQL); + loadApnTable(db); + } + + @Override + public void onUpgrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) { + if (DEBUG) { + LogUtil.d(TAG, "ApnDatabase onUpgrade"); + } + rebuildTables(db); + } + + @Override + public void onDowngrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) { + if (DEBUG) { + LogUtil.d(TAG, "ApnDatabase onDowngrade"); + } + rebuildTables(db); + } + + /** + * Load APN table from app resources + */ + private static void loadApnTable(final SQLiteDatabase db) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "ApnDatabase loadApnTable"); + } + final Resources r = sContext.getResources(); + final XmlResourceParser parser = r.getXml(R.xml.apns); + final ApnsXmlProcessor processor = ApnsXmlProcessor.get(parser); + processor.setApnHandler(new ApnsXmlProcessor.ApnHandler() { + @Override + public void process(final ContentValues apnValues) { + db.insert(APN_TABLE, null/*nullColumnHack*/, apnValues); + } + }); + try { + processor.process(); + } catch (final Exception e) { + Log.e(TAG, "Got exception while loading APN database.", e); + } finally { + parser.close(); + } + } + + public static void forceBuildAndLoadApnTables() { + final SQLiteDatabase db = getApnDatabase().getWritableDatabase(); + db.execSQL("DROP TABLE IF EXISTS " + APN_TABLE); + // Table(s) always need for JB MR1 for APN support for MMS because JB MR1 throws + // a SecurityException when trying to access the carriers table (which holds the + // APNs). Some JB MR2 devices also throw the security exception, so we're building + // the table for JB MR2, too. + db.execSQL(APN_TABLE_SQL); + + loadApnTable(db); + } + + /** + * Clear all tables + */ + public static void clearTables() { + final SQLiteDatabase db = getApnDatabase().getWritableDatabase(); + db.execSQL("DROP TABLE IF EXISTS " + APN_TABLE); + db.execSQL(APN_TABLE_SQL); + } +} diff --git a/src/com/android/messaging/sms/ApnsXmlProcessor.java b/src/com/android/messaging/sms/ApnsXmlProcessor.java new file mode 100644 index 0000000..976896c --- /dev/null +++ b/src/com/android/messaging/sms/ApnsXmlProcessor.java @@ -0,0 +1,329 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.sms; + +import android.content.ContentValues; +import android.provider.Telephony; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.PhoneUtils; +import com.google.common.collect.Maps; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.util.Map; + +/* + * XML processor for the following files: + * 1. res/xml/apns.xml + * 2. res/xml/mms_config.xml (or related overlay files) + */ +class ApnsXmlProcessor { + public interface ApnHandler { + public void process(ContentValues apnValues); + } + + public interface MmsConfigHandler { + public void process(String mccMnc, String key, String value, String type); + } + + private static final String TAG = LogUtil.BUGLE_TAG; + + private static final Map APN_ATTRIBUTE_MAP = Maps.newHashMap(); + static { + APN_ATTRIBUTE_MAP.put("mcc", Telephony.Carriers.MCC); + APN_ATTRIBUTE_MAP.put("mnc", Telephony.Carriers.MNC); + APN_ATTRIBUTE_MAP.put("carrier", Telephony.Carriers.NAME); + APN_ATTRIBUTE_MAP.put("apn", Telephony.Carriers.APN); + APN_ATTRIBUTE_MAP.put("mmsc", Telephony.Carriers.MMSC); + APN_ATTRIBUTE_MAP.put("mmsproxy", Telephony.Carriers.MMSPROXY); + APN_ATTRIBUTE_MAP.put("mmsport", Telephony.Carriers.MMSPORT); + APN_ATTRIBUTE_MAP.put("type", Telephony.Carriers.TYPE); + APN_ATTRIBUTE_MAP.put("user", Telephony.Carriers.USER); + APN_ATTRIBUTE_MAP.put("password", Telephony.Carriers.PASSWORD); + APN_ATTRIBUTE_MAP.put("authtype", Telephony.Carriers.AUTH_TYPE); + APN_ATTRIBUTE_MAP.put("mvno_match_data", Telephony.Carriers.MVNO_MATCH_DATA); + APN_ATTRIBUTE_MAP.put("mvno_type", Telephony.Carriers.MVNO_TYPE); + APN_ATTRIBUTE_MAP.put("protocol", Telephony.Carriers.PROTOCOL); + APN_ATTRIBUTE_MAP.put("bearer", Telephony.Carriers.BEARER); + APN_ATTRIBUTE_MAP.put("server", Telephony.Carriers.SERVER); + APN_ATTRIBUTE_MAP.put("roaming_protocol", Telephony.Carriers.ROAMING_PROTOCOL); + APN_ATTRIBUTE_MAP.put("proxy", Telephony.Carriers.PROXY); + APN_ATTRIBUTE_MAP.put("port", Telephony.Carriers.PORT); + APN_ATTRIBUTE_MAP.put("carrier_enabled", Telephony.Carriers.CARRIER_ENABLED); + } + + private static final String TAG_APNS = "apns"; + private static final String TAG_APN = "apn"; + private static final String TAG_MMS_CONFIG = "mms_config"; + + // Handler to process one apn + private ApnHandler mApnHandler; + // Handler to process one mms_config key/value pair + private MmsConfigHandler mMmsConfigHandler; + + private final StringBuilder mLogStringBuilder = new StringBuilder(); + + private final XmlPullParser mInputParser; + + private ApnsXmlProcessor(XmlPullParser parser) { + mInputParser = parser; + mApnHandler = null; + mMmsConfigHandler = null; + } + + public static ApnsXmlProcessor get(XmlPullParser parser) { + Assert.notNull(parser); + return new ApnsXmlProcessor(parser); + } + + public ApnsXmlProcessor setApnHandler(ApnHandler handler) { + mApnHandler = handler; + return this; + } + + public ApnsXmlProcessor setMmsConfigHandler(MmsConfigHandler handler) { + mMmsConfigHandler = handler; + return this; + } + + /** + * Move XML parser forward to next event type or the end of doc + * + * @param eventType + * @return The final event type we meet + * @throws XmlPullParserException + * @throws IOException + */ + private int advanceToNextEvent(int eventType) throws XmlPullParserException, IOException { + for (;;) { + int nextEvent = mInputParser.next(); + if (nextEvent == eventType + || nextEvent == XmlPullParser.END_DOCUMENT) { + return nextEvent; + } + } + } + + public void process() { + try { + // Find the first element + if (advanceToNextEvent(XmlPullParser.START_TAG) != XmlPullParser.START_TAG) { + throw new XmlPullParserException("ApnsXmlProcessor: expecting start tag @" + + xmlParserDebugContext()); + } + // A single ContentValues object for holding the parsing result of + // an apn element + final ContentValues values = new ContentValues(); + String tagName = mInputParser.getName(); + // Top level tag can be "apns" (apns.xml) + // or "mms_config" (mms_config.xml) + if (TAG_APNS.equals(tagName)) { + // For "apns", there could be "apn" or both "apn" and "mms_config" + for (;;) { + if (advanceToNextEvent(XmlPullParser.START_TAG) != XmlPullParser.START_TAG) { + break; + } + tagName = mInputParser.getName(); + if (TAG_APN.equals(tagName)) { + processApn(values); + } else if (TAG_MMS_CONFIG.equals(tagName)) { + processMmsConfig(); + } + } + } else if (TAG_MMS_CONFIG.equals(tagName)) { + // mms_config.xml resource + processMmsConfig(); + } + } catch (IOException e) { + LogUtil.e(TAG, "ApnsXmlProcessor: I/O failure " + e, e); + } catch (XmlPullParserException e) { + LogUtil.e(TAG, "ApnsXmlProcessor: parsing failure " + e, e); + } + } + + private Integer parseInt(String text, Integer defaultValue, String logHint) { + Integer value = defaultValue; + try { + value = Integer.parseInt(text); + } catch (Exception e) { + LogUtil.e(TAG, + "Invalid value " + text + "for" + logHint + " @" + xmlParserDebugContext()); + } + return value; + } + + private Boolean parseBoolean(String text, Boolean defaultValue, String logHint) { + Boolean value = defaultValue; + try { + value = Boolean.parseBoolean(text); + } catch (Exception e) { + LogUtil.e(TAG, + "Invalid value " + text + "for" + logHint + " @" + xmlParserDebugContext()); + } + return value; + } + + private static String xmlParserEventString(int event) { + switch (event) { + case XmlPullParser.START_DOCUMENT: return "START_DOCUMENT"; + case XmlPullParser.END_DOCUMENT: return "END_DOCUMENT"; + case XmlPullParser.START_TAG: return "START_TAG"; + case XmlPullParser.END_TAG: return "END_TAG"; + case XmlPullParser.TEXT: return "TEXT"; + } + return Integer.toString(event); + } + + /** + * @return The debugging information of the parser's current position + */ + private String xmlParserDebugContext() { + mLogStringBuilder.setLength(0); + if (mInputParser != null) { + try { + final int eventType = mInputParser.getEventType(); + mLogStringBuilder.append(xmlParserEventString(eventType)); + if (eventType == XmlPullParser.START_TAG + || eventType == XmlPullParser.END_TAG + || eventType == XmlPullParser.TEXT) { + mLogStringBuilder.append('<').append(mInputParser.getName()); + for (int i = 0; i < mInputParser.getAttributeCount(); i++) { + mLogStringBuilder.append(' ') + .append(mInputParser.getAttributeName(i)) + .append('=') + .append(mInputParser.getAttributeValue(i)); + } + mLogStringBuilder.append("/>"); + } + return mLogStringBuilder.toString(); + } catch (XmlPullParserException e) { + LogUtil.e(TAG, "xmlParserDebugContext: " + e, e); + } + } + return "Unknown"; + } + + /** + * Process one apn + * + * @param apnValues Where we store the parsed apn + * @throws IOException + * @throws XmlPullParserException + */ + private void processApn(ContentValues apnValues) throws IOException, XmlPullParserException { + Assert.notNull(apnValues); + apnValues.clear(); + // Collect all the attributes + for (int i = 0; i < mInputParser.getAttributeCount(); i++) { + final String key = APN_ATTRIBUTE_MAP.get(mInputParser.getAttributeName(i)); + if (key != null) { + apnValues.put(key, mInputParser.getAttributeValue(i)); + } + } + // Set numeric to be canonicalized mcc/mnc like "310120", always 6 digits + final String canonicalMccMnc = PhoneUtils.canonicalizeMccMnc( + apnValues.getAsString(Telephony.Carriers.MCC), + apnValues.getAsString(Telephony.Carriers.MNC)); + apnValues.put(Telephony.Carriers.NUMERIC, canonicalMccMnc); + // Some of the values should not be string type, converting them to desired types + final String authType = apnValues.getAsString(Telephony.Carriers.AUTH_TYPE); + if (authType != null) { + apnValues.put(Telephony.Carriers.AUTH_TYPE, parseInt(authType, -1, "apn authtype")); + } + final String carrierEnabled = apnValues.getAsString(Telephony.Carriers.CARRIER_ENABLED); + if (carrierEnabled != null) { + apnValues.put(Telephony.Carriers.CARRIER_ENABLED, + parseBoolean(carrierEnabled, null, "apn carrierEnabled")); + } + final String bearer = apnValues.getAsString(Telephony.Carriers.BEARER); + if (bearer != null) { + apnValues.put(Telephony.Carriers.BEARER, parseInt(bearer, 0, "apn bearer")); + } + // We are at the end tag + if (mInputParser.next() != XmlPullParser.END_TAG) { + throw new XmlPullParserException("Apn: expecting end tag @" + + xmlParserDebugContext()); + } + // We are done parsing one APN, call the handler + if (mApnHandler != null) { + mApnHandler.process(apnValues); + } + } + + /** + * Process one mms_config. + * + * @throws IOException + * @throws XmlPullParserException + */ + private void processMmsConfig() + throws IOException, XmlPullParserException { + // Get the mcc and mnc attributes + final String canonicalMccMnc = PhoneUtils.canonicalizeMccMnc( + mInputParser.getAttributeValue(null, "mcc"), + mInputParser.getAttributeValue(null, "mnc")); + // We are at the start tag + for (;;) { + int nextEvent; + // Skipping spaces + while ((nextEvent = mInputParser.next()) == XmlPullParser.TEXT) { + } + if (nextEvent == XmlPullParser.START_TAG) { + // Parse one mms config key/value + processMmsConfigKeyValue(canonicalMccMnc); + } else if (nextEvent == XmlPullParser.END_TAG) { + break; + } else { + throw new XmlPullParserException("MmsConfig: expecting start or end tag @" + + xmlParserDebugContext()); + } + } + } + + /** + * Process one mms_config key/value pair + * + * @param mccMnc The mcc and mnc of this mms_config + * @throws IOException + * @throws XmlPullParserException + */ + private void processMmsConfigKeyValue(String mccMnc) + throws IOException, XmlPullParserException { + final String key = mInputParser.getAttributeValue(null, "name"); + // We are at the start tag, the name of the tag is the type + // e.g. value + final String type = mInputParser.getName(); + int nextEvent = mInputParser.next(); + String value = null; + if (nextEvent == XmlPullParser.TEXT) { + value = mInputParser.getText(); + nextEvent = mInputParser.next(); + } + if (nextEvent != XmlPullParser.END_TAG) { + throw new XmlPullParserException("ApnsXmlProcessor: expecting end tag @" + + xmlParserDebugContext()); + } + // We are done parsing one mms_config key/value, call the handler + if (mMmsConfigHandler != null) { + mMmsConfigHandler.process(mccMnc, key, value, type); + } + } +} diff --git a/src/com/android/messaging/sms/BugleApnSettingsLoader.java b/src/com/android/messaging/sms/BugleApnSettingsLoader.java new file mode 100644 index 0000000..43c95ac --- /dev/null +++ b/src/com/android/messaging/sms/BugleApnSettingsLoader.java @@ -0,0 +1,646 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.sms; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.net.Uri; +import android.provider.Telephony; +import android.support.v7.mms.ApnSettingsLoader; +import android.support.v7.mms.MmsManager; +import android.text.TextUtils; +import android.util.SparseArray; + +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.mmslib.SqliteWrapper; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; + +/** + * APN loader for default SMS SIM + * + * This loader tries to load APNs from 3 sources in order: + * 1. Gservices setting + * 2. System APN table + * 3. Local APN table + */ +public class BugleApnSettingsLoader implements ApnSettingsLoader { + /** + * The base implementation of an APN + */ + private static class BaseApn implements Apn { + /** + * Create a base APN from parameters + * + * @param typesIn the APN type field + * @param mmscIn the APN mmsc field + * @param proxyIn the APN mmsproxy field + * @param portIn the APN mmsport field + * @return an instance of base APN, or null if any of the parameter is invalid + */ + public static BaseApn from(final String typesIn, final String mmscIn, final String proxyIn, + final String portIn) { + if (!isValidApnType(trimWithNullCheck(typesIn), APN_TYPE_MMS)) { + return null; + } + String mmsc = trimWithNullCheck(mmscIn); + if (TextUtils.isEmpty(mmsc)) { + return null; + } + mmsc = trimV4AddrZeros(mmsc); + try { + new URI(mmsc); + } catch (final URISyntaxException e) { + return null; + } + String mmsProxy = trimWithNullCheck(proxyIn); + int mmsProxyPort = 80; + if (!TextUtils.isEmpty(mmsProxy)) { + mmsProxy = trimV4AddrZeros(mmsProxy); + final String portString = trimWithNullCheck(portIn); + if (portString != null) { + try { + mmsProxyPort = Integer.parseInt(portString); + } catch (final NumberFormatException e) { + // Ignore, just use 80 to try + } + } + } + return new BaseApn(mmsc, mmsProxy, mmsProxyPort); + } + + private final String mMmsc; + private final String mMmsProxy; + private final int mMmsProxyPort; + + public BaseApn(final String mmsc, final String proxy, final int port) { + mMmsc = mmsc; + mMmsProxy = proxy; + mMmsProxyPort = port; + } + + @Override + public String getMmsc() { + return mMmsc; + } + + @Override + public String getMmsProxy() { + return mMmsProxy; + } + + @Override + public int getMmsProxyPort() { + return mMmsProxyPort; + } + + @Override + public void setSuccess() { + // Do nothing + } + + public boolean equals(final BaseApn other) { + return TextUtils.equals(mMmsc, other.getMmsc()) && + TextUtils.equals(mMmsProxy, other.getMmsProxy()) && + mMmsProxyPort == other.getMmsProxyPort(); + } + } + + /** + * The APN represented by the local APN table row + */ + private static class DatabaseApn implements Apn { + private static final ContentValues CURRENT_NULL_VALUE; + private static final ContentValues CURRENT_SET_VALUE; + static { + CURRENT_NULL_VALUE = new ContentValues(1); + CURRENT_NULL_VALUE.putNull(Telephony.Carriers.CURRENT); + CURRENT_SET_VALUE = new ContentValues(1); + CURRENT_SET_VALUE.put(Telephony.Carriers.CURRENT, "1"); // 1 for auto selected APN + } + private static final String CLEAR_UPDATE_SELECTION = Telephony.Carriers.CURRENT + " =?"; + private static final String[] CLEAR_UPDATE_SELECTION_ARGS = new String[] { "1" }; + private static final String SET_UPDATE_SELECTION = Telephony.Carriers._ID + " =?"; + + /** + * Create an APN loaded from local database + * + * @param apns the in-memory APN list + * @param typesIn the APN type field + * @param mmscIn the APN mmsc field + * @param proxyIn the APN mmsproxy field + * @param portIn the APN mmsport field + * @param rowId the APN's row ID in database + * @param current the value of CURRENT column in database + * @return an in-memory APN instance for database APN row, null if parameter invalid + */ + public static DatabaseApn from(final List apns, final String typesIn, + final String mmscIn, final String proxyIn, final String portIn, + final long rowId, final int current) { + if (apns == null) { + return null; + } + final BaseApn base = BaseApn.from(typesIn, mmscIn, proxyIn, portIn); + if (base == null) { + return null; + } + for (final ApnSettingsLoader.Apn apn : apns) { + if (apn instanceof DatabaseApn && ((DatabaseApn) apn).equals(base)) { + return null; + } + } + return new DatabaseApn(apns, base, rowId, current); + } + + private final List mApns; + private final BaseApn mBase; + private final long mRowId; + private int mCurrent; + + public DatabaseApn(final List apns, final BaseApn base, final long rowId, + final int current) { + mApns = apns; + mBase = base; + mRowId = rowId; + mCurrent = current; + } + + @Override + public String getMmsc() { + return mBase.getMmsc(); + } + + @Override + public String getMmsProxy() { + return mBase.getMmsProxy(); + } + + @Override + public int getMmsProxyPort() { + return mBase.getMmsProxyPort(); + } + + @Override + public void setSuccess() { + moveToListHead(); + setCurrentInDatabase(); + } + + /** + * Try to move this APN to the head of in-memory list + */ + private void moveToListHead() { + // If this is being marked as a successful APN, move it to the top of the list so + // next time it will be tried first + boolean moved = false; + synchronized (mApns) { + if (mApns.get(0) != this) { + mApns.remove(this); + mApns.add(0, this); + moved = true; + } + } + if (moved) { + LogUtil.d(LogUtil.BUGLE_TAG, "Set APN [" + + "MMSC=" + getMmsc() + ", " + + "PROXY=" + getMmsProxy() + ", " + + "PORT=" + getMmsProxyPort() + "] to be first"); + } + } + + /** + * Try to set the APN to be CURRENT in its database table + */ + private void setCurrentInDatabase() { + synchronized (this) { + if (mCurrent > 0) { + // Already current + return; + } + mCurrent = 1; + } + LogUtil.d(LogUtil.BUGLE_TAG, "Set APN @" + mRowId + " to be CURRENT in local db"); + final SQLiteDatabase database = ApnDatabase.getApnDatabase().getWritableDatabase(); + database.beginTransaction(); + try { + // clear the previous current=1 apn + // we don't clear current=2 apn since it is manually selected by user + // and we should not override it. + database.update(ApnDatabase.APN_TABLE, CURRENT_NULL_VALUE, + CLEAR_UPDATE_SELECTION, CLEAR_UPDATE_SELECTION_ARGS); + // set this one to be current (1) + database.update(ApnDatabase.APN_TABLE, CURRENT_SET_VALUE, SET_UPDATE_SELECTION, + new String[] { Long.toString(mRowId) }); + database.setTransactionSuccessful(); + } finally { + database.endTransaction(); + } + } + + public boolean equals(final BaseApn other) { + if (other == null) { + return false; + } + return mBase.equals(other); + } + } + + /** + * APN_TYPE_ALL is a special type to indicate that this APN entry can + * service all data connections. + */ + public static final String APN_TYPE_ALL = "*"; + /** APN type for MMS traffic */ + public static final String APN_TYPE_MMS = "mms"; + + private static final String[] APN_PROJECTION_SYSTEM = { + Telephony.Carriers.TYPE, + Telephony.Carriers.MMSC, + Telephony.Carriers.MMSPROXY, + Telephony.Carriers.MMSPORT, + }; + private static final String[] APN_PROJECTION_LOCAL = { + Telephony.Carriers.TYPE, + Telephony.Carriers.MMSC, + Telephony.Carriers.MMSPROXY, + Telephony.Carriers.MMSPORT, + Telephony.Carriers.CURRENT, + Telephony.Carriers._ID, + }; + private static final int COLUMN_TYPE = 0; + private static final int COLUMN_MMSC = 1; + private static final int COLUMN_MMSPROXY = 2; + private static final int COLUMN_MMSPORT = 3; + private static final int COLUMN_CURRENT = 4; + private static final int COLUMN_ID = 5; + + private static final String SELECTION_APN = Telephony.Carriers.APN + "=?"; + private static final String SELECTION_CURRENT = Telephony.Carriers.CURRENT + " IS NOT NULL"; + private static final String SELECTION_NUMERIC = Telephony.Carriers.NUMERIC + "=?"; + private static final String ORDER_BY = Telephony.Carriers.CURRENT + " DESC"; + + private final Context mContext; + + // Cached APNs for subIds + private final SparseArray> mApnsCache; + + public BugleApnSettingsLoader(final Context context) { + mContext = context; + mApnsCache = new SparseArray<>(); + } + + @Override + public List get(final String apnName) { + final int subId = PhoneUtils.getDefault().getEffectiveSubId( + ParticipantData.DEFAULT_SELF_SUB_ID); + List apns; + boolean didLoad = false; + synchronized (this) { + apns = mApnsCache.get(subId); + if (apns == null) { + apns = new ArrayList<>(); + mApnsCache.put(subId, apns); + loadLocked(subId, apnName, apns); + didLoad = true; + } + } + if (didLoad) { + LogUtil.i(LogUtil.BUGLE_TAG, "Loaded " + apns.size() + " APNs"); + } + return apns; + } + + private void loadLocked(final int subId, final String apnName, final List apns) { + // Try Gservices first + loadFromGservices(apns); + if (apns.size() > 0) { + return; + } + // Try system APN table + loadFromSystem(subId, apnName, apns); + if (apns.size() > 0) { + return; + } + // Try local APN table + loadFromLocalDatabase(apnName, apns); + if (apns.size() <= 0) { + LogUtil.w(LogUtil.BUGLE_TAG, "Failed to load any APN"); + } + } + + /** + * Load from Gservices if APN setting is set in Gservices + * + * @param apns the list used to return results + */ + private void loadFromGservices(final List apns) { + final BugleGservices gservices = BugleGservices.get(); + final String mmsc = gservices.getString(BugleGservicesKeys.MMS_MMSC, null); + if (TextUtils.isEmpty(mmsc)) { + return; + } + LogUtil.i(LogUtil.BUGLE_TAG, "Loading APNs from gservices"); + final String proxy = gservices.getString(BugleGservicesKeys.MMS_PROXY_ADDRESS, null); + final int port = gservices.getInt(BugleGservicesKeys.MMS_PROXY_PORT, -1); + final Apn apn = BaseApn.from("mms", mmsc, proxy, Integer.toString(port)); + if (apn != null) { + apns.add(apn); + } + } + + /** + * Load matching APNs from telephony provider. + * We try different combinations of the query to work around some platform quirks. + * + * @param subId the SIM subId + * @param apnName the APN name to match + * @param apns the list used to return results + */ + private void loadFromSystem(final int subId, final String apnName, final List apns) { + Uri uri; + if (OsUtil.isAtLeastL_MR1() && subId != MmsManager.DEFAULT_SUB_ID) { + uri = Uri.withAppendedPath(Telephony.Carriers.CONTENT_URI, "/subId/" + subId); + } else { + uri = Telephony.Carriers.CONTENT_URI; + } + Cursor cursor = null; + try { + for (; ; ) { + // Try different combinations of queries. Some would work on some platforms. + // So we query each combination until we find one returns non-empty result. + cursor = querySystem(uri, true/*checkCurrent*/, apnName); + if (cursor != null) { + break; + } + cursor = querySystem(uri, false/*checkCurrent*/, apnName); + if (cursor != null) { + break; + } + cursor = querySystem(uri, true/*checkCurrent*/, null/*apnName*/); + if (cursor != null) { + break; + } + cursor = querySystem(uri, false/*checkCurrent*/, null/*apnName*/); + break; + } + } catch (final SecurityException e) { + // Can't access platform APN table, return directly + return; + } + if (cursor == null) { + return; + } + try { + if (cursor.moveToFirst()) { + final ApnSettingsLoader.Apn apn = BaseApn.from( + cursor.getString(COLUMN_TYPE), + cursor.getString(COLUMN_MMSC), + cursor.getString(COLUMN_MMSPROXY), + cursor.getString(COLUMN_MMSPORT)); + if (apn != null) { + apns.add(apn); + } + } + } finally { + cursor.close(); + } + } + + /** + * Query system APN table + * + * @param uri The APN query URL to use + * @param checkCurrent If add "CURRENT IS NOT NULL" condition + * @param apnName The optional APN name for query condition + * @return A cursor of the query result. If a cursor is returned as not null, it is + * guaranteed to contain at least one row. + */ + private Cursor querySystem(final Uri uri, final boolean checkCurrent, String apnName) { + LogUtil.i(LogUtil.BUGLE_TAG, "Loading APNs from system, " + + "checkCurrent=" + checkCurrent + " apnName=" + apnName); + final StringBuilder selectionBuilder = new StringBuilder(); + String[] selectionArgs = null; + if (checkCurrent) { + selectionBuilder.append(SELECTION_CURRENT); + } + apnName = trimWithNullCheck(apnName); + if (!TextUtils.isEmpty(apnName)) { + if (selectionBuilder.length() > 0) { + selectionBuilder.append(" AND "); + } + selectionBuilder.append(SELECTION_APN); + selectionArgs = new String[] { apnName }; + } + try { + final Cursor cursor = SqliteWrapper.query( + mContext, + mContext.getContentResolver(), + uri, + APN_PROJECTION_SYSTEM, + selectionBuilder.toString(), + selectionArgs, + null/*sortOrder*/); + if (cursor == null || cursor.getCount() < 1) { + if (cursor != null) { + cursor.close(); + } + LogUtil.w(LogUtil.BUGLE_TAG, "Query " + uri + " with apn " + apnName + " and " + + (checkCurrent ? "checking CURRENT" : "not checking CURRENT") + + " returned empty"); + return null; + } + return cursor; + } catch (final SQLiteException e) { + LogUtil.w(LogUtil.BUGLE_TAG, "APN table query exception: " + e); + } catch (final SecurityException e) { + LogUtil.w(LogUtil.BUGLE_TAG, "Platform restricts APN table access: " + e); + throw e; + } + return null; + } + + /** + * Load matching APNs from local APN table. + * We try both using the APN name and not using the APN name. + * + * @param apnName the APN name + * @param apns the list of results to return + */ + private void loadFromLocalDatabase(final String apnName, final List apns) { + LogUtil.i(LogUtil.BUGLE_TAG, "Loading APNs from local APN table"); + final SQLiteDatabase database = ApnDatabase.getApnDatabase().getWritableDatabase(); + final String mccMnc = PhoneUtils.getMccMncString(PhoneUtils.getDefault().getMccMnc()); + Cursor cursor = null; + cursor = queryLocalDatabase(database, mccMnc, apnName); + if (cursor == null) { + cursor = queryLocalDatabase(database, mccMnc, null/*apnName*/); + } + if (cursor == null) { + LogUtil.w(LogUtil.BUGLE_TAG, "Could not find any APN in local table"); + return; + } + try { + while (cursor.moveToNext()) { + final Apn apn = DatabaseApn.from(apns, + cursor.getString(COLUMN_TYPE), + cursor.getString(COLUMN_MMSC), + cursor.getString(COLUMN_MMSPROXY), + cursor.getString(COLUMN_MMSPORT), + cursor.getLong(COLUMN_ID), + cursor.getInt(COLUMN_CURRENT)); + if (apn != null) { + apns.add(apn); + } + } + } finally { + cursor.close(); + } + } + + /** + * Make a query of local APN table based on MCC/MNC and APN name, sorted by CURRENT + * column in descending order + * + * @param db the local database + * @param numeric the MCC/MNC string + * @param apnName the optional APN name to match + * @return the cursor of the query, null if no result + */ + private static Cursor queryLocalDatabase(final SQLiteDatabase db, final String numeric, + final String apnName) { + final String selection; + final String[] selectionArgs; + if (TextUtils.isEmpty(apnName)) { + selection = SELECTION_NUMERIC; + selectionArgs = new String[] { numeric }; + } else { + selection = SELECTION_NUMERIC + " AND " + SELECTION_APN; + selectionArgs = new String[] { numeric, apnName }; + } + Cursor cursor = null; + try { + cursor = db.query(ApnDatabase.APN_TABLE, APN_PROJECTION_LOCAL, selection, selectionArgs, + null/*groupBy*/, null/*having*/, ORDER_BY, null/*limit*/); + } catch (final SQLiteException e) { + LogUtil.w(LogUtil.BUGLE_TAG, "Local APN table does not exist. Try rebuilding.", e); + ApnDatabase.forceBuildAndLoadApnTables(); + cursor = db.query(ApnDatabase.APN_TABLE, APN_PROJECTION_LOCAL, selection, selectionArgs, + null/*groupBy*/, null/*having*/, ORDER_BY, null/*limit*/); + } + if (cursor == null || cursor.getCount() < 1) { + if (cursor != null) { + cursor.close(); + } + LogUtil.w(LogUtil.BUGLE_TAG, "Query local APNs with apn " + apnName + + " returned empty"); + return null; + } + return cursor; + } + + private static String trimWithNullCheck(final String value) { + return value != null ? value.trim() : null; + } + + /** + * Trim leading zeros from IPv4 address strings + * Our base libraries will interpret that as octel.. + * Must leave non v4 addresses and host names alone. + * For example, 192.168.000.010 -> 192.168.0.10 + * + * @param addr a string representing an ip addr + * @return a string propertly trimmed + */ + private static String trimV4AddrZeros(final String addr) { + if (addr == null) { + return null; + } + final String[] octets = addr.split("\\."); + if (octets.length != 4) { + return addr; + } + final StringBuilder builder = new StringBuilder(16); + String result = null; + for (int i = 0; i < 4; i++) { + try { + if (octets[i].length() > 3) { + return addr; + } + builder.append(Integer.parseInt(octets[i])); + } catch (final NumberFormatException e) { + return addr; + } + if (i < 3) { + builder.append('.'); + } + } + result = builder.toString(); + return result; + } + + /** + * Check if the APN contains the APN type we want + * + * @param types The string encodes a list of supported types + * @param requestType The type we want + * @return true if the input types string contains the requestType + */ + public static boolean isValidApnType(final String types, final String requestType) { + // If APN type is unspecified, assume APN_TYPE_ALL. + if (TextUtils.isEmpty(types)) { + return true; + } + for (final String t : types.split(",")) { + if (t.equals(requestType) || t.equals(APN_TYPE_ALL)) { + return true; + } + } + return false; + } + + /** + * Get the ID of first APN to try + */ + public static String getFirstTryApn(final SQLiteDatabase database, final String mccMnc) { + String key = null; + Cursor cursor = null; + try { + cursor = queryLocalDatabase(database, mccMnc, null/*apnName*/); + if (cursor.moveToFirst()) { + key = cursor.getString(ApnDatabase.COLUMN_ID); + } + } catch (final Exception e) { + // Nothing to do + } finally { + if (cursor != null) { + cursor.close(); + } + } + return key; + } +} diff --git a/src/com/android/messaging/sms/BugleCarrierConfigValuesLoader.java b/src/com/android/messaging/sms/BugleCarrierConfigValuesLoader.java new file mode 100644 index 0000000..ac6c7e4 --- /dev/null +++ b/src/com/android/messaging/sms/BugleCarrierConfigValuesLoader.java @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.sms; + +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.content.res.XmlResourceParser; +import android.os.Bundle; +import android.support.v7.mms.CarrierConfigValuesLoader; +import android.util.SparseArray; + +import com.android.messaging.R; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +/** + * Carrier configuration loader + * + * Loader tries to load from resources. If there is MMS API available, also + * load from system. + */ +public class BugleCarrierConfigValuesLoader implements CarrierConfigValuesLoader { + /* + * Key types + */ + public static final String KEY_TYPE_INT = "int"; + public static final String KEY_TYPE_BOOL = "bool"; + public static final String KEY_TYPE_STRING = "string"; + + private final Context mContext; + + // Cached values for subIds + private final SparseArray mValuesCache; + + public BugleCarrierConfigValuesLoader(final Context context) { + mContext = context; + mValuesCache = new SparseArray<>(); + } + + @Override + public Bundle get(int subId) { + subId = PhoneUtils.getDefault().getEffectiveSubId(subId); + Bundle values; + String loadSource = null; + synchronized (this) { + values = mValuesCache.get(subId); + if (values == null) { + values = new Bundle(); + mValuesCache.put(subId, values); + loadSource = loadLocked(subId, values); + } + } + if (loadSource != null) { + LogUtil.i(LogUtil.BUGLE_TAG, "Carrier configs loaded: " + values + + " from " + loadSource + " for subId=" + subId); + } + return values; + } + + /** + * Clear the cache for reloading + */ + public void reset() { + synchronized (this) { + mValuesCache.clear(); + } + } + + /** + * Loading carrier config values + * + * @param subId which SIM to load for + * @param values the result to add to + * @return the source of the config, could be "resources" or "resources+system" + */ + private String loadLocked(final int subId, final Bundle values) { + // Load from resources in earlier platform + loadFromResources(subId, values); + if (OsUtil.isAtLeastL()) { + // Load from system to override if system API exists + loadFromSystem(subId, values); + return "resources+system"; + } + return "resources"; + } + + /** + * Load from system, using MMS API + * + * @param subId which SIM to load for + * @param values the result to add to + */ + private static void loadFromSystem(final int subId, final Bundle values) { + try { + final Bundle systemValues = + PhoneUtils.get(subId).getSmsManager().getCarrierConfigValues(); + if (systemValues != null) { + values.putAll(systemValues); + } + } catch (final Exception e) { + LogUtil.w(LogUtil.BUGLE_TAG, "Calling system getCarrierConfigValues exception", e); + } + } + + /** + * Load from SIM-dependent resources + * + * @param subId which SIM to load for + * @param values the result to add to + */ + private void loadFromResources(final int subId, final Bundle values) { + // Get a subscription-dependent context for loading the mms_config.xml + final Context subContext = getSubDepContext(mContext, subId); + // Load and parse the XML + XmlResourceParser parser = null; + try { + parser = subContext.getResources().getXml(R.xml.mms_config); + final ApnsXmlProcessor processor = ApnsXmlProcessor.get(parser); + processor.setMmsConfigHandler(new ApnsXmlProcessor.MmsConfigHandler() { + @Override + public void process(final String mccMnc, final String key, final String value, + final String type) { + update(values, type, key, value); + } + }); + processor.process(); + } catch (final Resources.NotFoundException e) { + LogUtil.w(LogUtil.BUGLE_TAG, "Can not find mms_config.xml"); + } finally { + if (parser != null) { + parser.close(); + } + } + } + + /** + * Get a subscription's Context so we can load resources from it + * + * @param context the sub-independent Context + * @param subId the SIM's subId + * @return the sub-dependent Context + */ + private static Context getSubDepContext(final Context context, final int subId) { + if (!OsUtil.isAtLeastL_MR1()) { + return context; + } + final int[] mccMnc = PhoneUtils.get(subId).getMccMnc(); + final int mcc = mccMnc[0]; + final int mnc = mccMnc[1]; + final Configuration subConfig = new Configuration(); + if (mcc == 0 && mnc == 0) { + Configuration config = context.getResources().getConfiguration(); + subConfig.mcc = config.mcc; + subConfig.mnc = config.mnc; + } else { + subConfig.mcc = mcc; + subConfig.mnc = mnc; + } + return context.createConfigurationContext(subConfig); + } + + /** + * Add or update a carrier config key/value pair to the Bundle + * + * @param values the result Bundle to add to + * @param type the value type + * @param key the key + * @param value the value + */ + public static void update(final Bundle values, final String type, final String key, + final String value) { + try { + if (KEY_TYPE_INT.equals(type)) { + values.putInt(key, Integer.parseInt(value)); + } else if (KEY_TYPE_BOOL.equals(type)) { + values.putBoolean(key, Boolean.parseBoolean(value)); + } else if (KEY_TYPE_STRING.equals(type)){ + values.putString(key, value); + } + } catch (final NumberFormatException e) { + LogUtil.w(LogUtil.BUGLE_TAG, "Add carrier values: " + + "invalid " + key + "," + value + "," + type); + } + } +} diff --git a/src/com/android/messaging/sms/BugleUserAgentInfoLoader.java b/src/com/android/messaging/sms/BugleUserAgentInfoLoader.java new file mode 100644 index 0000000..a8dc5c4 --- /dev/null +++ b/src/com/android/messaging/sms/BugleUserAgentInfoLoader.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.sms; + +import android.content.Context; +import android.support.v7.mms.UserAgentInfoLoader; +import android.telephony.TelephonyManager; +import android.text.TextUtils; + +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.VersionUtil; + +/** + * User agent and UA profile URL loader + */ +public class BugleUserAgentInfoLoader implements UserAgentInfoLoader { + private static final String DEFAULT_USER_AGENT_PREFIX = "Bugle/"; + + private Context mContext; + private boolean mLoaded; + + private String mUserAgent; + private String mUAProfUrl; + + public BugleUserAgentInfoLoader(final Context context) { + mContext = context; + } + + @Override + public String getUserAgent() { + load(); + return mUserAgent; + } + + @Override + public String getUAProfUrl() { + load(); + return mUAProfUrl; + } + + private void load() { + if (mLoaded) { + return; + } + boolean didLoad = false; + synchronized (this) { + if (!mLoaded) { + loadLocked(); + mLoaded = true; + didLoad = true; + } + } + if (didLoad) { + LogUtil.i(LogUtil.BUGLE_TAG, "Loaded user agent info: " + + "UA=" + mUserAgent + ", UAProfUrl=" + mUAProfUrl); + } + } + + private void loadLocked() { + if (OsUtil.isAtLeastKLP()) { + // load the MMS User agent and UaProfUrl from TelephonyManager APIs + final TelephonyManager telephonyManager = (TelephonyManager) mContext.getSystemService( + Context.TELEPHONY_SERVICE); + mUserAgent = telephonyManager.getMmsUserAgent(); + mUAProfUrl = telephonyManager.getMmsUAProfUrl(); + } + // if user agent string isn't set, use the format "Bugle/". + if (TextUtils.isEmpty(mUserAgent)) { + final String simpleVersionName = VersionUtil.getInstance(mContext).getSimpleName(); + mUserAgent = DEFAULT_USER_AGENT_PREFIX + simpleVersionName; + } + // if the UAProfUrl isn't set, get it from Gservices + if (TextUtils.isEmpty(mUAProfUrl)) { + mUAProfUrl = BugleGservices.get().getString( + BugleGservicesKeys.MMS_UA_PROFILE_URL, + BugleGservicesKeys.MMS_UA_PROFILE_URL_DEFAULT); + } + } +} diff --git a/src/com/android/messaging/sms/DatabaseMessages.java b/src/com/android/messaging/sms/DatabaseMessages.java new file mode 100644 index 0000000..0f662e5 --- /dev/null +++ b/src/com/android/messaging/sms/DatabaseMessages.java @@ -0,0 +1,1006 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.sms; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.content.res.AssetFileDescriptor; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.MediaMetadataRetriever; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.Telephony.Mms; +import android.provider.Telephony.Sms; +import android.text.TextUtils; +import android.util.Log; +import android.webkit.MimeTypeMap; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.media.VideoThumbnailRequest; +import com.android.messaging.mmslib.pdu.CharacterSets; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.MediaMetadataRetrieverWrapper; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.google.common.collect.Lists; + +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.List; + +/** + * Class contains various SMS/MMS database entities from telephony provider + */ +public class DatabaseMessages { + private static final String TAG = LogUtil.BUGLE_TAG; + + public abstract static class DatabaseMessage { + public abstract int getProtocol(); + public abstract String getUri(); + public abstract long getTimestampInMillis(); + + @Override + public boolean equals(final Object other) { + if (other == null || !(other instanceof DatabaseMessage)) { + return false; + } + final DatabaseMessage otherDbMsg = (DatabaseMessage) other; + // No need to check timestamp since we only need this when we compare + // messages at the same timestamp + return TextUtils.equals(getUri(), otherDbMsg.getUri()); + } + + @Override + public int hashCode() { + // No need to check timestamp since we only need this when we compare + // messages at the same timestamp + return getUri().hashCode(); + } + } + + /** + * SMS message + */ + public static class SmsMessage extends DatabaseMessage implements Parcelable { + private static int sIota = 0; + public static final int INDEX_ID = sIota++; + public static final int INDEX_TYPE = sIota++; + public static final int INDEX_ADDRESS = sIota++; + public static final int INDEX_BODY = sIota++; + public static final int INDEX_DATE = sIota++; + public static final int INDEX_THREAD_ID = sIota++; + public static final int INDEX_STATUS = sIota++; + public static final int INDEX_READ = sIota++; + public static final int INDEX_SEEN = sIota++; + public static final int INDEX_DATE_SENT = sIota++; + public static final int INDEX_SUB_ID = sIota++; + + private static String[] sProjection; + + public static String[] getProjection() { + if (sProjection == null) { + String[] projection = new String[] { + Sms._ID, + Sms.TYPE, + Sms.ADDRESS, + Sms.BODY, + Sms.DATE, + Sms.THREAD_ID, + Sms.STATUS, + Sms.READ, + Sms.SEEN, + Sms.DATE_SENT, + Sms.SUBSCRIPTION_ID, + }; + if (!MmsUtils.hasSmsDateSentColumn()) { + projection[INDEX_DATE_SENT] = Sms.DATE; + } + if (!OsUtil.isAtLeastL_MR1()) { + Assert.equals(INDEX_SUB_ID, projection.length - 1); + String[] withoutSubId = new String[projection.length - 1]; + System.arraycopy(projection, 0, withoutSubId, 0, withoutSubId.length); + projection = withoutSubId; + } + + sProjection = projection; + } + + return sProjection; + } + + public String mUri; + public String mAddress; + public String mBody; + private long mRowId; + public long mTimestampInMillis; + public long mTimestampSentInMillis; + public int mType; + public long mThreadId; + public int mStatus; + public boolean mRead; + public boolean mSeen; + public int mSubId; + + private SmsMessage() { + } + + /** + * Load from a cursor of a query that returns the SMS to import + * + * @param cursor + */ + private void load(final Cursor cursor) { + mRowId = cursor.getLong(INDEX_ID); + mAddress = cursor.getString(INDEX_ADDRESS); + mBody = cursor.getString(INDEX_BODY); + mTimestampInMillis = cursor.getLong(INDEX_DATE); + // Before ICS, there is no "date_sent" so use copy of "date" value + mTimestampSentInMillis = cursor.getLong(INDEX_DATE_SENT); + mType = cursor.getInt(INDEX_TYPE); + mThreadId = cursor.getLong(INDEX_THREAD_ID); + mStatus = cursor.getInt(INDEX_STATUS); + mRead = cursor.getInt(INDEX_READ) == 0 ? false : true; + mSeen = cursor.getInt(INDEX_SEEN) == 0 ? false : true; + mUri = ContentUris.withAppendedId(Sms.CONTENT_URI, mRowId).toString(); + mSubId = PhoneUtils.getDefault().getSubIdFromTelephony(cursor, INDEX_SUB_ID); + } + + /** + * Get a new SmsMessage by loading from the cursor of a query + * that returns the SMS to import + * + * @param cursor + * @return + */ + public static SmsMessage get(final Cursor cursor) { + final SmsMessage msg = new SmsMessage(); + msg.load(cursor); + return msg; + } + + @Override + public String getUri() { + return mUri; + } + + public int getSubId() { + return mSubId; + } + + @Override + public int getProtocol() { + return MessageData.PROTOCOL_SMS; + } + + @Override + public long getTimestampInMillis() { + return mTimestampInMillis; + } + + @Override + public int describeContents() { + return 0; + } + + private SmsMessage(final Parcel in) { + mUri = in.readString(); + mRowId = in.readLong(); + mTimestampInMillis = in.readLong(); + mTimestampSentInMillis = in.readLong(); + mType = in.readInt(); + mThreadId = in.readLong(); + mStatus = in.readInt(); + mRead = in.readInt() != 0; + mSeen = in.readInt() != 0; + mSubId = in.readInt(); + + // SMS specific + mAddress = in.readString(); + mBody = in.readString(); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public SmsMessage createFromParcel(final Parcel in) { + return new SmsMessage(in); + } + + @Override + public SmsMessage[] newArray(final int size) { + return new SmsMessage[size]; + } + }; + + @Override + public void writeToParcel(final Parcel out, final int flags) { + out.writeString(mUri); + out.writeLong(mRowId); + out.writeLong(mTimestampInMillis); + out.writeLong(mTimestampSentInMillis); + out.writeInt(mType); + out.writeLong(mThreadId); + out.writeInt(mStatus); + out.writeInt(mRead ? 1 : 0); + out.writeInt(mSeen ? 1 : 0); + out.writeInt(mSubId); + + // SMS specific + out.writeString(mAddress); + out.writeString(mBody); + } + } + + /** + * MMS message + */ + public static class MmsMessage extends DatabaseMessage implements Parcelable { + private static int sIota = 0; + public static final int INDEX_ID = sIota++; + public static final int INDEX_MESSAGE_BOX = sIota++; + public static final int INDEX_SUBJECT = sIota++; + public static final int INDEX_SUBJECT_CHARSET = sIota++; + public static final int INDEX_MESSAGE_SIZE = sIota++; + public static final int INDEX_DATE = sIota++; + public static final int INDEX_DATE_SENT = sIota++; + public static final int INDEX_THREAD_ID = sIota++; + public static final int INDEX_PRIORITY = sIota++; + public static final int INDEX_STATUS = sIota++; + public static final int INDEX_READ = sIota++; + public static final int INDEX_SEEN = sIota++; + public static final int INDEX_CONTENT_LOCATION = sIota++; + public static final int INDEX_TRANSACTION_ID = sIota++; + public static final int INDEX_MESSAGE_TYPE = sIota++; + public static final int INDEX_EXPIRY = sIota++; + public static final int INDEX_RESPONSE_STATUS = sIota++; + public static final int INDEX_RETRIEVE_STATUS = sIota++; + public static final int INDEX_SUB_ID = sIota++; + + private static String[] sProjection; + + public static String[] getProjection() { + if (sProjection == null) { + String[] projection = new String[] { + Mms._ID, + Mms.MESSAGE_BOX, + Mms.SUBJECT, + Mms.SUBJECT_CHARSET, + Mms.MESSAGE_SIZE, + Mms.DATE, + Mms.DATE_SENT, + Mms.THREAD_ID, + Mms.PRIORITY, + Mms.STATUS, + Mms.READ, + Mms.SEEN, + Mms.CONTENT_LOCATION, + Mms.TRANSACTION_ID, + Mms.MESSAGE_TYPE, + Mms.EXPIRY, + Mms.RESPONSE_STATUS, + Mms.RETRIEVE_STATUS, + Mms.SUBSCRIPTION_ID, + }; + + if (!OsUtil.isAtLeastL_MR1()) { + Assert.equals(INDEX_SUB_ID, projection.length - 1); + String[] withoutSubId = new String[projection.length - 1]; + System.arraycopy(projection, 0, withoutSubId, 0, withoutSubId.length); + projection = withoutSubId; + } + + sProjection = projection; + } + + return sProjection; + } + + public String mUri; + private long mRowId; + public int mType; + public String mSubject; + public int mSubjectCharset; + private long mSize; + public long mTimestampInMillis; + public long mSentTimestampInMillis; + public long mThreadId; + public int mPriority; + public int mStatus; + public boolean mRead; + public boolean mSeen; + public String mContentLocation; + public String mTransactionId; + public int mMmsMessageType; + public long mExpiryInMillis; + public int mSubId; + public String mSender; + public int mResponseStatus; + public int mRetrieveStatus; + + public List mParts = Lists.newArrayList(); + private boolean mPartsProcessed = false; + + private MmsMessage() { + } + + /** + * Load from a cursor of a query that returns the MMS to import + * + * @param cursor + */ + public void load(final Cursor cursor) { + mRowId = cursor.getLong(INDEX_ID); + mType = cursor.getInt(INDEX_MESSAGE_BOX); + mSubject = cursor.getString(INDEX_SUBJECT); + mSubjectCharset = cursor.getInt(INDEX_SUBJECT_CHARSET); + if (!TextUtils.isEmpty(mSubject)) { + // PduPersister stores the subject using ISO_8859_1 + // Let's load it using that encoding and convert it back to its original + // See PduPersister.persist and PduPersister.toIsoString + // (Refer to bug b/11162476) + mSubject = getDecodedString( + getStringBytes(mSubject, CharacterSets.ISO_8859_1), mSubjectCharset); + } + mSize = cursor.getLong(INDEX_MESSAGE_SIZE); + // MMS db times are in seconds + mTimestampInMillis = cursor.getLong(INDEX_DATE) * 1000; + mSentTimestampInMillis = cursor.getLong(INDEX_DATE_SENT) * 1000; + mThreadId = cursor.getLong(INDEX_THREAD_ID); + mPriority = cursor.getInt(INDEX_PRIORITY); + mStatus = cursor.getInt(INDEX_STATUS); + mRead = cursor.getInt(INDEX_READ) == 0 ? false : true; + mSeen = cursor.getInt(INDEX_SEEN) == 0 ? false : true; + mContentLocation = cursor.getString(INDEX_CONTENT_LOCATION); + mTransactionId = cursor.getString(INDEX_TRANSACTION_ID); + mMmsMessageType = cursor.getInt(INDEX_MESSAGE_TYPE); + mExpiryInMillis = cursor.getLong(INDEX_EXPIRY) * 1000; + mResponseStatus = cursor.getInt(INDEX_RESPONSE_STATUS); + mRetrieveStatus = cursor.getInt(INDEX_RETRIEVE_STATUS); + // Clear all parts in case we reuse this object + mParts.clear(); + mPartsProcessed = false; + mUri = ContentUris.withAppendedId(Mms.CONTENT_URI, mRowId).toString(); + mSubId = PhoneUtils.getDefault().getSubIdFromTelephony(cursor, INDEX_SUB_ID); + } + + /** + * Get a new MmsMessage by loading from the cursor of a query + * that returns the MMS to import + * + * @param cursor + * @return + */ + public static MmsMessage get(final Cursor cursor) { + final MmsMessage msg = new MmsMessage(); + msg.load(cursor); + return msg; + } + /** + * Add a loaded MMS part + * + * @param part + */ + public void addPart(final MmsPart part) { + mParts.add(part); + } + + public List getParts() { + return mParts; + } + + public long getSize() { + if (!mPartsProcessed) { + processParts(); + } + return mSize; + } + + /** + * Process loaded MMS parts to obtain the combined text, the combined attachment url, + * the combined content type and the combined size. + */ + private void processParts() { + if (mPartsProcessed) { + return; + } + mPartsProcessed = true; + // Remember the width and height of the first media part + // These are needed when building attachment list + long sizeOfParts = 0L; + for (final MmsPart part : mParts) { + sizeOfParts += part.mSize; + } + if (mSize <= 0) { + mSize = mSubject != null ? mSubject.getBytes().length : 0L; + mSize += sizeOfParts; + } + } + + @Override + public String getUri() { + return mUri; + } + + public long getId() { + return mRowId; + } + + public int getSubId() { + return mSubId; + } + + @Override + public int getProtocol() { + return MessageData.PROTOCOL_MMS; + } + + @Override + public long getTimestampInMillis() { + return mTimestampInMillis; + } + + @Override + public int describeContents() { + return 0; + } + + public void setSender(final String sender) { + mSender = sender; + } + + private MmsMessage(final Parcel in) { + mUri = in.readString(); + mRowId = in.readLong(); + mTimestampInMillis = in.readLong(); + mSentTimestampInMillis = in.readLong(); + mType = in.readInt(); + mThreadId = in.readLong(); + mStatus = in.readInt(); + mRead = in.readInt() != 0; + mSeen = in.readInt() != 0; + mSubId = in.readInt(); + + // MMS specific + mSubject = in.readString(); + mContentLocation = in.readString(); + mTransactionId = in.readString(); + mSender = in.readString(); + + mSize = in.readLong(); + mExpiryInMillis = in.readLong(); + + mSubjectCharset = in.readInt(); + mPriority = in.readInt(); + mMmsMessageType = in.readInt(); + mResponseStatus = in.readInt(); + mRetrieveStatus = in.readInt(); + + final int nParts = in.readInt(); + mParts = new ArrayList(); + mPartsProcessed = false; + for (int i = 0; i < nParts; i++) { + mParts.add((MmsPart) in.readParcelable(getClass().getClassLoader())); + } + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public MmsMessage createFromParcel(final Parcel in) { + return new MmsMessage(in); + } + + @Override + public MmsMessage[] newArray(final int size) { + return new MmsMessage[size]; + } + }; + + @Override + public void writeToParcel(final Parcel out, final int flags) { + out.writeString(mUri); + out.writeLong(mRowId); + out.writeLong(mTimestampInMillis); + out.writeLong(mSentTimestampInMillis); + out.writeInt(mType); + out.writeLong(mThreadId); + out.writeInt(mStatus); + out.writeInt(mRead ? 1 : 0); + out.writeInt(mSeen ? 1 : 0); + out.writeInt(mSubId); + + out.writeString(mSubject); + out.writeString(mContentLocation); + out.writeString(mTransactionId); + out.writeString(mSender); + + out.writeLong(mSize); + out.writeLong(mExpiryInMillis); + + out.writeInt(mSubjectCharset); + out.writeInt(mPriority); + out.writeInt(mMmsMessageType); + out.writeInt(mResponseStatus); + out.writeInt(mRetrieveStatus); + + out.writeInt(mParts.size()); + for (final MmsPart part : mParts) { + out.writeParcelable(part, 0); + } + } + } + + /** + * Part of an MMS message + */ + public static class MmsPart implements Parcelable { + public static final String[] PROJECTION = new String[] { + Mms.Part._ID, + Mms.Part.MSG_ID, + Mms.Part.CHARSET, + Mms.Part.CONTENT_TYPE, + Mms.Part.TEXT, + }; + private static int sIota = 0; + public static final int INDEX_ID = sIota++; + public static final int INDEX_MSG_ID = sIota++; + public static final int INDEX_CHARSET = sIota++; + public static final int INDEX_CONTENT_TYPE = sIota++; + public static final int INDEX_TEXT = sIota++; + + public String mUri; + public long mRowId; + public long mMessageId; + public String mContentType; + public String mText; + public int mCharset; + private int mWidth; + private int mHeight; + public long mSize; + + private MmsPart() { + } + + /** + * Load from a cursor of a query that returns the MMS part to import + * + * @param cursor + */ + public void load(final Cursor cursor, final boolean loadMedia) { + mRowId = cursor.getLong(INDEX_ID); + mMessageId = cursor.getLong(INDEX_MSG_ID); + mContentType = cursor.getString(INDEX_CONTENT_TYPE); + mText = cursor.getString(INDEX_TEXT); + mCharset = cursor.getInt(INDEX_CHARSET); + mWidth = 0; + mHeight = 0; + mSize = 0; + if (isMedia()) { + // For importing we don't load media since performance is critical + // For loading when we receive mms, we do load media to get enough + // information of the media file + if (loadMedia) { + if (ContentType.isImageType(mContentType)) { + loadImage(); + } else if (ContentType.isVideoType(mContentType)) { + loadVideo(); + } // No need to load audio for parsing + mSize = MmsUtils.getMediaFileSize(getDataUri()); + } + } else { + // Load text if not media type + loadText(); + } + mUri = Uri.withAppendedPath(Mms.CONTENT_URI, cursor.getString(INDEX_ID)).toString(); + } + + /** + * Get content type from file extension + */ + private static String extractContentType(final Context context, final Uri uri) { + final String path = uri.getPath(); + final MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton(); + String extension = MimeTypeMap.getFileExtensionFromUrl(path); + if (TextUtils.isEmpty(extension)) { + // getMimeTypeFromExtension() doesn't handle spaces in filenames nor can it handle + // urlEncoded strings. Let's try one last time at finding the extension. + final int dotPos = path.lastIndexOf('.'); + if (0 <= dotPos) { + extension = path.substring(dotPos + 1); + } + } + return mimeTypeMap.getMimeTypeFromExtension(extension); + } + + /** + * Get text of a text part + */ + private void loadText() { + byte[] data = null; + if (isEmbeddedTextType()) { + // Embedded text, get from the "text" column + if (!TextUtils.isEmpty(mText)) { + data = getStringBytes(mText, mCharset); + } + } else { + // Not embedded, load from disk + final ContentResolver resolver = + Factory.get().getApplicationContext().getContentResolver(); + final Uri uri = getDataUri(); + InputStream is = null; + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + is = resolver.openInputStream(uri); + final byte[] buffer = new byte[256]; + int len = is.read(buffer); + while (len >= 0) { + baos.write(buffer, 0, len); + len = is.read(buffer); + } + } catch (final IOException e) { + LogUtil.e(TAG, + "DatabaseMessages.MmsPart: loading text from file failed: " + e, e); + } finally { + if (is != null) { + try { + is.close(); + } catch (final IOException e) { + LogUtil.e(TAG, "DatabaseMessages.MmsPart: close file failed: " + e, e); + } + } + } + data = baos.toByteArray(); + } + if (data != null && data.length > 0) { + mSize = data.length; + mText = getDecodedString(data, mCharset); + } + } + + /** + * Load image file of an image part and parse the dimensions and type + */ + private void loadImage() { + final Context context = Factory.get().getApplicationContext(); + final ContentResolver resolver = context.getContentResolver(); + final Uri uri = getDataUri(); + // We have to get the width and height of the image -- they're needed when adding + // an attachment in bugle. + InputStream is = null; + try { + is = resolver.openInputStream(uri); + final BitmapFactory.Options opt = new BitmapFactory.Options(); + opt.inJustDecodeBounds = true; + BitmapFactory.decodeStream(is, null, opt); + mContentType = opt.outMimeType; + mWidth = opt.outWidth; + mHeight = opt.outHeight; + if (TextUtils.isEmpty(mContentType)) { + // BitmapFactory couldn't figure out the image type. That's got to be a bad + // sign, but see if we can figure it out from the file extension. + mContentType = extractContentType(context, uri); + } + } catch (final FileNotFoundException e) { + LogUtil.e(TAG, "DatabaseMessages.MmsPart.loadImage: file not found", e); + } finally { + if (is != null) { + try { + is.close(); + } catch (final IOException e) { + Log.e(TAG, "IOException caught while closing stream", e); + } + } + } + } + + /** + * Load video file of a video part and parse the dimensions and type + */ + private void loadVideo() { + // This is a coarse check, and should not be applied to outgoing messages. However, + // currently, this does not cause any problems. + if (!VideoThumbnailRequest.shouldShowIncomingVideoThumbnails()) { + return; + } + final Uri uri = getDataUri(); + final MediaMetadataRetrieverWrapper retriever = new MediaMetadataRetrieverWrapper(); + try { + retriever.setDataSource(uri); + // FLAG: This inadvertently fixes a problem with phone receiving audio + // messages on some carrier. We should handle this in a less accidental way so that + // we don't break it again. (The carrier changes the content type in the wrapper + // in-transit from audio/mp4 to video/3gpp without changing the data) + // Also note: There is a bug in some OEM device where mmr returns + // video/ffmpeg for image files. That shouldn't happen here but be aware. + mContentType = + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE); + final Bitmap bitmap = retriever.getFrameAtTime(-1); + if (bitmap != null) { + mWidth = bitmap.getWidth(); + mHeight = bitmap.getHeight(); + } else { + // Get here if it's not actually video (see above) + LogUtil.i(LogUtil.BUGLE_TAG, "loadVideo: Got null bitmap from " + uri); + } + } catch (IOException e) { + LogUtil.i(LogUtil.BUGLE_TAG, "Error extracting metadata from " + uri, e); + } finally { + retriever.release(); + } + } + + /** + * Get media file size + */ + private long getMediaFileSize() { + final Context context = Factory.get().getApplicationContext(); + final Uri uri = getDataUri(); + AssetFileDescriptor fd = null; + try { + fd = context.getContentResolver().openAssetFileDescriptor(uri, "r"); + if (fd != null) { + return fd.getParcelFileDescriptor().getStatSize(); + } + } catch (final FileNotFoundException e) { + LogUtil.e(TAG, "DatabaseMessages.MmsPart: cound not find media file: " + e, e); + } finally { + if (fd != null) { + try { + fd.close(); + } catch (final IOException e) { + LogUtil.e(TAG, "DatabaseMessages.MmsPart: failed to close " + e, e); + } + } + } + return 0L; + } + + /** + * @return If the type is a text type that stores text embedded (i.e. in db table) + */ + private boolean isEmbeddedTextType() { + return ContentType.TEXT_PLAIN.equals(mContentType) + || ContentType.APP_SMIL.equals(mContentType) + || ContentType.TEXT_HTML.equals(mContentType); + } + + /** + * Get an instance of the MMS part from the part table cursor + * + * @param cursor + * @param loadMedia Whether to load the media file of the part + * @return + */ + public static MmsPart get(final Cursor cursor, final boolean loadMedia) { + final MmsPart part = new MmsPart(); + part.load(cursor, loadMedia); + return part; + } + + public boolean isText() { + return ContentType.TEXT_PLAIN.equals(mContentType) + || ContentType.TEXT_HTML.equals(mContentType) + || ContentType.APP_WAP_XHTML.equals(mContentType); + } + + public boolean isMedia() { + return ContentType.isImageType(mContentType) + || ContentType.isVideoType(mContentType) + || ContentType.isAudioType(mContentType) + || ContentType.isVCardType(mContentType); + } + + public boolean isImage() { + return ContentType.isImageType(mContentType); + } + + public Uri getDataUri() { + return Uri.parse("content://mms/part/" + mRowId); + } + + @Override + public int describeContents() { + return 0; + } + + private MmsPart(final Parcel in) { + mUri = in.readString(); + mRowId = in.readLong(); + mMessageId = in.readLong(); + mContentType = in.readString(); + mText = in.readString(); + mCharset = in.readInt(); + mWidth = in.readInt(); + mHeight = in.readInt(); + mSize = in.readLong(); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public MmsPart createFromParcel(final Parcel in) { + return new MmsPart(in); + } + + @Override + public MmsPart[] newArray(final int size) { + return new MmsPart[size]; + } + }; + + @Override + public void writeToParcel(final Parcel out, final int flags) { + out.writeString(mUri); + out.writeLong(mRowId); + out.writeLong(mMessageId); + out.writeString(mContentType); + out.writeString(mText); + out.writeInt(mCharset); + out.writeInt(mWidth); + out.writeInt(mHeight); + out.writeLong(mSize); + } + } + + /** + * This class provides the same DatabaseMessage interface over a local SMS db message + */ + public static class LocalDatabaseMessage extends DatabaseMessage implements Parcelable { + private final int mProtocol; + private final String mUri; + private final long mTimestamp; + private final long mLocalId; + private final String mConversationId; + + public LocalDatabaseMessage(final long localId, final int protocol, final String uri, + final long timestamp, final String conversationId) { + mLocalId = localId; + mProtocol = protocol; + mUri = uri; + mTimestamp = timestamp; + mConversationId = conversationId; + } + + @Override + public int getProtocol() { + return mProtocol; + } + + @Override + public long getTimestampInMillis() { + return mTimestamp; + } + + @Override + public String getUri() { + return mUri; + } + + public long getLocalId() { + return mLocalId; + } + + public String getConversationId() { + return mConversationId; + } + + @Override + public int describeContents() { + return 0; + } + + private LocalDatabaseMessage(final Parcel in) { + mUri = in.readString(); + mConversationId = in.readString(); + mLocalId = in.readLong(); + mTimestamp = in.readLong(); + mProtocol = in.readInt(); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public LocalDatabaseMessage createFromParcel(final Parcel in) { + return new LocalDatabaseMessage(in); + } + + @Override + public LocalDatabaseMessage[] newArray(final int size) { + return new LocalDatabaseMessage[size]; + } + }; + + @Override + public void writeToParcel(final Parcel out, final int flags) { + out.writeString(mUri); + out.writeString(mConversationId); + out.writeLong(mLocalId); + out.writeLong(mTimestamp); + out.writeInt(mProtocol); + } + } + + /** + * Address for MMS message + */ + public static class MmsAddr { + public static final String[] PROJECTION = new String[] { + Mms.Addr.ADDRESS, + Mms.Addr.CHARSET, + }; + private static int sIota = 0; + public static final int INDEX_ADDRESS = sIota++; + public static final int INDEX_CHARSET = sIota++; + + public static String get(final Cursor cursor) { + final int charset = cursor.getInt(INDEX_CHARSET); + // PduPersister stores the addresses using ISO_8859_1 + // Let's load it using that encoding and convert it back to its original + // See PduPersister.persistAddress + return getDecodedString( + getStringBytes(cursor.getString(INDEX_ADDRESS), CharacterSets.ISO_8859_1), + charset); + } + } + + /** + * Decoded string by character set + */ + public static String getDecodedString(final byte[] data, final int charset) { + if (CharacterSets.ANY_CHARSET == charset) { + return new String(data); // system default encoding. + } else { + try { + final String name = CharacterSets.getMimeName(charset); + return new String(data, name); + } catch (final UnsupportedEncodingException e) { + try { + return new String(data, CharacterSets.MIMENAME_ISO_8859_1); + } catch (final UnsupportedEncodingException exception) { + return new String(data); // system default encoding. + } + } + } + } + + /** + * Unpack a given String into a byte[]. + */ + public static byte[] getStringBytes(final String data, final int charset) { + if (CharacterSets.ANY_CHARSET == charset) { + return data.getBytes(); + } else { + try { + final String name = CharacterSets.getMimeName(charset); + return data.getBytes(name); + } catch (final UnsupportedEncodingException e) { + return data.getBytes(); + } + } + } +} diff --git a/src/com/android/messaging/sms/MmsConfig.java b/src/com/android/messaging/sms/MmsConfig.java new file mode 100755 index 0000000..f13d785 --- /dev/null +++ b/src/com/android/messaging/sms/MmsConfig.java @@ -0,0 +1,309 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.sms; + +import android.os.Bundle; +import android.support.v7.mms.CarrierConfigValuesLoader; +import android.telephony.SubscriptionInfo; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.SafeAsyncTask; +import com.google.common.collect.Maps; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * MMS configuration. + * + * This is now a wrapper around the BugleCarrierConfigValuesLoader, which does + * the actual loading and stores the values in a Bundle. This class provides getter + * methods for values used in the app, which is easier to use than the raw loader + * class. + */ +public class MmsConfig { + private static final String TAG = LogUtil.BUGLE_TAG; + + private static final int DEFAULT_MAX_TEXT_LENGTH = 2000; + + /* + * Key types + */ + public static final String KEY_TYPE_INT = "int"; + public static final String KEY_TYPE_BOOL = "bool"; + public static final String KEY_TYPE_STRING = "string"; + + private static final Map sKeyTypeMap = Maps.newHashMap(); + static { + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ENABLED_MMS, KEY_TYPE_BOOL); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ENABLED_TRANS_ID, KEY_TYPE_BOOL); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ENABLED_NOTIFY_WAP_MMSC, KEY_TYPE_BOOL); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ALIAS_ENABLED, KEY_TYPE_BOOL); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ALLOW_ATTACH_AUDIO, KEY_TYPE_BOOL); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ENABLE_MULTIPART_SMS, KEY_TYPE_BOOL); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ENABLE_SMS_DELIVERY_REPORTS, + KEY_TYPE_BOOL); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ENABLE_GROUP_MMS, KEY_TYPE_BOOL); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_SUPPORT_MMS_CONTENT_DISPOSITION, + KEY_TYPE_BOOL); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_CELL_BROADCAST_APP_LINKS, KEY_TYPE_BOOL); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_SEND_MULTIPART_SMS_AS_SEPARATE_MESSAGES, + KEY_TYPE_BOOL); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ENABLE_MMS_READ_REPORTS, KEY_TYPE_BOOL); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ENABLE_MMS_DELIVERY_REPORTS, + KEY_TYPE_BOOL); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_SUPPORT_HTTP_CHARSET_HEADER, + KEY_TYPE_BOOL); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_MAX_MESSAGE_SIZE, KEY_TYPE_INT); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_MAX_IMAGE_HEIGHT, KEY_TYPE_INT); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_MAX_IMAGE_WIDTH, KEY_TYPE_INT); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_RECIPIENT_LIMIT, KEY_TYPE_INT); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_HTTP_SOCKET_TIMEOUT, KEY_TYPE_INT); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ALIAS_MIN_CHARS, KEY_TYPE_INT); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_ALIAS_MAX_CHARS, KEY_TYPE_INT); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_SMS_TO_MMS_TEXT_THRESHOLD, KEY_TYPE_INT); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_SMS_TO_MMS_TEXT_LENGTH_THRESHOLD, + KEY_TYPE_INT); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_MAX_MESSAGE_TEXT_SIZE, KEY_TYPE_INT); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_MAX_SUBJECT_LENGTH, KEY_TYPE_INT); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_UA_PROF_TAG_NAME, KEY_TYPE_STRING); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_HTTP_PARAMS, KEY_TYPE_STRING); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_EMAIL_GATEWAY_NUMBER, KEY_TYPE_STRING); + sKeyTypeMap.put(CarrierConfigValuesLoader.CONFIG_NAI_SUFFIX, KEY_TYPE_STRING); + } + + // A map that stores all MmsConfigs, one per active subscription. For pre-LMSim, this will + // contain just one entry with the default self sub id; for LMSim and above, this will contain + // all active sub ids but the default subscription id - the default subscription id will be + // resolved to an active sub id during runtime. + private static final Map sSubIdToMmsConfigMap = Maps.newHashMap(); + // The fallback values + private static final MmsConfig sFallback = + new MmsConfig(ParticipantData.DEFAULT_SELF_SUB_ID, new Bundle()); + + // Per-subscription configuration values. + private final Bundle mValues; + private final int mSubId; + + /** + * Retrieves the MmsConfig instance associated with the given {@code subId} + */ + public static MmsConfig get(final int subId) { + final int realSubId = PhoneUtils.getDefault().getEffectiveSubId(subId); + synchronized (sSubIdToMmsConfigMap) { + final MmsConfig mmsConfig = sSubIdToMmsConfigMap.get(realSubId); + if (mmsConfig == null) { + // The subId is no longer valid. Fall back to the default config. + LogUtil.e(LogUtil.BUGLE_TAG, "Get mms config failed: invalid subId. subId=" + subId + + ", real subId=" + realSubId + + ", map=" + sSubIdToMmsConfigMap.keySet()); + return sFallback; + } + return mmsConfig; + } + } + + private MmsConfig(final int subId, final Bundle values) { + mSubId = subId; + mValues = values; + } + + /** + * Same as load() but doing it using an async thread from SafeAsyncTask thread pool. + */ + public static void loadAsync() { + SafeAsyncTask.executeOnThreadPool(new Runnable() { + @Override + public void run() { + load(); + } + }); + } + + /** + * Reload the device and per-subscription settings. + */ + public static synchronized void load() { + final BugleCarrierConfigValuesLoader loader = Factory.get().getCarrierConfigValuesLoader(); + // Rebuild the entire MmsConfig map. + sSubIdToMmsConfigMap.clear(); + loader.reset(); + if (OsUtil.isAtLeastL_MR1()) { + final List subInfoRecords = + PhoneUtils.getDefault().toLMr1().getActiveSubscriptionInfoList(); + if (subInfoRecords == null) { + LogUtil.w(TAG, "Loading mms config failed: no active SIM"); + return; + } + for (SubscriptionInfo subInfoRecord : subInfoRecords) { + final int subId = subInfoRecord.getSubscriptionId(); + final Bundle values = loader.get(subId); + addMmsConfig(new MmsConfig(subId, values)); + } + } else { + final Bundle values = loader.get(ParticipantData.DEFAULT_SELF_SUB_ID); + addMmsConfig(new MmsConfig(ParticipantData.DEFAULT_SELF_SUB_ID, values)); + } + } + + private static void addMmsConfig(MmsConfig mmsConfig) { + Assert.isTrue(OsUtil.isAtLeastL_MR1() != + (mmsConfig.mSubId == ParticipantData.DEFAULT_SELF_SUB_ID)); + sSubIdToMmsConfigMap.put(mmsConfig.mSubId, mmsConfig); + } + + public int getSmsToMmsTextThreshold() { + return mValues.getInt(CarrierConfigValuesLoader.CONFIG_SMS_TO_MMS_TEXT_THRESHOLD, + CarrierConfigValuesLoader.CONFIG_SMS_TO_MMS_TEXT_THRESHOLD_DEFAULT); + } + + public int getSmsToMmsTextLengthThreshold() { + return mValues.getInt(CarrierConfigValuesLoader.CONFIG_SMS_TO_MMS_TEXT_LENGTH_THRESHOLD, + CarrierConfigValuesLoader.CONFIG_SMS_TO_MMS_TEXT_LENGTH_THRESHOLD_DEFAULT); + } + + public int getMaxMessageSize() { + return mValues.getInt(CarrierConfigValuesLoader.CONFIG_MAX_MESSAGE_SIZE, + CarrierConfigValuesLoader.CONFIG_MAX_MESSAGE_SIZE_DEFAULT); + } + + /** + * Return the largest MaxMessageSize for any subid + */ + public static int getMaxMaxMessageSize() { + int maxMax = 0; + for (MmsConfig config : sSubIdToMmsConfigMap.values()) { + maxMax = Math.max(maxMax, config.getMaxMessageSize()); + } + return maxMax > 0 ? maxMax : sFallback.getMaxMessageSize(); + } + + public boolean getTransIdEnabled() { + return mValues.getBoolean(CarrierConfigValuesLoader.CONFIG_ENABLED_TRANS_ID, + CarrierConfigValuesLoader.CONFIG_ENABLED_TRANS_ID_DEFAULT); + } + + public String getEmailGateway() { + return mValues.getString(CarrierConfigValuesLoader.CONFIG_EMAIL_GATEWAY_NUMBER, + CarrierConfigValuesLoader.CONFIG_EMAIL_GATEWAY_NUMBER_DEFAULT); + } + + public int getMaxImageHeight() { + return mValues.getInt(CarrierConfigValuesLoader.CONFIG_MAX_IMAGE_HEIGHT, + CarrierConfigValuesLoader.CONFIG_MAX_IMAGE_HEIGHT_DEFAULT); + } + + public int getMaxImageWidth() { + return mValues.getInt(CarrierConfigValuesLoader.CONFIG_MAX_IMAGE_WIDTH, + CarrierConfigValuesLoader.CONFIG_MAX_IMAGE_WIDTH_DEFAULT); + } + + public int getRecipientLimit() { + final int limit = mValues.getInt(CarrierConfigValuesLoader.CONFIG_RECIPIENT_LIMIT, + CarrierConfigValuesLoader.CONFIG_RECIPIENT_LIMIT_DEFAULT); + return limit < 0 ? Integer.MAX_VALUE : limit; + } + + public int getMaxTextLimit() { + final int max = mValues.getInt(CarrierConfigValuesLoader.CONFIG_MAX_MESSAGE_TEXT_SIZE, + CarrierConfigValuesLoader.CONFIG_MAX_MESSAGE_TEXT_SIZE_DEFAULT); + return max > -1 ? max : DEFAULT_MAX_TEXT_LENGTH; + } + + public boolean getMultipartSmsEnabled() { + return mValues.getBoolean(CarrierConfigValuesLoader.CONFIG_ENABLE_MULTIPART_SMS, + CarrierConfigValuesLoader.CONFIG_ENABLE_MULTIPART_SMS_DEFAULT); + } + + public boolean getSendMultipartSmsAsSeparateMessages() { + return mValues.getBoolean( + CarrierConfigValuesLoader.CONFIG_SEND_MULTIPART_SMS_AS_SEPARATE_MESSAGES, + CarrierConfigValuesLoader.CONFIG_SEND_MULTIPART_SMS_AS_SEPARATE_MESSAGES_DEFAULT); + } + + public boolean getSMSDeliveryReportsEnabled() { + return mValues.getBoolean(CarrierConfigValuesLoader.CONFIG_ENABLE_SMS_DELIVERY_REPORTS, + CarrierConfigValuesLoader.CONFIG_ENABLE_SMS_DELIVERY_REPORTS_DEFAULT); + } + + public boolean getNotifyWapMMSC() { + return mValues.getBoolean(CarrierConfigValuesLoader.CONFIG_ENABLED_NOTIFY_WAP_MMSC, + CarrierConfigValuesLoader.CONFIG_ENABLED_NOTIFY_WAP_MMSC_DEFAULT); + } + + public boolean isAliasEnabled() { + return mValues.getBoolean(CarrierConfigValuesLoader.CONFIG_ALIAS_ENABLED, + CarrierConfigValuesLoader.CONFIG_ALIAS_ENABLED_DEFAULT); + } + + public int getAliasMinChars() { + return mValues.getInt(CarrierConfigValuesLoader.CONFIG_ALIAS_MIN_CHARS, + CarrierConfigValuesLoader.CONFIG_ALIAS_MIN_CHARS_DEFAULT); + } + + public int getAliasMaxChars() { + return mValues.getInt(CarrierConfigValuesLoader.CONFIG_ALIAS_MAX_CHARS, + CarrierConfigValuesLoader.CONFIG_ALIAS_MAX_CHARS_DEFAULT); + } + + public boolean getAllowAttachAudio() { + return mValues.getBoolean(CarrierConfigValuesLoader.CONFIG_ALLOW_ATTACH_AUDIO, + CarrierConfigValuesLoader.CONFIG_ALLOW_ATTACH_AUDIO_DEFAULT); + } + + public int getMaxSubjectLength() { + return mValues.getInt(CarrierConfigValuesLoader.CONFIG_MAX_SUBJECT_LENGTH, + CarrierConfigValuesLoader.CONFIG_MAX_SUBJECT_LENGTH_DEFAULT); + } + + public boolean getGroupMmsEnabled() { + return mValues.getBoolean(CarrierConfigValuesLoader.CONFIG_ENABLE_GROUP_MMS, + CarrierConfigValuesLoader.CONFIG_ENABLE_GROUP_MMS_DEFAULT); + } + + public boolean getSupportMmsContentDisposition() { + return mValues.getBoolean(CarrierConfigValuesLoader.CONFIG_SUPPORT_MMS_CONTENT_DISPOSITION, + CarrierConfigValuesLoader.CONFIG_SUPPORT_MMS_CONTENT_DISPOSITION_DEFAULT); + } + + public boolean getShowCellBroadcast() { + return mValues.getBoolean(CarrierConfigValuesLoader.CONFIG_CELL_BROADCAST_APP_LINKS, + CarrierConfigValuesLoader.CONFIG_CELL_BROADCAST_APP_LINKS_DEFAULT); + } + + public Object getValue(final String key) { + return mValues.get(key); + } + + public Set keySet() { + return mValues.keySet(); + } + + public static String getKeyType(final String key) { + return sKeyTypeMap.get(key); + } + + public void update(final String type, final String key, final String value) { + BugleCarrierConfigValuesLoader.update(mValues, type, key, value); + } +} diff --git a/src/com/android/messaging/sms/MmsFailureException.java b/src/com/android/messaging/sms/MmsFailureException.java new file mode 100644 index 0000000..dd702ee --- /dev/null +++ b/src/com/android/messaging/sms/MmsFailureException.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.sms; + +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.util.Assert; + +/** + * Exception for MMS failures + */ +public class MmsFailureException extends Exception { + private static final long serialVersionUID = 1L; + + /** + * Hint of how we should retry in case of failure. Take values defined in MmsUtils. + */ + public final int retryHint; + + /** + * If set, provides a more detailed reason for the failure. + */ + public final int rawStatus; + + private void checkRetryHint() { + Assert.isTrue(retryHint == MmsUtils.MMS_REQUEST_AUTO_RETRY + || retryHint == MmsUtils.MMS_REQUEST_MANUAL_RETRY + || retryHint == MmsUtils.MMS_REQUEST_NO_RETRY); + } + /** + * Creates a new MmsFailureException. + * + * @param retryHint Hint for how to retry + */ + public MmsFailureException(final int retryHint) { + super(); + this.retryHint = retryHint; + checkRetryHint(); + this.rawStatus = MessageData.RAW_TELEPHONY_STATUS_UNDEFINED; + } + + public MmsFailureException(final int retryHint, final int rawStatus) { + super(); + this.retryHint = retryHint; + checkRetryHint(); + this.rawStatus = rawStatus; + } + + /** + * Creates a new MmsFailureException with the specified detail message. + * + * @param retryHint Hint for how to retry + * @param message the detail message. + */ + public MmsFailureException(final int retryHint, String message) { + super(message); + this.retryHint = retryHint; + checkRetryHint(); + this.rawStatus = MessageData.RAW_TELEPHONY_STATUS_UNDEFINED; + } + + /** + * Creates a new MmsFailureException with the specified cause. + * + * @param retryHint Hint for how to retry + * @param cause the cause. + */ + public MmsFailureException(final int retryHint, Throwable cause) { + super(cause); + this.retryHint = retryHint; + checkRetryHint(); + this.rawStatus = MessageData.RAW_TELEPHONY_STATUS_UNDEFINED; + } + + /** + * Creates a new MmsFailureException + * with the specified detail message and cause. + * + * @param retryHint Hint for how to retry + * @param message the detail message. + * @param cause the cause. + */ + public MmsFailureException(final int retryHint, String message, Throwable cause) { + super(message, cause); + this.retryHint = retryHint; + checkRetryHint(); + this.rawStatus = MessageData.RAW_TELEPHONY_STATUS_UNDEFINED; + } +} diff --git a/src/com/android/messaging/sms/MmsSender.java b/src/com/android/messaging/sms/MmsSender.java new file mode 100644 index 0000000..6dfa81a --- /dev/null +++ b/src/com/android/messaging/sms/MmsSender.java @@ -0,0 +1,312 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.sms; + +import android.app.Activity; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.support.v7.mms.MmsManager; +import android.telephony.SmsManager; + +import com.android.messaging.datamodel.MmsFileProvider; +import com.android.messaging.datamodel.action.SendMessageAction; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.mmslib.InvalidHeaderValueException; +import com.android.messaging.mmslib.pdu.AcknowledgeInd; +import com.android.messaging.mmslib.pdu.EncodedStringValue; +import com.android.messaging.mmslib.pdu.GenericPdu; +import com.android.messaging.mmslib.pdu.NotifyRespInd; +import com.android.messaging.mmslib.pdu.PduComposer; +import com.android.messaging.mmslib.pdu.PduHeaders; +import com.android.messaging.mmslib.pdu.PduParser; +import com.android.messaging.mmslib.pdu.RetrieveConf; +import com.android.messaging.mmslib.pdu.SendConf; +import com.android.messaging.mmslib.pdu.SendReq; +import com.android.messaging.receiver.SendStatusReceiver; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.PhoneUtils; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; + +/** + * Class that sends chat message via MMS. + * + * The interface emulates a blocking send similar to making an HTTP request. + */ +public class MmsSender { + private static final String TAG = LogUtil.BUGLE_TAG; + + /** + * Send an MMS message. + * + * @param context Context + * @param messageUri The unique URI of the message for identifying it during sending + * @param sendReq The SendReq PDU of the message + * @throws MmsFailureException + */ + public static void sendMms(final Context context, final int subId, final Uri messageUri, + final SendReq sendReq, final Bundle sentIntentExras) throws MmsFailureException { + sendMms(context, + subId, + messageUri, + null /* locationUrl */, + sendReq, + true /* responseImportant */, + sentIntentExras); + } + + /** + * Send NotifyRespInd (response to mms auto download). + * + * @param context Context + * @param subId subscription to use to send the response + * @param transactionId The transaction id of the MMS message + * @param contentLocation The url of the MMS message + * @param status The status to send with the NotifyRespInd + * @throws MmsFailureException + * @throws InvalidHeaderValueException + */ + public static void sendNotifyResponseForMmsDownload(final Context context, final int subId, + final byte[] transactionId, final String contentLocation, final int status) + throws MmsFailureException, InvalidHeaderValueException { + // Create the M-NotifyResp.ind + final NotifyRespInd notifyRespInd = new NotifyRespInd( + PduHeaders.CURRENT_MMS_VERSION, transactionId, status); + final Uri messageUri = Uri.parse(contentLocation); + // Pack M-NotifyResp.ind and send it + sendMms(context, + subId, + messageUri, + MmsConfig.get(subId).getNotifyWapMMSC() ? contentLocation : null, + notifyRespInd, + false /* responseImportant */, + null /* sentIntentExtras */); + } + + /** + * Send AcknowledgeInd (response to mms manual download). Ignore failures. + * + * @param context Context + * @param subId The SIM's subId we are currently using + * @param transactionId The transaction id of the MMS message + * @param contentLocation The url of the MMS message + * @throws MmsFailureException + * @throws InvalidHeaderValueException + */ + public static void sendAcknowledgeForMmsDownload(final Context context, final int subId, + final byte[] transactionId, final String contentLocation) + throws MmsFailureException, InvalidHeaderValueException { + final String selfNumber = PhoneUtils.get(subId).getCanonicalForSelf(true/*allowOverride*/); + // Create the M-Acknowledge.ind + final AcknowledgeInd acknowledgeInd = new AcknowledgeInd(PduHeaders.CURRENT_MMS_VERSION, + transactionId); + acknowledgeInd.setFrom(new EncodedStringValue(selfNumber)); + final Uri messageUri = Uri.parse(contentLocation); + // Sending + sendMms(context, + subId, + messageUri, + MmsConfig.get(subId).getNotifyWapMMSC() ? contentLocation : null, + acknowledgeInd, + false /*responseImportant*/, + null /* sentIntentExtras */); + } + + /** + * Send a generic PDU. + * + * @param context Context + * @param messageUri The unique URI of the message for identifying it during sending + * @param locationUrl The optional URL to send to + * @param pdu The PDU to send + * @param responseImportant If the sending response is important. Responses to the + * Sending of AcknowledgeInd and NotifyRespInd are not important. + * @throws MmsFailureException + */ + private static void sendMms(final Context context, final int subId, final Uri messageUri, + final String locationUrl, final GenericPdu pdu, final boolean responseImportant, + final Bundle sentIntentExtras) throws MmsFailureException { + // Write PDU to temporary file to send to platform + final Uri contentUri = writePduToTempFile(context, pdu, subId); + + // Construct PendingIntent that will notify us when message sending is complete + final Intent sentIntent = new Intent(SendStatusReceiver.MMS_SENT_ACTION, + messageUri, + context, + SendStatusReceiver.class); + sentIntent.putExtra(SendMessageAction.EXTRA_CONTENT_URI, contentUri); + sentIntent.putExtra(SendMessageAction.EXTRA_RESPONSE_IMPORTANT, responseImportant); + if (sentIntentExtras != null) { + sentIntent.putExtras(sentIntentExtras); + } + final PendingIntent sentPendingIntent = PendingIntent.getBroadcast( + context, + 0 /*request code*/, + sentIntent, + PendingIntent.FLAG_UPDATE_CURRENT); + + // Send the message + MmsManager.sendMultimediaMessage(subId, context, contentUri, locationUrl, + sentPendingIntent); + } + + private static Uri writePduToTempFile(final Context context, final GenericPdu pdu, int subId) + throws MmsFailureException { + final Uri contentUri = MmsFileProvider.buildRawMmsUri(); + final File tempFile = MmsFileProvider.getFile(contentUri); + FileOutputStream writer = null; + try { + // Ensure rawmms directory exists + tempFile.getParentFile().mkdirs(); + writer = new FileOutputStream(tempFile); + final byte[] pduBytes = new PduComposer(context, pdu).make(); + if (pduBytes == null) { + throw new MmsFailureException( + MmsUtils.MMS_REQUEST_NO_RETRY, "Failed to compose PDU"); + } + if (pduBytes.length > MmsConfig.get(subId).getMaxMessageSize()) { + throw new MmsFailureException( + MmsUtils.MMS_REQUEST_NO_RETRY, + MessageData.RAW_TELEPHONY_STATUS_MESSAGE_TOO_BIG); + } + writer.write(pduBytes); + } catch (final IOException e) { + if (tempFile != null) { + tempFile.delete(); + } + LogUtil.e(TAG, "Cannot create temporary file " + tempFile.getAbsolutePath(), e); + throw new MmsFailureException( + MmsUtils.MMS_REQUEST_AUTO_RETRY, "Cannot create raw mms file"); + } catch (final OutOfMemoryError e) { + if (tempFile != null) { + tempFile.delete(); + } + LogUtil.e(TAG, "Out of memory in composing PDU", e); + throw new MmsFailureException( + MmsUtils.MMS_REQUEST_MANUAL_RETRY, + MessageData.RAW_TELEPHONY_STATUS_MESSAGE_TOO_BIG); + } finally { + if (writer != null) { + try { + writer.close(); + } catch (final IOException e) { + // no action we can take here + } + } + } + return contentUri; + } + + public static SendConf parseSendConf(byte[] response, int subId) { + if (response != null) { + final GenericPdu respPdu = new PduParser( + response, MmsConfig.get(subId).getSupportMmsContentDisposition()).parse(); + if (respPdu != null) { + if (respPdu instanceof SendConf) { + return (SendConf) respPdu; + } else { + LogUtil.e(TAG, "MmsSender: send response not SendConf"); + } + } else { + // Invalid PDU + LogUtil.e(TAG, "MmsSender: send invalid response"); + } + } + // Empty or invalid response + return null; + } + + /** + * Download an MMS message. + * + * @param context Context + * @param contentLocation The url of the MMS message + * @throws MmsFailureException + * @throws InvalidHeaderValueException + */ + public static void downloadMms(final Context context, final int subId, + final String contentLocation, Bundle extras) throws MmsFailureException, + InvalidHeaderValueException { + final Uri requestUri = Uri.parse(contentLocation); + final Uri contentUri = MmsFileProvider.buildRawMmsUri(); + + final Intent downloadedIntent = new Intent(SendStatusReceiver.MMS_DOWNLOADED_ACTION, + requestUri, + context, + SendStatusReceiver.class); + downloadedIntent.putExtra(SendMessageAction.EXTRA_CONTENT_URI, contentUri); + if (extras != null) { + downloadedIntent.putExtras(extras); + } + final PendingIntent downloadedPendingIntent = PendingIntent.getBroadcast( + context, + 0 /*request code*/, + downloadedIntent, + PendingIntent.FLAG_UPDATE_CURRENT); + + MmsManager.downloadMultimediaMessage(subId, context, contentLocation, contentUri, + downloadedPendingIntent); + } + + public static RetrieveConf parseRetrieveConf(byte[] data, int subId) { + if (data != null) { + final GenericPdu pdu = new PduParser( + data, MmsConfig.get(subId).getSupportMmsContentDisposition()).parse(); + if (pdu != null) { + if (pdu instanceof RetrieveConf) { + return (RetrieveConf) pdu; + } else { + LogUtil.e(TAG, "MmsSender: downloaded pdu not RetrieveConf: " + + pdu.getClass().getName()); + } + } else { + LogUtil.e(TAG, "MmsSender: downloaded pdu could not be parsed (invalid)"); + } + } + LogUtil.e(TAG, "MmsSender: downloaded pdu is empty"); + return null; + } + + // Process different result code from platform MMS service + public static int getErrorResultStatus(int resultCode, int httpStatusCode) { + Assert.isFalse(resultCode == Activity.RESULT_OK); + switch (resultCode) { + case SmsManager.MMS_ERROR_UNABLE_CONNECT_MMS: + case SmsManager.MMS_ERROR_IO_ERROR: + return MmsUtils.MMS_REQUEST_AUTO_RETRY; + case SmsManager.MMS_ERROR_INVALID_APN: + case SmsManager.MMS_ERROR_CONFIGURATION_ERROR: + case SmsManager.MMS_ERROR_NO_DATA_NETWORK: + case SmsManager.MMS_ERROR_UNSPECIFIED: + return MmsUtils.MMS_REQUEST_MANUAL_RETRY; + case SmsManager.MMS_ERROR_HTTP_FAILURE: + if (httpStatusCode == 404) { + return MmsUtils.MMS_REQUEST_NO_RETRY; + } else { + return MmsUtils.MMS_REQUEST_AUTO_RETRY; + } + default: + return MmsUtils.MMS_REQUEST_MANUAL_RETRY; + } + } +} diff --git a/src/com/android/messaging/sms/MmsSmsUtils.java b/src/com/android/messaging/sms/MmsSmsUtils.java new file mode 100644 index 0000000..1a0ef99 --- /dev/null +++ b/src/com/android/messaging/sms/MmsSmsUtils.java @@ -0,0 +1,204 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.sms; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.BaseColumns; +import android.text.TextUtils; +import android.util.Patterns; + +import com.android.messaging.mmslib.SqliteWrapper; +import com.android.messaging.util.LogUtil; + +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Utility functions for the Messaging Service + */ +public class MmsSmsUtils { + private MmsSmsUtils() { + // Forbidden being instantiated. + } + + // An alias (or commonly called "nickname") is: + // Nickname must begin with a letter. + // Only letters a-z, numbers 0-9, or . are allowed in Nickname field. + public static boolean isAlias(final String string, final int subId) { + if (!MmsConfig.get(subId).isAliasEnabled()) { + return false; + } + + final int len = string == null ? 0 : string.length(); + + if (len < MmsConfig.get(subId).getAliasMinChars() || + len > MmsConfig.get(subId).getAliasMaxChars()) { + return false; + } + + if (!Character.isLetter(string.charAt(0))) { // Nickname begins with a letter + return false; + } + for (int i = 1; i < len; i++) { + final char c = string.charAt(i); + if (!(Character.isLetterOrDigit(c) || c == '.')) { + return false; + } + } + + return true; + } + + /** + * mailbox = name-addr + * name-addr = [display-name] angle-addr + * angle-addr = [CFWS] "<" addr-spec ">" [CFWS] + */ + public static final Pattern NAME_ADDR_EMAIL_PATTERN = + Pattern.compile("\\s*(\"[^\"]*\"|[^<>\"]+)\\s*<([^<>]+)>\\s*"); + + public static String extractAddrSpec(final String address) { + final Matcher match = NAME_ADDR_EMAIL_PATTERN.matcher(address); + + if (match.matches()) { + return match.group(2); + } + return address; + } + + /** + * Returns true if the address is an email address + * + * @param address the input address to be tested + * @return true if address is an email address + */ + public static boolean isEmailAddress(final String address) { + if (TextUtils.isEmpty(address)) { + return false; + } + + final String s = extractAddrSpec(address); + final Matcher match = Patterns.EMAIL_ADDRESS.matcher(s); + return match.matches(); + } + + /** + * Returns true if the number is a Phone number + * + * @param number the input number to be tested + * @return true if number is a Phone number + */ + public static boolean isPhoneNumber(final String number) { + if (TextUtils.isEmpty(number)) { + return false; + } + + final Matcher match = Patterns.PHONE.matcher(number); + return match.matches(); + } + + /** + * Check if MMS is required when sending to email address + * + * @param destinationHasEmailAddress destination includes an email address + * @return true if MMS is required. + */ + public static boolean getRequireMmsForEmailAddress(final boolean destinationHasEmailAddress, + final int subId) { + if (!TextUtils.isEmpty(MmsConfig.get(subId).getEmailGateway())) { + return false; + } else { + return destinationHasEmailAddress; + } + } + + /** + * Helper functions for the "threads" table used by MMS and SMS. + */ + public static final class Threads implements android.provider.Telephony.ThreadsColumns { + private static final String[] ID_PROJECTION = { BaseColumns._ID }; + private static final Uri THREAD_ID_CONTENT_URI = Uri.parse( + "content://mms-sms/threadID"); + public static final Uri CONTENT_URI = Uri.withAppendedPath( + android.provider.Telephony.MmsSms.CONTENT_URI, "conversations"); + + // No one should construct an instance of this class. + private Threads() { + } + + /** + * This is a single-recipient version of + * getOrCreateThreadId. It's convenient for use with SMS + * messages. + */ + public static long getOrCreateThreadId(final Context context, final String recipient) { + final Set recipients = new HashSet(); + + recipients.add(recipient); + return getOrCreateThreadId(context, recipients); + } + + /** + * Given the recipients list and subject of an unsaved message, + * return its thread ID. If the message starts a new thread, + * allocate a new thread ID. Otherwise, use the appropriate + * existing thread ID. + * + * Find the thread ID of the same set of recipients (in + * any order, without any additions). If one + * is found, return it. Otherwise, return a unique thread ID. + */ + public static long getOrCreateThreadId( + final Context context, final Set recipients) { + final Uri.Builder uriBuilder = THREAD_ID_CONTENT_URI.buildUpon(); + + for (String recipient : recipients) { + if (isEmailAddress(recipient)) { + recipient = extractAddrSpec(recipient); + } + + uriBuilder.appendQueryParameter("recipient", recipient); + } + + final Uri uri = uriBuilder.build(); + //if (DEBUG) Rlog.v(TAG, "getOrCreateThreadId uri: " + uri); + + final Cursor cursor = SqliteWrapper.query(context, context.getContentResolver(), + uri, ID_PROJECTION, null, null, null); + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + return cursor.getLong(0); + } else { + LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, + "getOrCreateThreadId returned no rows!"); + } + } finally { + cursor.close(); + } + } + + LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "getOrCreateThreadId failed with " + + LogUtil.sanitizePII(recipients.toString())); + throw new IllegalArgumentException("Unable to find or allocate a thread ID."); + } + } +} diff --git a/src/com/android/messaging/sms/MmsUtils.java b/src/com/android/messaging/sms/MmsUtils.java new file mode 100644 index 0000000..913e9a6 --- /dev/null +++ b/src/com/android/messaging/sms/MmsUtils.java @@ -0,0 +1,2747 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.sms; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.res.AssetFileDescriptor; +import android.content.res.Resources; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.media.MediaMetadataRetriever; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.Uri; +import android.os.Bundle; +import android.provider.Settings; +import android.provider.Telephony; +import android.provider.Telephony.Mms; +import android.provider.Telephony.Sms; +import android.provider.Telephony.Threads; +import android.telephony.SmsManager; +import android.telephony.SmsMessage; +import android.text.TextUtils; +import android.text.util.Rfc822Token; +import android.text.util.Rfc822Tokenizer; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.datamodel.action.DownloadMmsAction; +import com.android.messaging.datamodel.action.SendMessageAction; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.mmslib.InvalidHeaderValueException; +import com.android.messaging.mmslib.MmsException; +import com.android.messaging.mmslib.SqliteWrapper; +import com.android.messaging.mmslib.pdu.CharacterSets; +import com.android.messaging.mmslib.pdu.EncodedStringValue; +import com.android.messaging.mmslib.pdu.GenericPdu; +import com.android.messaging.mmslib.pdu.NotificationInd; +import com.android.messaging.mmslib.pdu.PduBody; +import com.android.messaging.mmslib.pdu.PduComposer; +import com.android.messaging.mmslib.pdu.PduHeaders; +import com.android.messaging.mmslib.pdu.PduParser; +import com.android.messaging.mmslib.pdu.PduPart; +import com.android.messaging.mmslib.pdu.PduPersister; +import com.android.messaging.mmslib.pdu.RetrieveConf; +import com.android.messaging.mmslib.pdu.SendConf; +import com.android.messaging.mmslib.pdu.SendReq; +import com.android.messaging.sms.SmsSender.SendResult; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.EmailAddress; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.ImageUtils.ImageResizer; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.MediaMetadataRetrieverWrapper; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.google.common.base.Joiner; + +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +/** + * Utils for sending sms/mms messages. + */ +public class MmsUtils { + private static final String TAG = LogUtil.BUGLE_TAG; + + public static final boolean DEFAULT_DELIVERY_REPORT_MODE = false; + public static final boolean DEFAULT_READ_REPORT_MODE = false; + public static final long DEFAULT_EXPIRY_TIME_IN_SECONDS = 7 * 24 * 60 * 60; + public static final int DEFAULT_PRIORITY = PduHeaders.PRIORITY_NORMAL; + + public static final int MAX_SMS_RETRY = 3; + + /** + * MMS request succeeded + */ + public static final int MMS_REQUEST_SUCCEEDED = 0; + /** + * MMS request failed with a transient error and can be retried automatically + */ + public static final int MMS_REQUEST_AUTO_RETRY = 1; + /** + * MMS request failed with an error and can be retried manually + */ + public static final int MMS_REQUEST_MANUAL_RETRY = 2; + /** + * MMS request failed with a specific error and should not be retried + */ + public static final int MMS_REQUEST_NO_RETRY = 3; + + public static final String getRequestStatusDescription(final int status) { + switch (status) { + case MMS_REQUEST_SUCCEEDED: + return "SUCCEEDED"; + case MMS_REQUEST_AUTO_RETRY: + return "AUTO_RETRY"; + case MMS_REQUEST_MANUAL_RETRY: + return "MANUAL_RETRY"; + case MMS_REQUEST_NO_RETRY: + return "NO_RETRY"; + default: + return String.valueOf(status) + " (check MmsUtils)"; + } + } + + public static final int PDU_HEADER_VALUE_UNDEFINED = 0; + + private static final int DEFAULT_DURATION = 5000; //ms + + // amount of space to leave in a MMS for text and overhead. + private static final int MMS_MAX_SIZE_SLOP = 1024; + public static final long INVALID_TIMESTAMP = 0L; + private static String[] sNoSubjectStrings; + + public static class MmsInfo { + public Uri mUri; + public int mMessageSize; + public PduBody mPduBody; + } + + // Sync all remote messages apart from drafts + private static final String REMOTE_SMS_SELECTION = String.format( + Locale.US, + "(%s IN (%d, %d, %d, %d, %d))", + Sms.TYPE, + Sms.MESSAGE_TYPE_INBOX, + Sms.MESSAGE_TYPE_OUTBOX, + Sms.MESSAGE_TYPE_QUEUED, + Sms.MESSAGE_TYPE_FAILED, + Sms.MESSAGE_TYPE_SENT); + + private static final String REMOTE_MMS_SELECTION = String.format( + Locale.US, + "((%s IN (%d, %d, %d, %d)) AND (%s IN (%d, %d, %d)))", + Mms.MESSAGE_BOX, + Mms.MESSAGE_BOX_INBOX, + Mms.MESSAGE_BOX_OUTBOX, + Mms.MESSAGE_BOX_SENT, + Mms.MESSAGE_BOX_FAILED, + Mms.MESSAGE_TYPE, + PduHeaders.MESSAGE_TYPE_SEND_REQ, + PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND, + PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF); + + /** + * Type selection for importing sms messages. + * + * @return The SQL selection for importing sms messages + */ + public static String getSmsTypeSelectionSql() { + return REMOTE_SMS_SELECTION; + } + + /** + * Type selection for importing mms messages. + * + * @return The SQL selection for importing mms messages. This selects the message type, + * not including the selection on timestamp. + */ + public static String getMmsTypeSelectionSql() { + return REMOTE_MMS_SELECTION; + } + + // SMIL spec: http://www.w3.org/TR/SMIL3 + + private static final String sSmilImagePart = + "" + + "" + + ""; + + private static final String sSmilVideoPart = + "" + + ""; + + private static final String sSmilAudioPart = + "" + + ""; + + private static final String sSmilTextPart = + "" + + "" + + ""; + + private static final String sSmilPart = + "" + + "" + + ""; + + private static final String sSmilTextOnly = + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "%s" + // constructed body goes here + "" + + ""; + + private static final String sSmilVisualAttachmentsOnly = + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "%s" + // constructed body goes here + "" + + ""; + + private static final String sSmilVisualAttachmentsWithText = + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "%s" + // constructed body goes here + "" + + ""; + + private static final String sSmilNonVisualAttachmentsOnly = + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "%s" + // constructed body goes here + "" + + ""; + + private static final String sSmilNonVisualAttachmentsWithText = sSmilTextOnly; + + public static final String MMS_DUMP_PREFIX = "mmsdump-"; + public static final String SMS_DUMP_PREFIX = "smsdump-"; + + public static final int MIN_VIDEO_BYTES_PER_SECOND = 4 * 1024; + public static final int MIN_IMAGE_BYTE_SIZE = 16 * 1024; + public static final int MAX_VIDEO_ATTACHMENT_COUNT = 1; + + public static MmsInfo makePduBody(final Context context, final MessageData message, + final int subId) { + final PduBody pb = new PduBody(); + + // Compute data size requirements for this message: count up images and total size of + // non-image attachments. + int totalLength = 0; + int countImage = 0; + for (final MessagePartData part : message.getParts()) { + if (part.isAttachment()) { + final String contentType = part.getContentType(); + if (ContentType.isImageType(contentType)) { + countImage++; + } else if (ContentType.isVCardType(contentType)) { + totalLength += getDataLength(context, part.getContentUri()); + } else { + totalLength += getMediaFileSize(part.getContentUri()); + } + } + } + final long minSize = countImage * MIN_IMAGE_BYTE_SIZE; + final int byteBudget = MmsConfig.get(subId).getMaxMessageSize() - totalLength + - MMS_MAX_SIZE_SLOP; + final double budgetFactor = + minSize > 0 ? Math.max(1.0, byteBudget / ((double) minSize)) : 1; + final int bytesPerImage = (int) (budgetFactor * MIN_IMAGE_BYTE_SIZE); + final int widthLimit = MmsConfig.get(subId).getMaxImageWidth(); + final int heightLimit = MmsConfig.get(subId).getMaxImageHeight(); + + // Actually add the attachments, shrinking images appropriately. + int index = 0; + totalLength = 0; + boolean hasVisualAttachment = false; + boolean hasNonVisualAttachment = false; + boolean hasText = false; + final StringBuilder smilBody = new StringBuilder(); + for (final MessagePartData part : message.getParts()) { + String srcName; + if (part.isAttachment()) { + String contentType = part.getContentType(); + if (ContentType.isImageType(contentType)) { + // There's a good chance that if we selected the image from our media picker the + // content type is image/*. Fix the content type here for gifs so that we only + // need to open the input stream once. All other gif vs static image checks will + // only have to do a string comparison which is much cheaper. + final boolean isGif = ImageUtils.isGif(contentType, part.getContentUri()); + contentType = isGif ? ContentType.IMAGE_GIF : contentType; + srcName = String.format(isGif ? "image%06d.gif" : "image%06d.jpg", index); + smilBody.append(String.format(sSmilImagePart, srcName)); + totalLength += addPicturePart(context, pb, index, part, + widthLimit, heightLimit, bytesPerImage, srcName, contentType); + hasVisualAttachment = true; + } else if (ContentType.isVideoType(contentType)) { + srcName = String.format("video%06d.mp4", index); + final int length = addVideoPart(context, pb, part, srcName); + totalLength += length; + smilBody.append(String.format(sSmilVideoPart, srcName, + getMediaDurationMs(context, part, DEFAULT_DURATION))); + hasVisualAttachment = true; + } else if (ContentType.isVCardType(contentType)) { + srcName = String.format("contact%06d.vcf", index); + totalLength += addVCardPart(context, pb, part, srcName); + smilBody.append(String.format(sSmilPart, srcName)); + hasNonVisualAttachment = true; + } else if (ContentType.isAudioType(contentType)) { + srcName = String.format("recording%06d.amr", index); + totalLength += addOtherPart(context, pb, part, srcName); + final int duration = getMediaDurationMs(context, part, -1); + Assert.isTrue(duration != -1); + smilBody.append(String.format(sSmilAudioPart, srcName, duration)); + hasNonVisualAttachment = true; + } else { + srcName = String.format("other%06d.dat", index); + totalLength += addOtherPart(context, pb, part, srcName); + smilBody.append(String.format(sSmilPart, srcName)); + } + index++; + } + if (!TextUtils.isEmpty(part.getText())) { + hasText = true; + } + } + + if (hasText) { + final String srcName = String.format("text.%06d.txt", index); + final String text = message.getMessageText(); + totalLength += addTextPart(context, pb, text, srcName); + + // Append appropriate SMIL to the body. + smilBody.append(String.format(sSmilTextPart, srcName)); + } + + final String smilTemplate = getSmilTemplate(hasVisualAttachment, + hasNonVisualAttachment, hasText); + addSmilPart(pb, smilTemplate, smilBody.toString()); + + final MmsInfo mmsInfo = new MmsInfo(); + mmsInfo.mPduBody = pb; + mmsInfo.mMessageSize = totalLength; + + return mmsInfo; + } + + private static int getMediaDurationMs(final Context context, final MessagePartData part, + final int defaultDurationMs) { + Assert.notNull(context); + Assert.notNull(part); + Assert.isTrue(ContentType.isAudioType(part.getContentType()) || + ContentType.isVideoType(part.getContentType())); + + final MediaMetadataRetrieverWrapper retriever = new MediaMetadataRetrieverWrapper(); + try { + retriever.setDataSource(part.getContentUri()); + return retriever.extractInteger( + MediaMetadataRetriever.METADATA_KEY_DURATION, defaultDurationMs); + } catch (final IOException e) { + LogUtil.i(LogUtil.BUGLE_TAG, "Error extracting duration from " + part.getContentUri(), e); + return defaultDurationMs; + } finally { + retriever.release(); + } + } + + private static void setPartContentLocationAndId(final PduPart part, final String srcName) { + // Set Content-Location. + part.setContentLocation(srcName.getBytes()); + + // Set Content-Id. + final int index = srcName.lastIndexOf("."); + final String contentId = (index == -1) ? srcName : srcName.substring(0, index); + part.setContentId(contentId.getBytes()); + } + + private static int addTextPart(final Context context, final PduBody pb, + final String text, final String srcName) { + final PduPart part = new PduPart(); + + // Set Charset if it's a text media. + part.setCharset(CharacterSets.UTF_8); + + // Set Content-Type. + part.setContentType(ContentType.TEXT_PLAIN.getBytes()); + + // Set Content-Location. + setPartContentLocationAndId(part, srcName); + + part.setData(text.getBytes()); + + pb.addPart(part); + + return part.getData().length; + } + + private static int addPicturePart(final Context context, final PduBody pb, final int index, + final MessagePartData messagePart, int widthLimit, int heightLimit, + final int maxPartSize, final String srcName, final String contentType) { + final Uri imageUri = messagePart.getContentUri(); + final int width = messagePart.getWidth(); + final int height = messagePart.getHeight(); + + // Swap the width and height limits to match the orientation of the image so we scale the + // picture as little as possible. + if ((height > width) != (heightLimit > widthLimit)) { + final int temp = widthLimit; + widthLimit = heightLimit; + heightLimit = temp; + } + + final int orientation = ImageUtils.getOrientation(context, imageUri); + int imageSize = getDataLength(context, imageUri); + if (imageSize <= 0) { + LogUtil.e(TAG, "Can't get image", new Exception()); + return 0; + } + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "addPicturePart size: " + imageSize + " width: " + + width + " widthLimit: " + widthLimit + + " height: " + height + + " heightLimit: " + heightLimit); + } + + PduPart part; + // Check if we're already within the limits - in which case we don't need to resize. + // The size can be zero here, even when the media has content. See the comment in + // MediaModel.initMediaSize. Sometimes it'll compute zero and it's costly to read the + // whole stream to compute the size. When we call getResizedImageAsPart(), we'll correctly + // set the size. + if (imageSize <= maxPartSize && + width <= widthLimit && + height <= heightLimit && + (orientation == android.media.ExifInterface.ORIENTATION_UNDEFINED || + orientation == android.media.ExifInterface.ORIENTATION_NORMAL)) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "addPicturePart - already sized"); + } + part = new PduPart(); + part.setDataUri(imageUri); + part.setContentType(contentType.getBytes()); + } else { + part = getResizedImageAsPart(widthLimit, heightLimit, maxPartSize, + width, height, orientation, imageUri, context, contentType); + if (part == null) { + final OutOfMemoryError e = new OutOfMemoryError(); + LogUtil.e(TAG, "Can't resize image: not enough memory?", e); + throw e; + } + imageSize = part.getData().length; + } + + setPartContentLocationAndId(part, srcName); + + pb.addPart(index, part); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "addPicturePart size: " + imageSize); + } + + return imageSize; + } + + private static void addPartForUri(final Context context, final PduBody pb, + final String srcName, final Uri uri, final String contentType) { + final PduPart part = new PduPart(); + part.setDataUri(uri); + part.setContentType(contentType.getBytes()); + + setPartContentLocationAndId(part, srcName); + + pb.addPart(part); + } + + private static int addVCardPart(final Context context, final PduBody pb, + final MessagePartData messagePart, final String srcName) { + final Uri vcardUri = messagePart.getContentUri(); + final String contentType = messagePart.getContentType(); + final int vcardSize = getDataLength(context, vcardUri); + if (vcardSize <= 0) { + LogUtil.e(TAG, "Can't get vcard", new Exception()); + return 0; + } + + addPartForUri(context, pb, srcName, vcardUri, contentType); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "addVCardPart size: " + vcardSize); + } + + return vcardSize; + } + + /** + * Add video part recompressing video if necessary. If recompression fails, part is not + * added. + */ + private static int addVideoPart(final Context context, final PduBody pb, + final MessagePartData messagePart, final String srcName) { + final Uri attachmentUri = messagePart.getContentUri(); + String contentType = messagePart.getContentType(); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "addPart attachmentUrl: " + attachmentUri.toString()); + } + + if (TextUtils.isEmpty(contentType)) { + contentType = ContentType.VIDEO_3G2; + } + + addPartForUri(context, pb, srcName, attachmentUri, contentType); + return (int) getMediaFileSize(attachmentUri); + } + + private static int addOtherPart(final Context context, final PduBody pb, + final MessagePartData messagePart, final String srcName) { + final Uri attachmentUri = messagePart.getContentUri(); + final String contentType = messagePart.getContentType(); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "addPart attachmentUrl: " + attachmentUri.toString()); + } + + final int dataSize = (int) getMediaFileSize(attachmentUri); + + addPartForUri(context, pb, srcName, attachmentUri, contentType); + + return dataSize; + } + + private static void addSmilPart(final PduBody pb, final String smilTemplate, + final String smilBody) { + final PduPart smilPart = new PduPart(); + smilPart.setContentId("smil".getBytes()); + smilPart.setContentLocation("smil.xml".getBytes()); + smilPart.setContentType(ContentType.APP_SMIL.getBytes()); + final String smil = String.format(smilTemplate, smilBody); + smilPart.setData(smil.getBytes()); + pb.addPart(0, smilPart); + } + + private static String getSmilTemplate(final boolean hasVisualAttachments, + final boolean hasNonVisualAttachments, final boolean hasText) { + if (hasVisualAttachments) { + return hasText ? sSmilVisualAttachmentsWithText : sSmilVisualAttachmentsOnly; + } + if (hasNonVisualAttachments) { + return hasText ? sSmilNonVisualAttachmentsWithText : sSmilNonVisualAttachmentsOnly; + } + return sSmilTextOnly; + } + + private static int getDataLength(final Context context, final Uri uri) { + InputStream is = null; + try { + is = context.getContentResolver().openInputStream(uri); + try { + return is == null ? 0 : is.available(); + } catch (final IOException e) { + LogUtil.e(TAG, "getDataLength couldn't stream: " + uri, e); + } + } catch (final FileNotFoundException e) { + LogUtil.e(TAG, "getDataLength couldn't open: " + uri, e); + } finally { + if (is != null) { + try { + is.close(); + } catch (final IOException e) { + LogUtil.e(TAG, "getDataLength couldn't close: " + uri, e); + } + } + } + return 0; + } + + /** + * Returns {@code true} if group mms is turned on, + * {@code false} otherwise. + * + * For the group mms feature to be enabled, the following must be true: + * 1. the feature is enabled in mms_config.xml (currently on by default) + * 2. the feature is enabled in the SMS settings page + * + * @return true if group mms is supported + */ + public static boolean groupMmsEnabled(final int subId) { + final Context context = Factory.get().getApplicationContext(); + final Resources resources = context.getResources(); + final BuglePrefs prefs = BuglePrefs.getSubscriptionPrefs(subId); + final String groupMmsKey = resources.getString(R.string.group_mms_pref_key); + final boolean groupMmsEnabledDefault = resources.getBoolean(R.bool.group_mms_pref_default); + final boolean groupMmsPrefOn = prefs.getBoolean(groupMmsKey, groupMmsEnabledDefault); + return MmsConfig.get(subId).getGroupMmsEnabled() && groupMmsPrefOn; + } + + /** + * Get a version of this image resized to fit the given dimension and byte-size limits. Note + * that the content type of the resulting PduPart may not be the same as the content type of + * this UriImage; always call {@link PduPart#getContentType()} to get the new content type. + * + * @param widthLimit The width limit, in pixels + * @param heightLimit The height limit, in pixels + * @param byteLimit The binary size limit, in bytes + * @param width The image width, in pixels + * @param height The image height, in pixels + * @param orientation Orientation constant from ExifInterface for rotating or flipping the + * image + * @param imageUri Uri to the image data + * @param context Needed to open the image + * @return A new PduPart containing the resized image data + */ + private static PduPart getResizedImageAsPart(final int widthLimit, + final int heightLimit, final int byteLimit, final int width, final int height, + final int orientation, final Uri imageUri, final Context context, final String contentType) { + final PduPart part = new PduPart(); + + final byte[] data = ImageResizer.getResizedImageData(width, height, orientation, + widthLimit, heightLimit, byteLimit, imageUri, context, contentType); + if (data == null) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Resize image failed."); + } + return null; + } + + part.setData(data); + // Any static images will be compressed into a jpeg + final String contentTypeOfResizedImage = ImageUtils.isGif(contentType, imageUri) + ? ContentType.IMAGE_GIF : ContentType.IMAGE_JPEG; + part.setContentType(contentTypeOfResizedImage.getBytes()); + + return part; + } + + /** + * Get media file size + */ + public static long getMediaFileSize(final Uri uri) { + final Context context = Factory.get().getApplicationContext(); + AssetFileDescriptor fd = null; + try { + fd = context.getContentResolver().openAssetFileDescriptor(uri, "r"); + if (fd != null) { + return fd.getParcelFileDescriptor().getStatSize(); + } + } catch (final FileNotFoundException e) { + LogUtil.e(TAG, "MmsUtils.getMediaFileSize: cound not find media file: " + e, e); + } finally { + if (fd != null) { + try { + fd.close(); + } catch (final IOException e) { + LogUtil.e(TAG, "MmsUtils.getMediaFileSize: failed to close " + e, e); + } + } + } + return 0L; + } + + // Code for extracting the actual phone numbers for the participants in a conversation, + // given a thread id. + + private static final Uri ALL_THREADS_URI = + Threads.CONTENT_URI.buildUpon().appendQueryParameter("simple", "true").build(); + + private static final String[] RECIPIENTS_PROJECTION = { + Threads._ID, + Threads.RECIPIENT_IDS + }; + + private static final int RECIPIENT_IDS = 1; + + public static List getRecipientsByThread(final long threadId) { + final String spaceSepIds = getRawRecipientIdsForThread(threadId); + if (!TextUtils.isEmpty(spaceSepIds)) { + final Context context = Factory.get().getApplicationContext(); + return getAddresses(context, spaceSepIds); + } + return null; + } + + // NOTE: There are phones on which you can't get the recipients from the thread id for SMS + // until you have a message in the conversation! + public static String getRawRecipientIdsForThread(final long threadId) { + if (threadId <= 0) { + return null; + } + final Context context = Factory.get().getApplicationContext(); + final ContentResolver cr = context.getContentResolver(); + final Cursor thread = cr.query( + ALL_THREADS_URI, + RECIPIENTS_PROJECTION, "_id=?", new String[] { String.valueOf(threadId) }, null); + if (thread != null) { + try { + if (thread.moveToFirst()) { + // recipientIds will be a space-separated list of ids into the + // canonical addresses table. + return thread.getString(RECIPIENT_IDS); + } + } finally { + thread.close(); + } + } + return null; + } + + private static final Uri SINGLE_CANONICAL_ADDRESS_URI = + Uri.parse("content://mms-sms/canonical-address"); + + private static List getAddresses(final Context context, final String spaceSepIds) { + final List numbers = new ArrayList(); + final String[] ids = spaceSepIds.split(" "); + for (final String id : ids) { + long longId; + + try { + longId = Long.parseLong(id); + if (longId < 0) { + LogUtil.e(TAG, "MmsUtils.getAddresses: invalid id " + longId); + continue; + } + } catch (final NumberFormatException ex) { + LogUtil.e(TAG, "MmsUtils.getAddresses: invalid id. " + ex, ex); + // skip this id + continue; + } + + // TODO: build a single query where we get all the addresses at once. + Cursor c = null; + try { + c = context.getContentResolver().query( + ContentUris.withAppendedId(SINGLE_CANONICAL_ADDRESS_URI, longId), + null, null, null, null); + } catch (final Exception e) { + LogUtil.e(TAG, "MmsUtils.getAddresses: query failed for id " + longId, e); + } + if (c != null) { + try { + if (c.moveToFirst()) { + final String number = c.getString(0); + if (!TextUtils.isEmpty(number)) { + numbers.add(number); + } else { + LogUtil.w(TAG, "Canonical MMS/SMS address is empty for id: " + longId); + } + } + } finally { + c.close(); + } + } + } + if (numbers.isEmpty()) { + LogUtil.w(TAG, "No MMS addresses found from ids string [" + spaceSepIds + "]"); + } + return numbers; + } + + // Get telephony SMS thread ID + public static long getOrCreateSmsThreadId(final Context context, final String dest) { + // use destinations to determine threadId + final Set recipients = new HashSet(); + recipients.add(dest); + try { + return MmsSmsUtils.Threads.getOrCreateThreadId(context, recipients); + } catch (final IllegalArgumentException e) { + LogUtil.e(TAG, "MmsUtils: getting thread id failed: " + e); + return -1; + } + } + + // Get telephony SMS thread ID + public static long getOrCreateThreadId(final Context context, final List dests) { + if (dests == null || dests.size() == 0) { + return -1; + } + // use destinations to determine threadId + final Set recipients = new HashSet(dests); + try { + return MmsSmsUtils.Threads.getOrCreateThreadId(context, recipients); + } catch (final IllegalArgumentException e) { + LogUtil.e(TAG, "MmsUtils: getting thread id failed: " + e); + return -1; + } + } + + /** + * Add an SMS to the given URI with thread_id specified. + * + * @param resolver the content resolver to use + * @param uri the URI to add the message to + * @param subId subId for the receiving sim + * @param address the address of the sender + * @param body the body of the message + * @param subject the psuedo-subject of the message + * @param date the timestamp for the message + * @param read true if the message has been read, false if not + * @param threadId the thread_id of the message + * @return the URI for the new message + */ + private static Uri addMessageToUri(final ContentResolver resolver, + final Uri uri, final int subId, final String address, final String body, + final String subject, final Long date, final boolean read, final boolean seen, + final int status, final int type, final long threadId) { + final ContentValues values = new ContentValues(7); + + values.put(Telephony.Sms.ADDRESS, address); + if (date != null) { + values.put(Telephony.Sms.DATE, date); + } + values.put(Telephony.Sms.READ, read ? 1 : 0); + values.put(Telephony.Sms.SEEN, seen ? 1 : 0); + values.put(Telephony.Sms.SUBJECT, subject); + values.put(Telephony.Sms.BODY, body); + if (OsUtil.isAtLeastL_MR1()) { + values.put(Telephony.Sms.SUBSCRIPTION_ID, subId); + } + if (status != Telephony.Sms.STATUS_NONE) { + values.put(Telephony.Sms.STATUS, status); + } + if (type != Telephony.Sms.MESSAGE_TYPE_ALL) { + values.put(Telephony.Sms.TYPE, type); + } + if (threadId != -1L) { + values.put(Telephony.Sms.THREAD_ID, threadId); + } + return resolver.insert(uri, values); + } + + // Insert an SMS message to telephony + public static Uri insertSmsMessage(final Context context, final Uri uri, final int subId, + final String dest, final String text, final long timestamp, final int status, + final int type, final long threadId) { + Uri response = null; + try { + response = addMessageToUri(context.getContentResolver(), uri, subId, dest, + text, null /* subject */, timestamp, true /* read */, + true /* seen */, status, type, threadId); + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "Mmsutils: Inserted SMS message into telephony (type = " + type + ")" + + ", uri: " + response); + } + } catch (final SQLiteException e) { + LogUtil.e(TAG, "MmsUtils: persist sms message failure " + e, e); + } catch (final IllegalArgumentException e) { + LogUtil.e(TAG, "MmsUtils: persist sms message failure " + e, e); + } + return response; + } + + // Update SMS message type in telephony; returns true if it succeeded. + public static boolean updateSmsMessageSendingStatus(final Context context, final Uri uri, + final int type, final long date) { + try { + final ContentResolver resolver = context.getContentResolver(); + final ContentValues values = new ContentValues(2); + + values.put(Telephony.Sms.TYPE, type); + values.put(Telephony.Sms.DATE, date); + final int cnt = resolver.update(uri, values, null, null); + if (cnt == 1) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "Mmsutils: Updated sending SMS " + uri + "; type = " + type + + ", date = " + date + " (millis since epoch)"); + } + return true; + } + } catch (final SQLiteException e) { + LogUtil.e(TAG, "MmsUtils: update sms message failure " + e, e); + } catch (final IllegalArgumentException e) { + LogUtil.e(TAG, "MmsUtils: update sms message failure " + e, e); + } + return false; + } + + // Persist a sent MMS message in telephony + private static Uri insertSendReq(final Context context, final GenericPdu pdu, final int subId, + final String subPhoneNumber) { + final PduPersister persister = PduPersister.getPduPersister(context); + Uri uri = null; + try { + // Persist the PDU + uri = persister.persist( + pdu, + Mms.Sent.CONTENT_URI, + subId, + subPhoneNumber, + null/*preOpenedFiles*/); + // Update mms table to reflect sent messages are always seen and read + final ContentValues values = new ContentValues(1); + values.put(Mms.READ, 1); + values.put(Mms.SEEN, 1); + SqliteWrapper.update(context, context.getContentResolver(), uri, values, null, null); + } catch (final MmsException e) { + LogUtil.e(TAG, "MmsUtils: persist mms sent message failure " + e, e); + } + return uri; + } + + // Persist a received MMS message in telephony + public static Uri insertReceivedMmsMessage(final Context context, + final RetrieveConf retrieveConf, final int subId, final String subPhoneNumber, + final long receivedTimestampInSeconds, final String contentLocation) { + final PduPersister persister = PduPersister.getPduPersister(context); + Uri uri = null; + try { + uri = persister.persist( + retrieveConf, + Mms.Inbox.CONTENT_URI, + subId, + subPhoneNumber, + null/*preOpenedFiles*/); + + final ContentValues values = new ContentValues(2); + // Update mms table with local time instead of PDU time + values.put(Mms.DATE, receivedTimestampInSeconds); + // Also update the content location field from NotificationInd so that + // wap push dedup would work even after the wap push is deleted + values.put(Mms.CONTENT_LOCATION, contentLocation); + SqliteWrapper.update(context, context.getContentResolver(), uri, values, null, null); + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "MmsUtils: Inserted MMS message into telephony, uri: " + uri); + } + } catch (final MmsException e) { + LogUtil.e(TAG, "MmsUtils: persist mms received message failure " + e, e); + // Just returns empty uri to RetrieveMmsRequest, which triggers a permanent failure + } catch (final SQLiteException e) { + LogUtil.e(TAG, "MmsUtils: update mms received message failure " + e, e); + // Time update failure is ignored. + } + return uri; + } + + // Update MMS message type in telephony; returns true if it succeeded. + public static boolean updateMmsMessageSendingStatus(final Context context, final Uri uri, + final int box, final long timestampInMillis) { + try { + final ContentResolver resolver = context.getContentResolver(); + final ContentValues values = new ContentValues(); + + final long timestampInSeconds = timestampInMillis / 1000L; + values.put(Telephony.Mms.MESSAGE_BOX, box); + values.put(Telephony.Mms.DATE, timestampInSeconds); + final int cnt = resolver.update(uri, values, null, null); + if (cnt == 1) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "Mmsutils: Updated sending MMS " + uri + "; box = " + box + + ", date = " + timestampInSeconds + " (secs since epoch)"); + } + return true; + } + } catch (final SQLiteException e) { + LogUtil.e(TAG, "MmsUtils: update mms message failure " + e, e); + } catch (final IllegalArgumentException e) { + LogUtil.e(TAG, "MmsUtils: update mms message failure " + e, e); + } + return false; + } + + /** + * Parse values from a received sms message + * + * @param context + * @param msgs The received sms message content + * @param error The received sms error + * @return Parsed values from the message + */ + public static ContentValues parseReceivedSmsMessage( + final Context context, final SmsMessage[] msgs, final int error) { + final SmsMessage sms = msgs[0]; + final ContentValues values = new ContentValues(); + + values.put(Sms.ADDRESS, sms.getDisplayOriginatingAddress()); + values.put(Sms.BODY, buildMessageBodyFromPdus(msgs)); + if (MmsUtils.hasSmsDateSentColumn()) { + // TODO:: The boxing here seems unnecessary. + values.put(Sms.DATE_SENT, Long.valueOf(sms.getTimestampMillis())); + } + values.put(Sms.PROTOCOL, sms.getProtocolIdentifier()); + if (sms.getPseudoSubject().length() > 0) { + values.put(Sms.SUBJECT, sms.getPseudoSubject()); + } + values.put(Sms.REPLY_PATH_PRESENT, sms.isReplyPathPresent() ? 1 : 0); + values.put(Sms.SERVICE_CENTER, sms.getServiceCenterAddress()); + // Error code + values.put(Sms.ERROR_CODE, error); + + return values; + } + + // Some providers send formfeeds in their messages. Convert those formfeeds to newlines. + private static String replaceFormFeeds(final String s) { + return s == null ? "" : s.replace('\f', '\n'); + } + + // Parse the message body from message PDUs + private static String buildMessageBodyFromPdus(final SmsMessage[] msgs) { + if (msgs.length == 1) { + // There is only one part, so grab the body directly. + return replaceFormFeeds(msgs[0].getDisplayMessageBody()); + } else { + // Build up the body from the parts. + final StringBuilder body = new StringBuilder(); + for (final SmsMessage msg : msgs) { + try { + // getDisplayMessageBody() can NPE if mWrappedMessage inside is null. + body.append(msg.getDisplayMessageBody()); + } catch (final NullPointerException e) { + // Nothing to do + } + } + return replaceFormFeeds(body.toString()); + } + } + + // Parse the message date + public static Long getMessageDate(final SmsMessage sms, long now) { + // Use now for the timestamp to avoid confusion with clock + // drift between the handset and the SMSC. + // Check to make sure the system is giving us a non-bogus time. + final Calendar buildDate = new GregorianCalendar(2011, 8, 18); // 18 Sep 2011 + final Calendar nowDate = new GregorianCalendar(); + nowDate.setTimeInMillis(now); + if (nowDate.before(buildDate)) { + // It looks like our system clock isn't set yet because the current time right now + // is before an arbitrary time we made this build. Instead of inserting a bogus + // receive time in this case, use the timestamp of when the message was sent. + now = sms.getTimestampMillis(); + } + return now; + } + + /** + * cleanseMmsSubject will take a subject that's says, "", and return + * a null string. Otherwise it will return the original subject string. + * @param resources So the function can grab string resources + * @param subject the raw subject + * @return + */ + public static String cleanseMmsSubject(final Resources resources, final String subject) { + if (TextUtils.isEmpty(subject)) { + return null; + } + if (sNoSubjectStrings == null) { + sNoSubjectStrings = + resources.getStringArray(R.array.empty_subject_strings); + } + for (final String noSubjectString : sNoSubjectStrings) { + if (subject.equalsIgnoreCase(noSubjectString)) { + return null; + } + } + return subject; + } + + // return a semicolon separated list of phone numbers from a smsto: uri. + public static String getSmsRecipients(final Uri uri) { + String recipients = uri.getSchemeSpecificPart(); + final int pos = recipients.indexOf('?'); + if (pos != -1) { + recipients = recipients.substring(0, pos); + } + recipients = replaceUnicodeDigits(recipients).replace(',', ';'); + return recipients; + } + + // This function was lifted from Telephony.PhoneNumberUtils because it was @hide + /** + * Replace arabic/unicode digits with decimal digits. + * @param number + * the number to be normalized. + * @return the replaced number. + */ + private static String replaceUnicodeDigits(final String number) { + final StringBuilder normalizedDigits = new StringBuilder(number.length()); + for (final char c : number.toCharArray()) { + final int digit = Character.digit(c, 10); + if (digit != -1) { + normalizedDigits.append(digit); + } else { + normalizedDigits.append(c); + } + } + return normalizedDigits.toString(); + } + + /** + * @return Whether the data roaming is enabled + */ + private static boolean isDataRoamingEnabled() { + boolean dataRoamingEnabled = false; + final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver(); + if (OsUtil.isAtLeastJB_MR1()) { + dataRoamingEnabled = (Settings.Global.getInt(cr, Settings.Global.DATA_ROAMING, 0) != 0); + } else { + dataRoamingEnabled = (Settings.System.getInt(cr, Settings.System.DATA_ROAMING, 0) != 0); + } + return dataRoamingEnabled; + } + + /** + * @return Whether to auto retrieve MMS + */ + public static boolean allowMmsAutoRetrieve(final int subId) { + final Context context = Factory.get().getApplicationContext(); + final Resources resources = context.getResources(); + final BuglePrefs prefs = BuglePrefs.getSubscriptionPrefs(subId); + final boolean autoRetrieve = prefs.getBoolean( + resources.getString(R.string.auto_retrieve_mms_pref_key), + resources.getBoolean(R.bool.auto_retrieve_mms_pref_default)); + if (autoRetrieve) { + final boolean autoRetrieveInRoaming = prefs.getBoolean( + resources.getString(R.string.auto_retrieve_mms_when_roaming_pref_key), + resources.getBoolean(R.bool.auto_retrieve_mms_when_roaming_pref_default)); + final PhoneUtils phoneUtils = PhoneUtils.get(subId); + if ((autoRetrieveInRoaming && phoneUtils.isDataRoamingEnabled()) + || !phoneUtils.isRoaming()) { + return true; + } + } + return false; + } + + /** + * Parse the message row id from a message Uri. + * + * @param messageUri The input Uri + * @return The message row id if valid, otherwise -1 + */ + public static long parseRowIdFromMessageUri(final Uri messageUri) { + try { + if (messageUri != null) { + return ContentUris.parseId(messageUri); + } + } catch (final UnsupportedOperationException e) { + // Nothing to do + } catch (final NumberFormatException e) { + // Nothing to do + } + return -1; + } + + public static SmsMessage getSmsMessageFromDeliveryReport(final Intent intent) { + final byte[] pdu = intent.getByteArrayExtra("pdu"); + return SmsMessage.createFromPdu(pdu); + } + + /** + * Update the status and date_sent column of sms message in telephony provider + * + * @param smsMessageUri + * @param status + * @param timeSentInMillis + */ + public static void updateSmsStatusAndDateSent(final Uri smsMessageUri, final int status, + final long timeSentInMillis) { + if (smsMessageUri == null) { + return; + } + final ContentValues values = new ContentValues(); + values.put(Sms.STATUS, status); + if (MmsUtils.hasSmsDateSentColumn()) { + values.put(Sms.DATE_SENT, timeSentInMillis); + } + final ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver(); + resolver.update(smsMessageUri, values, null/*where*/, null/*selectionArgs*/); + } + + /** + * Get the SQL selection statement for matching messages with media. + * + * Example for MMS part table: + * "((ct LIKE 'image/%') + * OR (ct LIKE 'video/%') + * OR (ct LIKE 'audio/%') + * OR (ct='application/ogg')) + * + * @param contentTypeColumn The content-type column name + * @return The SQL selection statement for matching media types: image, video, audio + */ + public static String getMediaTypeSelectionSql(final String contentTypeColumn) { + return String.format( + Locale.US, + "((%s LIKE '%s') OR (%s LIKE '%s') OR (%s LIKE '%s') OR (%s='%s'))", + contentTypeColumn, + "image/%", + contentTypeColumn, + "video/%", + contentTypeColumn, + "audio/%", + contentTypeColumn, + ContentType.AUDIO_OGG); + } + + // Max number of operands per SQL query for deleting SMS messages + public static final int MAX_IDS_PER_QUERY = 128; + + /** + * Delete MMS messages with media parts. + * + * Because the telephony provider constraints, we can't use JOIN and delete messages in one + * shot. We have to do a query first and then batch delete the messages based on IDs. + * + * @return The count of messages deleted. + */ + public static int deleteMediaMessages() { + // Do a query first + // + // The WHERE clause has two parts: + // The first part is to select the exact same types of MMS messages as when we import them + // (so that we don't delete messages that are not in local database) + // The second part is to select MMS with media parts, including image, video and audio + final String selection = String.format( + Locale.US, + "%s AND (%s IN (SELECT %s FROM part WHERE %s))", + getMmsTypeSelectionSql(), + Mms._ID, + Mms.Part.MSG_ID, + getMediaTypeSelectionSql(Mms.Part.CONTENT_TYPE)); + final ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver(); + final Cursor cursor = resolver.query(Mms.CONTENT_URI, + new String[]{ Mms._ID }, + selection, + null/*selectionArgs*/, + null/*sortOrder*/); + int deleted = 0; + if (cursor != null) { + final long[] messageIds = new long[cursor.getCount()]; + try { + int i = 0; + while (cursor.moveToNext()) { + messageIds[i++] = cursor.getLong(0); + } + } finally { + cursor.close(); + } + final int totalIds = messageIds.length; + if (totalIds > 0) { + // Batch delete the messages using IDs + // We don't want to send all IDs at once since there is a limit on SQL statement + for (int start = 0; start < totalIds; start += MAX_IDS_PER_QUERY) { + final int end = Math.min(start + MAX_IDS_PER_QUERY, totalIds); // excluding + final int count = end - start; + final String batchSelection = String.format( + Locale.US, + "%s IN %s", + Mms._ID, + getSqlInOperand(count)); + final String[] batchSelectionArgs = + getSqlInOperandArgs(messageIds, start, count); + final int deletedForBatch = resolver.delete( + Mms.CONTENT_URI, + batchSelection, + batchSelectionArgs); + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "deleteMediaMessages: deleting IDs = " + + Joiner.on(',').skipNulls().join(batchSelectionArgs) + + ", deleted = " + deletedForBatch); + } + deleted += deletedForBatch; + } + } + } + return deleted; + } + + /** + * Get the (?,?,...) thing for the SQL IN operator by a count + * + * @param count + * @return + */ + public static String getSqlInOperand(final int count) { + if (count <= 0) { + return null; + } + final StringBuilder sb = new StringBuilder(); + sb.append("(?"); + for (int i = 0; i < count - 1; i++) { + sb.append(",?"); + } + sb.append(")"); + return sb.toString(); + } + + /** + * Get the args for SQL IN operator from a long ID array + * + * @param ids The original long id array + * @param start Start of the ids to fill the args + * @param count Number of ids to pack + * @return The long array with the id args + */ + private static String[] getSqlInOperandArgs( + final long[] ids, final int start, final int count) { + if (count <= 0) { + return null; + } + final String[] args = new String[count]; + for (int i = 0; i < count; i++) { + args[i] = Long.toString(ids[start + i]); + } + return args; + } + + /** + * Delete SMS and MMS messages that are earlier than a specific timestamp + * + * @param cutOffTimestampInMillis The cut-off timestamp + * @return Total number of messages deleted. + */ + public static int deleteMessagesOlderThan(final long cutOffTimestampInMillis) { + int deleted = 0; + final ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver(); + // Delete old SMS + final String smsSelection = String.format( + Locale.US, + "%s AND (%s<=%d)", + getSmsTypeSelectionSql(), + Sms.DATE, + cutOffTimestampInMillis); + deleted += resolver.delete(Sms.CONTENT_URI, smsSelection, null/*selectionArgs*/); + // Delete old MMS + final String mmsSelection = String.format( + Locale.US, + "%s AND (%s<=%d)", + getMmsTypeSelectionSql(), + Mms.DATE, + cutOffTimestampInMillis / 1000L); + deleted += resolver.delete(Mms.CONTENT_URI, mmsSelection, null/*selectionArgs*/); + return deleted; + } + + /** + * Update the read status of SMS/MMS messages by thread and timestamp + * + * @param threadId The thread of sms/mms to change + * @param timestampInMillis Change the status before this timestamp + */ + public static void updateSmsReadStatus(final long threadId, final long timestampInMillis) { + final ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver(); + final ContentValues values = new ContentValues(); + values.put("read", 1); + values.put("seen", 1); /* If you read it you saw it */ + final String smsSelection = String.format( + Locale.US, + "%s=%d AND %s<=%d AND %s=0", + Sms.THREAD_ID, + threadId, + Sms.DATE, + timestampInMillis, + Sms.READ); + resolver.update( + Sms.CONTENT_URI, + values, + smsSelection, + null/*selectionArgs*/); + final String mmsSelection = String.format( + Locale.US, + "%s=%d AND %s<=%d AND %s=0", + Mms.THREAD_ID, + threadId, + Mms.DATE, + timestampInMillis / 1000L, + Mms.READ); + resolver.update( + Mms.CONTENT_URI, + values, + mmsSelection, + null/*selectionArgs*/); + } + + /** + * Update the read status of a single MMS message by its URI + * + * @param mmsUri + * @param read + */ + public static void updateReadStatusForMmsMessage(final Uri mmsUri, final boolean read) { + final ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver(); + final ContentValues values = new ContentValues(); + values.put(Mms.READ, read ? 1 : 0); + resolver.update(mmsUri, values, null/*where*/, null/*selectionArgs*/); + } + + public static class AttachmentInfo { + public String mUrl; + public String mContentType; + public int mWidth; + public int mHeight; + } + + /** + * Convert byte array to Java String using a charset name + * + * @param bytes + * @param charsetName + * @return + */ + public static String bytesToString(final byte[] bytes, final String charsetName) { + if (bytes == null) { + return null; + } + try { + return new String(bytes, charsetName); + } catch (final UnsupportedEncodingException e) { + LogUtil.e(TAG, "MmsUtils.bytesToString: " + e, e); + return new String(bytes); + } + } + + /** + * Convert a Java String to byte array using a charset name + * + * @param string + * @param charsetName + * @return + */ + public static byte[] stringToBytes(final String string, final String charsetName) { + if (string == null) { + return null; + } + try { + return string.getBytes(charsetName); + } catch (final UnsupportedEncodingException e) { + LogUtil.e(TAG, "MmsUtils.stringToBytes: " + e, e); + return string.getBytes(); + } + } + + private static final String[] TEST_DATE_SENT_PROJECTION = new String[] { Sms.DATE_SENT }; + private static Boolean sHasSmsDateSentColumn = null; + /** + * Check if date_sent column exists on ICS and above devices. We need to do a test + * query to figure that out since on some ICS+ devices, somehow the date_sent column does + * not exist. http://b/17629135 tracks the associated compliance test. + * + * @return Whether "date_sent" column exists in sms table + */ + public static boolean hasSmsDateSentColumn() { + if (sHasSmsDateSentColumn == null) { + Cursor cursor = null; + try { + final Context context = Factory.get().getApplicationContext(); + final ContentResolver resolver = context.getContentResolver(); + cursor = SqliteWrapper.query( + context, + resolver, + Sms.CONTENT_URI, + TEST_DATE_SENT_PROJECTION, + null/*selection*/, + null/*selectionArgs*/, + Sms.DATE_SENT + " ASC LIMIT 1"); + sHasSmsDateSentColumn = true; + } catch (final SQLiteException e) { + LogUtil.w(TAG, "date_sent in sms table does not exist", e); + sHasSmsDateSentColumn = false; + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + return sHasSmsDateSentColumn; + } + + private static final String[] TEST_CARRIERS_PROJECTION = + new String[] { Telephony.Carriers.MMSC }; + private static Boolean sUseSystemApn = null; + /** + * Check if we can access the APN data in the Telephony provider. Access was restricted in + * JB MR1 (and some JB MR2) devices. If we can't access the APN, we have to fall back and use + * a private table in our own app. + * + * @return Whether we can access the system APN table + */ + public static boolean useSystemApnTable() { + if (sUseSystemApn == null) { + Cursor cursor = null; + try { + final Context context = Factory.get().getApplicationContext(); + final ContentResolver resolver = context.getContentResolver(); + cursor = SqliteWrapper.query( + context, + resolver, + Telephony.Carriers.CONTENT_URI, + TEST_CARRIERS_PROJECTION, + null/*selection*/, + null/*selectionArgs*/, + null); + sUseSystemApn = true; + } catch (final SecurityException e) { + LogUtil.w(TAG, "Can't access system APN, using internal table", e); + sUseSystemApn = false; + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + return sUseSystemApn; + } + + // For the internal debugger only + public static void setUseSystemApnTable(final boolean turnOn) { + if (!turnOn) { + // We're not turning on to the system table. Instead, we're using our internal table. + final int osVersion = OsUtil.getApiVersion(); + if (osVersion != android.os.Build.VERSION_CODES.JELLY_BEAN_MR1) { + // We're turning on local APNs on a device where we wouldn't normally have the + // local APN table. Build it here. + + final SQLiteDatabase database = ApnDatabase.getApnDatabase().getWritableDatabase(); + + // Do we already have the table? + Cursor cursor = null; + try { + cursor = database.query(ApnDatabase.APN_TABLE, + ApnDatabase.APN_PROJECTION, + null, null, null, null, null, null); + } catch (final Exception e) { + // Apparently there's no table, create it now. + ApnDatabase.forceBuildAndLoadApnTables(); + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + } + sUseSystemApn = turnOn; + } + + /** + * Checks if we should dump sms, based on both the setting and the global debug + * flag + * + * @return if dump sms is enabled + */ + public static boolean isDumpSmsEnabled() { + if (!DebugUtils.isDebugEnabled()) { + return false; + } + return getDumpSmsOrMmsPref(R.string.dump_sms_pref_key, R.bool.dump_sms_pref_default); + } + + /** + * Checks if we should dump mms, based on both the setting and the global debug + * flag + * + * @return if dump mms is enabled + */ + public static boolean isDumpMmsEnabled() { + if (!DebugUtils.isDebugEnabled()) { + return false; + } + return getDumpSmsOrMmsPref(R.string.dump_mms_pref_key, R.bool.dump_mms_pref_default); + } + + /** + * Load the value of dump sms or mms setting preference + */ + private static boolean getDumpSmsOrMmsPref(final int prefKeyRes, final int defaultKeyRes) { + final Context context = Factory.get().getApplicationContext(); + final Resources resources = context.getResources(); + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + final String key = resources.getString(prefKeyRes); + final boolean defaultValue = resources.getBoolean(defaultKeyRes); + return prefs.getBoolean(key, defaultValue); + } + + public static final Uri MMS_PART_CONTENT_URI = Uri.parse("content://mms/part"); + + /** + * Load MMS from telephony + * + * @param mmsUri The MMS pdu Uri + * @return A memory copy of the MMS pdu including parts (but not addresses) + */ + public static DatabaseMessages.MmsMessage loadMms(final Uri mmsUri) { + final Context context = Factory.get().getApplicationContext(); + final ContentResolver resolver = context.getContentResolver(); + DatabaseMessages.MmsMessage mms = null; + Cursor cursor = null; + // Load pdu first + try { + cursor = SqliteWrapper.query(context, resolver, + mmsUri, + DatabaseMessages.MmsMessage.getProjection(), + null/*selection*/, null/*selectionArgs*/, null/*sortOrder*/); + if (cursor != null && cursor.moveToFirst()) { + mms = DatabaseMessages.MmsMessage.get(cursor); + } + } catch (final SQLiteException e) { + LogUtil.e(TAG, "MmsLoader: query pdu failure: " + e, e); + } finally { + if (cursor != null) { + cursor.close(); + } + } + if (mms == null) { + return null; + } + // Load parts except SMIL + // TODO: we may need to load SMIL part in the future. + final long rowId = MmsUtils.parseRowIdFromMessageUri(mmsUri); + final String selection = String.format( + Locale.US, + "%s != '%s' AND %s = ?", + Mms.Part.CONTENT_TYPE, + ContentType.APP_SMIL, + Mms.Part.MSG_ID); + cursor = null; + try { + cursor = SqliteWrapper.query(context, resolver, + MMS_PART_CONTENT_URI, + DatabaseMessages.MmsPart.PROJECTION, + selection, + new String[] { Long.toString(rowId) }, + null/*sortOrder*/); + if (cursor != null) { + while (cursor.moveToNext()) { + mms.addPart(DatabaseMessages.MmsPart.get(cursor, true/*loadMedia*/)); + } + } + } catch (final SQLiteException e) { + LogUtil.e(TAG, "MmsLoader: query parts failure: " + e, e); + } finally { + if (cursor != null) { + cursor.close(); + } + } + return mms; + } + + /** + * Get the sender of an MMS message + * + * @param recipients The recipient list of the message + * @param mmsUri The pdu uri of the MMS + * @return The sender phone number of the MMS + */ + public static String getMmsSender(final List recipients, final String mmsUri) { + final Context context = Factory.get().getApplicationContext(); + // We try to avoid the database query. + // If this is a 1v1 conv., then the other party is the sender + if (recipients != null && recipients.size() == 1) { + return recipients.get(0); + } + // Otherwise, we have to query the MMS addr table for sender address + // This should only be done for a received group mms message + final Cursor cursor = SqliteWrapper.query( + context, + context.getContentResolver(), + Uri.withAppendedPath(Uri.parse(mmsUri), "addr"), + new String[] { Mms.Addr.ADDRESS, Mms.Addr.CHARSET }, + Mms.Addr.TYPE + "=" + PduHeaders.FROM, + null/*selectionArgs*/, + null/*sortOrder*/); + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + return DatabaseMessages.MmsAddr.get(cursor); + } + } finally { + cursor.close(); + } + } + return null; + } + + public static int bugleStatusForMms(final boolean isOutgoing, final boolean isNotification, + final int messageBox) { + int bugleStatus = MessageData.BUGLE_STATUS_UNKNOWN; + // For a message we sync either + if (isOutgoing) { + if (messageBox == Mms.MESSAGE_BOX_OUTBOX || messageBox == Mms.MESSAGE_BOX_FAILED) { + // Not sent counts as failed and available for manual resend + bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_FAILED; + } else { + // Otherwise outgoing message is complete + bugleStatus = MessageData.BUGLE_STATUS_OUTGOING_COMPLETE; + } + } else if (isNotification) { + // Incoming MMS notifications we sync count as failed and available for manual download + bugleStatus = MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD; + } else { + // Other incoming MMS messages are complete + bugleStatus = MessageData.BUGLE_STATUS_INCOMING_COMPLETE; + } + return bugleStatus; + } + + public static MessageData createMmsMessage(final DatabaseMessages.MmsMessage mms, + final String conversationId, final String participantId, final String selfId, + final int bugleStatus) { + Assert.notNull(mms); + final boolean isNotification = (mms.mMmsMessageType == + PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND); + final int rawMmsStatus = (bugleStatus < MessageData.BUGLE_STATUS_FIRST_INCOMING + ? mms.mRetrieveStatus : mms.mResponseStatus); + + final MessageData message = MessageData.createMmsMessage(mms.getUri(), + participantId, selfId, conversationId, isNotification, bugleStatus, + mms.mContentLocation, mms.mTransactionId, mms.mPriority, mms.mSubject, + mms.mSeen, mms.mRead, mms.getSize(), rawMmsStatus, + mms.mExpiryInMillis, mms.mSentTimestampInMillis, mms.mTimestampInMillis); + + for (final DatabaseMessages.MmsPart part : mms.mParts) { + final MessagePartData messagePart = MmsUtils.createMmsMessagePart(part); + // Import media and text parts (skip SMIL and others) + if (messagePart != null) { + message.addPart(messagePart); + } + } + + if (!message.getParts().iterator().hasNext()) { + message.addPart(MessagePartData.createEmptyMessagePart()); + } + + return message; + } + + public static MessagePartData createMmsMessagePart(final DatabaseMessages.MmsPart part) { + MessagePartData messagePart = null; + if (part.isText()) { + final int mmsTextLengthLimit = + BugleGservices.get().getInt(BugleGservicesKeys.MMS_TEXT_LIMIT, + BugleGservicesKeys.MMS_TEXT_LIMIT_DEFAULT); + String text = part.mText; + if (text != null && text.length() > mmsTextLengthLimit) { + // Limit the text to a reasonable value. We ran into a situation where a vcard + // with a photo was sent as plain text. The massive amount of text caused the + // app to hang, ANR, and eventually crash in native text code. + text = text.substring(0, mmsTextLengthLimit); + } + messagePart = MessagePartData.createTextMessagePart(text); + } else if (part.isMedia()) { + messagePart = MessagePartData.createMediaMessagePart(part.mContentType, + part.getDataUri(), MessagePartData.UNSPECIFIED_SIZE, + MessagePartData.UNSPECIFIED_SIZE); + } + return messagePart; + } + + public static class StatusPlusUri { + // The request status to be as the result of the operation + // e.g. MMS_REQUEST_MANUAL_RETRY + public final int status; + // The raw telephony status + public final int rawStatus; + // The raw telephony URI + public final Uri uri; + // The operation result code from system api invocation (sent by system) + // or mapped from internal exception (sent by app) + public final int resultCode; + + public StatusPlusUri(final int status, final int rawStatus, final Uri uri) { + this.status = status; + this.rawStatus = rawStatus; + this.uri = uri; + resultCode = MessageData.UNKNOWN_RESULT_CODE; + } + + public StatusPlusUri(final int status, final int rawStatus, final Uri uri, + final int resultCode) { + this.status = status; + this.rawStatus = rawStatus; + this.uri = uri; + this.resultCode = resultCode; + } + } + + public static class SendReqResp { + public SendReq mSendReq; + public SendConf mSendConf; + + public SendReqResp(final SendReq sendReq, final SendConf sendConf) { + mSendReq = sendReq; + mSendConf = sendConf; + } + } + + /** + * Returned when sending/downloading MMS via platform APIs. In that case, we have to wait to + * receive the pending intent to determine status. + */ + public static final StatusPlusUri STATUS_PENDING = new StatusPlusUri(-1, -1, null); + + public static StatusPlusUri downloadMmsMessage(final Context context, final Uri notificationUri, + final int subId, final String subPhoneNumber, final String transactionId, + final String contentLocation, final boolean autoDownload, + final long receivedTimestampInSeconds, Bundle extras) { + if (TextUtils.isEmpty(contentLocation)) { + LogUtil.e(TAG, "MmsUtils: Download from empty content location URL"); + return new StatusPlusUri( + MMS_REQUEST_NO_RETRY, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED, null); + } + if (!isMmsDataAvailable(subId)) { + LogUtil.e(TAG, + "MmsUtils: failed to download message, no data available"); + return new StatusPlusUri(MMS_REQUEST_MANUAL_RETRY, + MessageData.RAW_TELEPHONY_STATUS_UNDEFINED, + null, + SmsManager.MMS_ERROR_NO_DATA_NETWORK); + } + int status = MMS_REQUEST_MANUAL_RETRY; + try { + RetrieveConf retrieveConf = null; + if (DebugUtils.isDebugEnabled() && + MediaScratchFileProvider + .isMediaScratchSpaceUri(Uri.parse(contentLocation))) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "MmsUtils: Reading MMS from dump file: " + contentLocation); + } + final String fileName = Uri.parse(contentLocation).getPathSegments().get(1); + final byte[] data = DebugUtils.receiveFromDumpFile(fileName); + retrieveConf = receiveFromDumpFile(data); + } else { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "MmsUtils: Downloading MMS via MMS lib API; notification " + + "message: " + notificationUri); + } + if (OsUtil.isAtLeastL_MR1()) { + if (subId < 0) { + LogUtil.e(TAG, "MmsUtils: Incoming MMS came from unknown SIM"); + throw new MmsFailureException(MMS_REQUEST_NO_RETRY, + "Message from unknown SIM"); + } + } else { + Assert.isTrue(subId == ParticipantData.DEFAULT_SELF_SUB_ID); + } + if (extras == null) { + extras = new Bundle(); + } + extras.putParcelable(DownloadMmsAction.EXTRA_NOTIFICATION_URI, notificationUri); + extras.putInt(DownloadMmsAction.EXTRA_SUB_ID, subId); + extras.putString(DownloadMmsAction.EXTRA_SUB_PHONE_NUMBER, subPhoneNumber); + extras.putString(DownloadMmsAction.EXTRA_TRANSACTION_ID, transactionId); + extras.putString(DownloadMmsAction.EXTRA_CONTENT_LOCATION, contentLocation); + extras.putBoolean(DownloadMmsAction.EXTRA_AUTO_DOWNLOAD, autoDownload); + extras.putLong(DownloadMmsAction.EXTRA_RECEIVED_TIMESTAMP, + receivedTimestampInSeconds); + + MmsSender.downloadMms(context, subId, contentLocation, extras); + return STATUS_PENDING; // Download happens asynchronously; no status to return + } + return insertDownloadedMessageAndSendResponse(context, notificationUri, subId, + subPhoneNumber, transactionId, contentLocation, autoDownload, + receivedTimestampInSeconds, retrieveConf); + + } catch (final MmsFailureException e) { + LogUtil.e(TAG, "MmsUtils: failed to download message " + notificationUri, e); + status = e.retryHint; + } catch (final InvalidHeaderValueException e) { + LogUtil.e(TAG, "MmsUtils: failed to download message " + notificationUri, e); + } + return new StatusPlusUri(status, PDU_HEADER_VALUE_UNDEFINED, null); + } + + public static StatusPlusUri insertDownloadedMessageAndSendResponse(final Context context, + final Uri notificationUri, final int subId, final String subPhoneNumber, + final String transactionId, final String contentLocation, + final boolean autoDownload, final long receivedTimestampInSeconds, + final RetrieveConf retrieveConf) { + final byte[] transactionIdBytes = stringToBytes(transactionId, "UTF-8"); + Uri messageUri = null; + int status = MMS_REQUEST_MANUAL_RETRY; + int retrieveStatus = PDU_HEADER_VALUE_UNDEFINED; + + retrieveStatus = retrieveConf.getRetrieveStatus(); + if (retrieveStatus == PduHeaders.RETRIEVE_STATUS_OK) { + status = MMS_REQUEST_SUCCEEDED; + } else if (retrieveStatus >= PduHeaders.RETRIEVE_STATUS_ERROR_TRANSIENT_FAILURE && + retrieveStatus < PduHeaders.RETRIEVE_STATUS_ERROR_PERMANENT_FAILURE) { + status = MMS_REQUEST_AUTO_RETRY; + } else { + // else not meant to retry download + status = MMS_REQUEST_NO_RETRY; + LogUtil.e(TAG, "MmsUtils: failed to retrieve message; retrieveStatus: " + + retrieveStatus); + } + final ContentValues values = new ContentValues(1); + values.put(Mms.RETRIEVE_STATUS, retrieveConf.getRetrieveStatus()); + SqliteWrapper.update(context, context.getContentResolver(), + notificationUri, values, null, null); + + if (status == MMS_REQUEST_SUCCEEDED) { + // Send response of the notification + if (autoDownload) { + sendNotifyResponseForMmsDownload(context, subId, transactionIdBytes, + contentLocation, PduHeaders.STATUS_RETRIEVED); + } else { + sendAcknowledgeForMmsDownload(context, subId, transactionIdBytes, contentLocation); + } + + // Insert downloaded message into telephony + final Uri inboxUri = MmsUtils.insertReceivedMmsMessage(context, retrieveConf, subId, + subPhoneNumber, receivedTimestampInSeconds, contentLocation); + messageUri = ContentUris.withAppendedId(Mms.CONTENT_URI, ContentUris.parseId(inboxUri)); + } else if (status == MMS_REQUEST_AUTO_RETRY) { + // For a retry do nothing + } else if (status == MMS_REQUEST_MANUAL_RETRY && autoDownload) { + // Failure from autodownload - just treat like manual download + sendNotifyResponseForMmsDownload(context, subId, transactionIdBytes, + contentLocation, PduHeaders.STATUS_DEFERRED); + } + return new StatusPlusUri(status, retrieveStatus, messageUri); + } + + /** + * Send response for MMS download - catches and ignores errors + */ + public static void sendNotifyResponseForMmsDownload(final Context context, final int subId, + final byte[] transactionId, final String contentLocation, final int status) { + try { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "MmsUtils: Sending M-NotifyResp.ind for received MMS, status: " + + String.format("0x%X", status)); + } + if (contentLocation == null) { + LogUtil.w(TAG, "MmsUtils: Can't send NotifyResp; contentLocation is null"); + return; + } + if (transactionId == null) { + LogUtil.w(TAG, "MmsUtils: Can't send NotifyResp; transaction id is null"); + return; + } + if (!isMmsDataAvailable(subId)) { + LogUtil.w(TAG, "MmsUtils: Can't send NotifyResp; no data available"); + return; + } + MmsSender.sendNotifyResponseForMmsDownload( + context, subId, transactionId, contentLocation, status); + } catch (final MmsFailureException e) { + LogUtil.e(TAG, "sendNotifyResponseForMmsDownload: failed to retrieve message " + e, e); + } catch (final InvalidHeaderValueException e) { + LogUtil.e(TAG, "sendNotifyResponseForMmsDownload: failed to retrieve message " + e, e); + } + } + + /** + * Send acknowledge for mms download - catched and ignores errors + */ + public static void sendAcknowledgeForMmsDownload(final Context context, final int subId, + final byte[] transactionId, final String contentLocation) { + try { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "MmsUtils: Sending M-Acknowledge.ind for received MMS"); + } + if (contentLocation == null) { + LogUtil.w(TAG, "MmsUtils: Can't send AckInd; contentLocation is null"); + return; + } + if (transactionId == null) { + LogUtil.w(TAG, "MmsUtils: Can't send AckInd; transaction id is null"); + return; + } + if (!isMmsDataAvailable(subId)) { + LogUtil.w(TAG, "MmsUtils: Can't send AckInd; no data available"); + return; + } + MmsSender.sendAcknowledgeForMmsDownload(context, subId, transactionId, contentLocation); + } catch (final MmsFailureException e) { + LogUtil.e(TAG, "sendAcknowledgeForMmsDownload: failed to retrieve message " + e, e); + } catch (final InvalidHeaderValueException e) { + LogUtil.e(TAG, "sendAcknowledgeForMmsDownload: failed to retrieve message " + e, e); + } + } + + /** + * Try parsing a PDU without knowing the carrier. This is useful for importing + * MMS or storing draft when carrier info is not available + * + * @param data The PDU data + * @return Parsed PDU, null if failed to parse + */ + private static GenericPdu parsePduForAnyCarrier(final byte[] data) { + GenericPdu pdu = null; + try { + pdu = (new PduParser(data, true/*parseContentDisposition*/)).parse(); + } catch (final RuntimeException e) { + LogUtil.d(TAG, "parsePduForAnyCarrier: Failed to parse PDU with content disposition", + e); + } + if (pdu == null) { + try { + pdu = (new PduParser(data, false/*parseContentDisposition*/)).parse(); + } catch (final RuntimeException e) { + LogUtil.d(TAG, + "parsePduForAnyCarrier: Failed to parse PDU without content disposition", + e); + } + } + return pdu; + } + + private static RetrieveConf receiveFromDumpFile(final byte[] data) throws MmsFailureException { + final GenericPdu pdu = parsePduForAnyCarrier(data); + if (pdu == null || !(pdu instanceof RetrieveConf)) { + LogUtil.e(TAG, "receiveFromDumpFile: Parsing retrieved PDU failure"); + throw new MmsFailureException(MMS_REQUEST_MANUAL_RETRY, "Failed reading dump file"); + } + return (RetrieveConf) pdu; + } + + private static boolean isMmsDataAvailable(final int subId) { + if (OsUtil.isAtLeastL_MR1()) { + // L_MR1 above may support sending mms via wifi + return true; + } + final PhoneUtils phoneUtils = PhoneUtils.get(subId); + return !phoneUtils.isAirplaneModeOn() && phoneUtils.isMobileDataEnabled(); + } + + private static boolean isSmsDataAvailable(final int subId) { + if (OsUtil.isAtLeastL_MR1()) { + // L_MR1 above may support sending sms via wifi + return true; + } + final PhoneUtils phoneUtils = PhoneUtils.get(subId); + return !phoneUtils.isAirplaneModeOn(); + } + + public static boolean isMobileDataEnabled(final int subId) { + final PhoneUtils phoneUtils = PhoneUtils.get(subId); + return phoneUtils.isMobileDataEnabled(); + } + + public static boolean isAirplaneModeOn(final int subId) { + final PhoneUtils phoneUtils = PhoneUtils.get(subId); + return phoneUtils.isAirplaneModeOn(); + } + + public static StatusPlusUri sendMmsMessage(final Context context, final int subId, + final Uri messageUri, final Bundle extras) { + int status = MMS_REQUEST_MANUAL_RETRY; + int rawStatus = MessageData.RAW_TELEPHONY_STATUS_UNDEFINED; + if (!isMmsDataAvailable(subId)) { + LogUtil.w(TAG, "MmsUtils: failed to send message, no data available"); + return new StatusPlusUri(MMS_REQUEST_MANUAL_RETRY, + MessageData.RAW_TELEPHONY_STATUS_UNDEFINED, + messageUri, + SmsManager.MMS_ERROR_NO_DATA_NETWORK); + } + final PduPersister persister = PduPersister.getPduPersister(context); + try { + final SendReq sendReq = (SendReq) persister.load(messageUri); + if (sendReq == null) { + LogUtil.w(TAG, "MmsUtils: Sending MMS was deleted; uri = " + messageUri); + return new StatusPlusUri(MMS_REQUEST_NO_RETRY, + MessageData.RAW_TELEPHONY_STATUS_UNDEFINED, messageUri); + } + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, String.format("MmsUtils: Sending MMS, message uri: %s", messageUri)); + } + extras.putInt(SendMessageAction.KEY_SUB_ID, subId); + MmsSender.sendMms(context, subId, messageUri, sendReq, extras); + return STATUS_PENDING; + } catch (final MmsFailureException e) { + status = e.retryHint; + rawStatus = e.rawStatus; + LogUtil.e(TAG, "MmsUtils: failed to send message " + e, e); + } catch (final InvalidHeaderValueException e) { + LogUtil.e(TAG, "MmsUtils: failed to send message " + e, e); + } catch (final IllegalArgumentException e) { + LogUtil.e(TAG, "MmsUtils: invalid message to send " + e, e); + } catch (final MmsException e) { + LogUtil.e(TAG, "MmsUtils: failed to send message " + e, e); + } + // If we get here, some exception occurred + return new StatusPlusUri(status, rawStatus, messageUri); + } + + public static StatusPlusUri updateSentMmsMessageStatus(final Context context, + final Uri messageUri, final SendConf sendConf) { + int status = MMS_REQUEST_MANUAL_RETRY; + final int respStatus = sendConf.getResponseStatus(); + + final ContentValues values = new ContentValues(2); + values.put(Mms.RESPONSE_STATUS, respStatus); + final byte[] messageId = sendConf.getMessageId(); + if (messageId != null && messageId.length > 0) { + values.put(Mms.MESSAGE_ID, PduPersister.toIsoString(messageId)); + } + SqliteWrapper.update(context, context.getContentResolver(), + messageUri, values, null, null); + if (respStatus == PduHeaders.RESPONSE_STATUS_OK) { + status = MMS_REQUEST_SUCCEEDED; + } else if (respStatus == PduHeaders.RESPONSE_STATUS_ERROR_TRANSIENT_FAILURE || + respStatus == PduHeaders.RESPONSE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM || + respStatus == PduHeaders.RESPONSE_STATUS_ERROR_TRANSIENT_PARTIAL_SUCCESS) { + status = MMS_REQUEST_AUTO_RETRY; + } else { + // else permanent failure + LogUtil.e(TAG, "MmsUtils: failed to send message; respStatus = " + + String.format("0x%X", respStatus)); + } + return new StatusPlusUri(status, respStatus, messageUri); + } + + public static void clearMmsStatus(final Context context, final Uri uri) { + // Messaging application can leave invalid values in STATUS field of M-Notification.ind + // messages. Take this opportunity to clear it. + // Downloading status just kept in local db and not reflected into telephony. + final ContentValues values = new ContentValues(1); + values.putNull(Mms.STATUS); + SqliteWrapper.update(context, context.getContentResolver(), + uri, values, null, null); + } + + // Selection for new dedup algorithm: + // ((m_type<>130) OR (exp>NOW)) AND (date>NOW-7d) AND (date?) OR (" + Mms.EXPIRY + ">?)) AND (" + + Mms.DATE + ">?) AND (" + Mms.DATE + " 0) { + // New dedup algorithm + selection = DUP_NOTIFICATION_QUERY_SELECTION; + final long nowSecs = System.currentTimeMillis() / 1000; + final long timeLowerBoundSecs = nowSecs - timeLimit; + // Need upper bound to protect against clock change so that a message has a time + // stamp in the future + final long timeUpperBoundSecs = nowSecs + timeLimit; + selectionArgs = new String[] { + Integer.toString(PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND), + Long.toString(nowSecs), + Long.toString(timeLowerBoundSecs), + Long.toString(timeUpperBoundSecs), + location + }; + } else { + // If time limit is 0, we revert back to old behavior in case the new + // dedup algorithm behaves badly + selection = DUP_NOTIFICATION_QUERY_SELECTION_OLD; + selectionArgs = new String[] { + Integer.toString(PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND), + location + }; + } + Cursor cursor = null; + try { + cursor = SqliteWrapper.query( + context, context.getContentResolver(), + Mms.CONTENT_URI, new String[] { Mms._ID }, + selection, selectionArgs, null); + final int dupCount = cursor.getCount(); + if (dupCount > 0) { + // We already received the same notification before. + // Don't want to return too many dups. It is only for debugging. + final int returnCount = dupCount < MAX_RETURN ? dupCount : MAX_RETURN; + final String[] dups = new String[returnCount]; + for (int i = 0; cursor.moveToNext() && i < returnCount; i++) { + dups[i] = cursor.getString(0); + } + return dups; + } + } catch (final SQLiteException e) { + LogUtil.e(TAG, "query failure: " + e, e); + } finally { + cursor.close(); + } + } + return null; + } + + /** + * Try parse the address using RFC822 format. If it fails to parse, then return the + * original address + * + * @param address The MMS ind sender address to parse + * @return The real address. If in RFC822 format, returns the correct email. + */ + private static String parsePotentialRfc822EmailAddress(final String address) { + if (address == null || !address.contains("@") || !address.contains("<")) { + return address; + } + final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address); + if (tokens != null && tokens.length > 0) { + for (final Rfc822Token token : tokens) { + if (token != null && !TextUtils.isEmpty(token.getAddress())) { + return token.getAddress(); + } + } + } + return address; + } + + public static DatabaseMessages.MmsMessage processReceivedPdu(final Context context, + final byte[] pushData, final int subId, final String subPhoneNumber) { + // Parse data + + // Insert placeholder row to telephony and local db + // Get raw PDU push-data from the message and parse it + final PduParser parser = new PduParser(pushData, + MmsConfig.get(subId).getSupportMmsContentDisposition()); + final GenericPdu pdu = parser.parse(); + + if (null == pdu) { + LogUtil.e(TAG, "Invalid PUSH data"); + return null; + } + + final PduPersister p = PduPersister.getPduPersister(context); + final int type = pdu.getMessageType(); + + Uri messageUri = null; + switch (type) { + case PduHeaders.MESSAGE_TYPE_DELIVERY_IND: + case PduHeaders.MESSAGE_TYPE_READ_ORIG_IND: { + // TODO: Should this be commented out? +// threadId = findThreadId(context, pdu, type); +// if (threadId == -1) { +// // The associated SendReq isn't found, therefore skip +// // processing this PDU. +// break; +// } + +// Uri uri = p.persist(pdu, Inbox.CONTENT_URI, true, +// MessagingPreferenceActivity.getIsGroupMmsEnabled(mContext), null); +// // Update thread ID for ReadOrigInd & DeliveryInd. +// ContentValues values = new ContentValues(1); +// values.put(Mms.THREAD_ID, threadId); +// SqliteWrapper.update(mContext, cr, uri, values, null, null); + LogUtil.w(TAG, "Received unsupported WAP Push, type=" + type); + break; + } + case PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND: { + final NotificationInd nInd = (NotificationInd) pdu; + + if (MmsConfig.get(subId).getTransIdEnabled()) { + final byte [] contentLocationTemp = nInd.getContentLocation(); + if ('=' == contentLocationTemp[contentLocationTemp.length - 1]) { + final byte [] transactionIdTemp = nInd.getTransactionId(); + final byte [] contentLocationWithId = + new byte [contentLocationTemp.length + + transactionIdTemp.length]; + System.arraycopy(contentLocationTemp, 0, contentLocationWithId, + 0, contentLocationTemp.length); + System.arraycopy(transactionIdTemp, 0, contentLocationWithId, + contentLocationTemp.length, transactionIdTemp.length); + nInd.setContentLocation(contentLocationWithId); + } + } + final String[] dups = getDupNotifications(context, nInd); + if (dups == null) { + // TODO: Do we handle Rfc822 Email Addresses? + //final String contentLocation = + // MmsUtils.bytesToString(nInd.getContentLocation(), "UTF-8"); + //final byte[] transactionId = nInd.getTransactionId(); + //final long messageSize = nInd.getMessageSize(); + //final long expiry = nInd.getExpiry(); + //final String transactionIdString = + // MmsUtils.bytesToString(transactionId, "UTF-8"); + + //final EncodedStringValue fromEncoded = nInd.getFrom(); + // An mms ind received from email address will have from address shown as + // "John Doe " but the actual received message will only + // have the email address. So let's try to parse the RFC822 format to get the + // real email. Otherwise we will create two conversations for the MMS + // notification and the actual MMS message if auto retrieve is disabled. + //final String from = parsePotentialRfc822EmailAddress( + // fromEncoded != null ? fromEncoded.getString() : null); + + Uri inboxUri = null; + try { + inboxUri = p.persist(pdu, Mms.Inbox.CONTENT_URI, subId, subPhoneNumber, + null); + messageUri = ContentUris.withAppendedId(Mms.CONTENT_URI, + ContentUris.parseId(inboxUri)); + } catch (final MmsException e) { + LogUtil.e(TAG, "Failed to save the data from PUSH: type=" + type, e); + } + } else { + LogUtil.w(TAG, "Received WAP Push is a dup: " + Joiner.on(',').join(dups)); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.w(TAG, "Dup WAP Push url=" + new String(nInd.getContentLocation())); + } + } + break; + } + default: + LogUtil.e(TAG, "Received unrecognized WAP Push, type=" + type); + } + + DatabaseMessages.MmsMessage mms = null; + if (messageUri != null) { + mms = MmsUtils.loadMms(messageUri); + } + return mms; + } + + public static Uri insertSendingMmsMessage(final Context context, final List recipients, + final MessageData content, final int subId, final String subPhoneNumber, + final long timestamp) { + final SendReq sendReq = createMmsSendReq( + context, subId, recipients.toArray(new String[recipients.size()]), content, + DEFAULT_DELIVERY_REPORT_MODE, + DEFAULT_READ_REPORT_MODE, + DEFAULT_EXPIRY_TIME_IN_SECONDS, + DEFAULT_PRIORITY, + timestamp); + Uri messageUri = null; + if (sendReq != null) { + final Uri outboxUri = MmsUtils.insertSendReq(context, sendReq, subId, subPhoneNumber); + if (outboxUri != null) { + messageUri = ContentUris.withAppendedId(Telephony.Mms.CONTENT_URI, + ContentUris.parseId(outboxUri)); + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "Mmsutils: Inserted sending MMS message into telephony, uri: " + + outboxUri); + } + } else { + LogUtil.e(TAG, "insertSendingMmsMessage: failed to persist message into telephony"); + } + } + return messageUri; + } + + public static MessageData readSendingMmsMessage(final Uri messageUri, + final String conversationId, final String participantId, final String selfId) { + MessageData message = null; + if (messageUri != null) { + final DatabaseMessages.MmsMessage mms = MmsUtils.loadMms(messageUri); + + // Make sure that the message has not been deleted from the Telephony DB + if (mms != null) { + // Transform the message + message = MmsUtils.createMmsMessage(mms, conversationId, participantId, selfId, + MessageData.BUGLE_STATUS_OUTGOING_RESENDING); + } + } + return message; + } + + /** + * Create an MMS message with subject, text and image + * + * @return Both the M-Send.req and the M-Send.conf for processing in the caller + * @throws MmsException + */ + private static SendReq createMmsSendReq(final Context context, final int subId, + final String[] recipients, final MessageData message, + final boolean requireDeliveryReport, final boolean requireReadReport, + final long expiryTime, final int priority, final long timestampMillis) { + Assert.notNull(context); + if (recipients == null || recipients.length < 1) { + throw new IllegalArgumentException("MMS sendReq no recipient"); + } + + // Make a copy so we don't propagate changes to recipients to outside of this method + final String[] recipientsCopy = new String[recipients.length]; + // Don't send phone number as is since some received phone number is malformed + // for sending. We need to strip the separators. + for (int i = 0; i < recipients.length; i++) { + final String recipient = recipients[i]; + if (EmailAddress.isValidEmail(recipients[i])) { + // Don't do stripping for emails + recipientsCopy[i] = recipient; + } else { + recipientsCopy[i] = stripPhoneNumberSeparators(recipient); + } + } + + SendReq sendReq = null; + try { + sendReq = createSendReq(context, subId, recipientsCopy, + message, requireDeliveryReport, + requireReadReport, expiryTime, priority, timestampMillis); + } catch (final InvalidHeaderValueException e) { + LogUtil.e(TAG, "InvalidHeaderValue creating sendReq PDU"); + } catch (final OutOfMemoryError e) { + LogUtil.e(TAG, "Out of memory error creating sendReq PDU"); + } + return sendReq; + } + + /** + * Stripping out the invalid characters in a phone number before sending + * MMS. We only keep alphanumeric and '*', '#', '+'. + */ + private static String stripPhoneNumberSeparators(final String phoneNumber) { + if (phoneNumber == null) { + return null; + } + final int len = phoneNumber.length(); + final StringBuilder ret = new StringBuilder(len); + for (int i = 0; i < len; i++) { + final char c = phoneNumber.charAt(i); + if (Character.isLetterOrDigit(c) || c == '+' || c == '*' || c == '#') { + ret.append(c); + } + } + return ret.toString(); + } + + /** + * Create M-Send.req for the MMS message to be sent. + * + * @return the M-Send.req + * @throws InvalidHeaderValueException if there is any error in parsing the input + */ + static SendReq createSendReq(final Context context, final int subId, + final String[] recipients, final MessageData message, + final boolean requireDeliveryReport, + final boolean requireReadReport, final long expiryTime, final int priority, + final long timestampMillis) + throws InvalidHeaderValueException { + final SendReq req = new SendReq(); + // From, per spec + final String lineNumber = PhoneUtils.get(subId).getCanonicalForSelf(true/*allowOverride*/); + if (!TextUtils.isEmpty(lineNumber)) { + req.setFrom(new EncodedStringValue(lineNumber)); + } + // To + final EncodedStringValue[] encodedNumbers = EncodedStringValue.encodeStrings(recipients); + if (encodedNumbers != null) { + req.setTo(encodedNumbers); + } + // Subject + if (!TextUtils.isEmpty(message.getMmsSubject())) { + req.setSubject(new EncodedStringValue(message.getMmsSubject())); + } + // Date + req.setDate(timestampMillis / 1000L); + // Body + final MmsInfo bodyInfo = MmsUtils.makePduBody(context, message, subId); + req.setBody(bodyInfo.mPduBody); + // Message size + req.setMessageSize(bodyInfo.mMessageSize); + // Message class + req.setMessageClass(PduHeaders.MESSAGE_CLASS_PERSONAL_STR.getBytes()); + // Expiry + req.setExpiry(expiryTime); + // Priority + req.setPriority(priority); + // Delivery report + req.setDeliveryReport(requireDeliveryReport ? PduHeaders.VALUE_YES : PduHeaders.VALUE_NO); + // Read report + req.setReadReport(requireReadReport ? PduHeaders.VALUE_YES : PduHeaders.VALUE_NO); + return req; + } + + public static boolean isDeliveryReportRequired(final int subId) { + if (!MmsConfig.get(subId).getSMSDeliveryReportsEnabled()) { + return false; + } + final Context context = Factory.get().getApplicationContext(); + final Resources res = context.getResources(); + final BuglePrefs prefs = BuglePrefs.getSubscriptionPrefs(subId); + final String deliveryReportKey = res.getString(R.string.delivery_reports_pref_key); + final boolean defaultValue = res.getBoolean(R.bool.delivery_reports_pref_default); + return prefs.getBoolean(deliveryReportKey, defaultValue); + } + + public static int sendSmsMessage(final String recipient, final String messageText, + final Uri requestUri, final int subId, + final String smsServiceCenter, final boolean requireDeliveryReport) { + if (!isSmsDataAvailable(subId)) { + LogUtil.w(TAG, "MmsUtils: can't send SMS without radio"); + return MMS_REQUEST_MANUAL_RETRY; + } + final Context context = Factory.get().getApplicationContext(); + int status = MMS_REQUEST_MANUAL_RETRY; + try { + // Send a single message + final SendResult result = SmsSender.sendMessage( + context, + subId, + recipient, + messageText, + smsServiceCenter, + requireDeliveryReport, + requestUri); + if (!result.hasPending()) { + // not timed out, check failures + final int failureLevel = result.getHighestFailureLevel(); + switch (failureLevel) { + case SendResult.FAILURE_LEVEL_NONE: + status = MMS_REQUEST_SUCCEEDED; + break; + case SendResult.FAILURE_LEVEL_TEMPORARY: + status = MMS_REQUEST_AUTO_RETRY; + LogUtil.e(TAG, "MmsUtils: SMS temporary failure"); + break; + case SendResult.FAILURE_LEVEL_PERMANENT: + LogUtil.e(TAG, "MmsUtils: SMS permanent failure"); + break; + } + } else { + // Timed out + LogUtil.e(TAG, "MmsUtils: sending SMS timed out"); + } + } catch (final Exception e) { + LogUtil.e(TAG, "MmsUtils: failed to send SMS " + e, e); + } + return status; + } + + /** + * Delete SMS and MMS messages in a particular thread + * + * @return the number of messages deleted + */ + public static int deleteThread(final long threadId, final long cutOffTimestampInMillis) { + final ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver(); + final Uri threadUri = ContentUris.withAppendedId(Telephony.Threads.CONTENT_URI, threadId); + if (cutOffTimestampInMillis < Long.MAX_VALUE) { + return resolver.delete(threadUri, Sms.DATE + "<=?", + new String[] { Long.toString(cutOffTimestampInMillis) }); + } else { + return resolver.delete(threadUri, null /* smsSelection */, null /* selectionArgs */); + } + } + + /** + * Delete single SMS and MMS message + * + * @return number of rows deleted (should be 1 or 0) + */ + public static int deleteMessage(final Uri messageUri) { + final ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver(); + return resolver.delete(messageUri, null /* selection */, null /* selectionArgs */); + } + + public static byte[] createDebugNotificationInd(final String fileName) { + byte[] pduData = null; + try { + final Context context = Factory.get().getApplicationContext(); + // Load the message file + final byte[] data = DebugUtils.receiveFromDumpFile(fileName); + final RetrieveConf retrieveConf = receiveFromDumpFile(data); + // Create the notification + final NotificationInd notification = new NotificationInd(); + final long expiry = System.currentTimeMillis() / 1000 + 600; + notification.setTransactionId(fileName.getBytes()); + notification.setMmsVersion(retrieveConf.getMmsVersion()); + notification.setFrom(retrieveConf.getFrom()); + notification.setSubject(retrieveConf.getSubject()); + notification.setExpiry(expiry); + notification.setMessageSize(data.length); + notification.setMessageClass(retrieveConf.getMessageClass()); + + final Uri.Builder builder = MediaScratchFileProvider.getUriBuilder(); + builder.appendPath(fileName); + final Uri contentLocation = builder.build(); + notification.setContentLocation(contentLocation.toString().getBytes()); + + // Serialize + pduData = new PduComposer(context, notification).make(); + if (pduData == null || pduData.length < 1) { + throw new IllegalArgumentException("Empty or zero length PDU data"); + } + } catch (final MmsFailureException e) { + // Nothing to do + } catch (final InvalidHeaderValueException e) { + // Nothing to do + } + return pduData; + } + + public static int mapRawStatusToErrorResourceId(final int bugleStatus, final int rawStatus) { + int stringResId = R.string.message_status_send_failed; + switch (rawStatus) { + case PduHeaders.RESPONSE_STATUS_ERROR_SERVICE_DENIED: + case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_SERVICE_DENIED: + //case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_LIMITATIONS_NOT_MET: + //case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_REQUEST_NOT_ACCEPTED: + //case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_FORWARDING_DENIED: + //case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_NOT_SUPPORTED: + //case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_ADDRESS_HIDING_NOT_SUPPORTED: + //case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_LACK_OF_PREPAID: + stringResId = R.string.mms_failure_outgoing_service; + break; + case PduHeaders.RESPONSE_STATUS_ERROR_SENDING_ADDRESS_UNRESOLVED: + case PduHeaders.RESPONSE_STATUS_ERROR_TRANSIENT_SENDNG_ADDRESS_UNRESOLVED: + case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_SENDING_ADDRESS_UNRESOLVED: + stringResId = R.string.mms_failure_outgoing_address; + break; + case PduHeaders.RESPONSE_STATUS_ERROR_MESSAGE_FORMAT_CORRUPT: + case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_MESSAGE_FORMAT_CORRUPT: + stringResId = R.string.mms_failure_outgoing_corrupt; + break; + case PduHeaders.RESPONSE_STATUS_ERROR_CONTENT_NOT_ACCEPTED: + case PduHeaders.RESPONSE_STATUS_ERROR_PERMANENT_CONTENT_NOT_ACCEPTED: + stringResId = R.string.mms_failure_outgoing_content; + break; + case PduHeaders.RESPONSE_STATUS_ERROR_UNSUPPORTED_MESSAGE: + //case PduHeaders.RESPONSE_STATUS_ERROR_MESSAGE_NOT_FOUND: + //case PduHeaders.RESPONSE_STATUS_ERROR_TRANSIENT_MESSAGE_NOT_FOUND: + stringResId = R.string.mms_failure_outgoing_unsupported; + break; + case MessageData.RAW_TELEPHONY_STATUS_MESSAGE_TOO_BIG: + stringResId = R.string.mms_failure_outgoing_too_large; + break; + } + return stringResId; + } + + /** + * The absence of a connection type. + */ + public static final int TYPE_NONE = -1; + + public static int getConnectivityEventNetworkType(final Context context, final Intent intent) { + final ConnectivityManager connMgr = (ConnectivityManager) + context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (OsUtil.isAtLeastJB_MR1()) { + return intent.getIntExtra(ConnectivityManager.EXTRA_NETWORK_TYPE, TYPE_NONE); + } else { + final NetworkInfo info = (NetworkInfo) intent.getParcelableExtra( + ConnectivityManager.EXTRA_NETWORK_INFO); + if (info != null) { + return info.getType(); + } + } + return TYPE_NONE; + } + + /** + * Dump the raw MMS data into a file + * + * @param rawPdu The raw pdu data + * @param pdu The parsed pdu, used to construct a dump file name + */ + public static void dumpPdu(final byte[] rawPdu, final GenericPdu pdu) { + if (rawPdu == null || rawPdu.length < 1) { + return; + } + final String dumpFileName = MmsUtils.MMS_DUMP_PREFIX + getDumpFileId(pdu); + final File dumpFile = DebugUtils.getDebugFile(dumpFileName, true); + if (dumpFile != null) { + try { + final FileOutputStream fos = new FileOutputStream(dumpFile); + final BufferedOutputStream bos = new BufferedOutputStream(fos); + try { + bos.write(rawPdu); + bos.flush(); + } finally { + bos.close(); + } + DebugUtils.ensureReadable(dumpFile); + } catch (final IOException e) { + LogUtil.e(TAG, "dumpPdu: " + e, e); + } + } + } + + /** + * Get the dump file id based on the parsed PDU + * 1. Use message id if not empty + * 2. Use transaction id if message id is empty + * 3. If all above is empty, use random UUID + * + * @param pdu the parsed PDU + * @return the id of the dump file + */ + private static String getDumpFileId(final GenericPdu pdu) { + String fileId = null; + if (pdu != null && pdu instanceof RetrieveConf) { + final RetrieveConf retrieveConf = (RetrieveConf) pdu; + if (retrieveConf.getMessageId() != null) { + fileId = new String(retrieveConf.getMessageId()); + } else if (retrieveConf.getTransactionId() != null) { + fileId = new String(retrieveConf.getTransactionId()); + } + } + if (TextUtils.isEmpty(fileId)) { + fileId = UUID.randomUUID().toString(); + } + return fileId; + } +} diff --git a/src/com/android/messaging/sms/SmsException.java b/src/com/android/messaging/sms/SmsException.java new file mode 100644 index 0000000..728db8c --- /dev/null +++ b/src/com/android/messaging/sms/SmsException.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.sms; + +/** + * A generic Exception for errors in sending SMS + */ +class SmsException extends Exception { + private static final long serialVersionUID = 1L; + + /** + * Creates a new SmsException. + */ + public SmsException() { + super(); + } + + /** + * Creates a new SmsException with the specified detail message. + * + * @param message the detail message. + */ + public SmsException(String message) { + super(message); + } + + /** + * Creates a new SmsException with the specified cause. + * + * @param cause the cause. + */ + public SmsException(Throwable cause) { + super(cause); + } + + /** + * Creates a new SmsException with the specified detail message and cause. + * + * @param message the detail message. + * @param cause the cause. + */ + public SmsException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/com/android/messaging/sms/SmsReleaseStorage.java b/src/com/android/messaging/sms/SmsReleaseStorage.java new file mode 100644 index 0000000..13a6284 --- /dev/null +++ b/src/com/android/messaging/sms/SmsReleaseStorage.java @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.sms; + +import android.content.res.Resources; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.SyncManager; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.LogUtil; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Class handling message cleanup when storage is low + */ +public class SmsReleaseStorage { + /** + * Class representing a time duration specified by Gservices + */ + public static class Duration { + // Time duration unit types + public static final int UNIT_WEEK = 'w'; + public static final int UNIT_MONTH = 'm'; + public static final int UNIT_YEAR = 'y'; + + // Number of units + public final int mCount; + // Unit type: week, month or year + public final int mUnit; + + public Duration(final int count, final int unit) { + mCount = count; + mUnit = unit; + } + } + + private static final String TAG = LogUtil.BUGLE_TAG; + + private static final Duration DEFAULT_DURATION = new Duration(1, Duration.UNIT_MONTH); + + private static final Pattern DURATION_PATTERN = Pattern.compile("([1-9]+\\d*)(w|m|y)"); + /** + * Parse message retaining time duration specified by Gservices + * + * @return The parsed time duration from Gservices + */ + public static Duration parseMessageRetainingDuration() { + final String smsAutoDeleteMessageRetainingDuration = + BugleGservices.get().getString( + BugleGservicesKeys.SMS_STORAGE_PURGING_MESSAGE_RETAINING_DURATION, + BugleGservicesKeys.SMS_STORAGE_PURGING_MESSAGE_RETAINING_DURATION_DEFAULT); + final Matcher matcher = DURATION_PATTERN.matcher(smsAutoDeleteMessageRetainingDuration); + try { + if (matcher.matches()) { + return new Duration( + Integer.parseInt(matcher.group(1)), + matcher.group(2).charAt(0)); + } + } catch (final NumberFormatException e) { + // Nothing to do + } + LogUtil.e(TAG, "SmsAutoDelete: invalid duration " + + smsAutoDeleteMessageRetainingDuration); + return DEFAULT_DURATION; + } + + /** + * Get string representation of the time duration + * + * @param duration + * @return + */ + public static String getMessageRetainingDurationString(final Duration duration) { + final Resources resources = Factory.get().getApplicationContext().getResources(); + switch (duration.mUnit) { + case Duration.UNIT_WEEK: + return resources.getQuantityString( + R.plurals.week_count, duration.mCount, duration.mCount); + case Duration.UNIT_MONTH: + return resources.getQuantityString( + R.plurals.month_count, duration.mCount, duration.mCount); + case Duration.UNIT_YEAR: + return resources.getQuantityString( + R.plurals.year_count, duration.mCount, duration.mCount); + } + throw new IllegalArgumentException( + "SmsAutoDelete: invalid duration unit " + duration.mUnit); + } + + // Time conversations + private static final long WEEK_IN_MILLIS = 7 * 24 * 3600 * 1000L; + private static final long MONTH_IN_MILLIS = 30 * 24 * 3600 * 1000L; + private static final long YEAR_IN_MILLIS = 365 * 24 * 3600 * 1000L; + + /** + * Convert time duration to time in milliseconds + * + * @param duration + * @return + */ + public static long durationToTimeInMillis(final Duration duration) { + switch (duration.mUnit) { + case Duration.UNIT_WEEK: + return duration.mCount * WEEK_IN_MILLIS; + case Duration.UNIT_MONTH: + return duration.mCount * MONTH_IN_MILLIS; + case Duration.UNIT_YEAR: + return duration.mCount * YEAR_IN_MILLIS; + } + return -1L; + } + + /** + * Delete message actions: + * 0: delete media messages + * 1: delete old messages + * + * @param actionIndex The index of the delete action to perform + * @param durationInMillis The time duration for retaining messages + */ + public static void deleteMessages(final int actionIndex, final long durationInMillis) { + int deleted = 0; + switch (actionIndex) { + case 0: { + // Delete media + deleted = MmsUtils.deleteMediaMessages(); + break; + } + case 1: { + // Delete old messages + final long now = System.currentTimeMillis(); + final long cutOffTimestampInMillis = now - durationInMillis; + // Delete messages from telephony provider + deleted = MmsUtils.deleteMessagesOlderThan(cutOffTimestampInMillis); + break; + } + default: { + LogUtil.e(TAG, "SmsStorageStatusManager: invalid action " + actionIndex); + break; + } + } + + if (deleted > 0) { + // Kick off a sync to update local db. + SyncManager.sync(); + } + } +} diff --git a/src/com/android/messaging/sms/SmsSender.java b/src/com/android/messaging/sms/SmsSender.java new file mode 100644 index 0000000..889973f --- /dev/null +++ b/src/com/android/messaging/sms/SmsSender.java @@ -0,0 +1,315 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.sms; + +import android.app.Activity; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.SystemClock; +import android.telephony.PhoneNumberUtils; +import android.telephony.SmsManager; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.receiver.SendStatusReceiver; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.UiUtils; + +import java.util.ArrayList; +import java.util.Random; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Class that sends chat message via SMS. + * + * The interface emulates a blocking sending similar to making an HTTP request. + * It calls the SmsManager to send a (potentially multipart) message and waits + * on the sent status on each part. The waiting has a timeout so it won't wait + * forever. Once the sent status of all parts received, the call returns. + * A successful sending requires success status for all parts. Otherwise, we + * pick the highest level of failure as the error for the whole message, which + * is used to determine if we need to retry the sending. + */ +public class SmsSender { + private static final String TAG = LogUtil.BUGLE_TAG; + + public static final String EXTRA_PART_ID = "part_id"; + + /* + * A map for pending sms messages. The key is the random request UUID. + */ + private static ConcurrentHashMap sPendingMessageMap = + new ConcurrentHashMap(); + + private static final Random RANDOM = new Random(); + + // Whether we should send multipart SMS as separate messages + private static Boolean sSendMultipartSmsAsSeparateMessages = null; + + /** + * Class that holds the sent status for all parts of a multipart message sending + */ + public static class SendResult { + // Failure levels, used by the caller of the sender. + // For temporary failures, possibly we could retry the sending + // For permanent failures, we probably won't retry + public static final int FAILURE_LEVEL_NONE = 0; + public static final int FAILURE_LEVEL_TEMPORARY = 1; + public static final int FAILURE_LEVEL_PERMANENT = 2; + + // Tracking the remaining pending parts in sending + private int mPendingParts; + // Tracking the highest level of failure among all parts + private int mHighestFailureLevel; + + public SendResult(final int numOfParts) { + Assert.isTrue(numOfParts > 0); + mPendingParts = numOfParts; + mHighestFailureLevel = FAILURE_LEVEL_NONE; + } + + // Update the sent status of one part + public void setPartResult(final int resultCode) { + mPendingParts--; + setHighestFailureLevel(resultCode); + } + + public boolean hasPending() { + return mPendingParts > 0; + } + + public int getHighestFailureLevel() { + return mHighestFailureLevel; + } + + private int getFailureLevel(final int resultCode) { + switch (resultCode) { + case Activity.RESULT_OK: + return FAILURE_LEVEL_NONE; + case SmsManager.RESULT_ERROR_NO_SERVICE: + return FAILURE_LEVEL_TEMPORARY; + case SmsManager.RESULT_ERROR_RADIO_OFF: + return FAILURE_LEVEL_PERMANENT; + case SmsManager.RESULT_ERROR_GENERIC_FAILURE: + return FAILURE_LEVEL_PERMANENT; + default: { + LogUtil.e(TAG, "SmsSender: Unexpected sent intent resultCode = " + resultCode); + return FAILURE_LEVEL_PERMANENT; + } + } + } + + private void setHighestFailureLevel(final int resultCode) { + final int level = getFailureLevel(resultCode); + if (level > mHighestFailureLevel) { + mHighestFailureLevel = level; + } + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder(); + sb.append("SendResult:"); + sb.append("Pending=").append(mPendingParts).append(","); + sb.append("HighestFailureLevel=").append(mHighestFailureLevel); + return sb.toString(); + } + } + + public static void setResult(final Uri requestId, final int resultCode, + final int errorCode, final int partId, int subId) { + if (resultCode != Activity.RESULT_OK) { + LogUtil.e(TAG, "SmsSender: failure in sending message part. " + + " requestId=" + requestId + " partId=" + partId + + " resultCode=" + resultCode + " errorCode=" + errorCode); + if (errorCode != SendStatusReceiver.NO_ERROR_CODE) { + final Context context = Factory.get().getApplicationContext(); + UiUtils.showToastAtBottom(getSendErrorToastMessage(context, subId, errorCode)); + } + } else { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SmsSender: received sent result. " + " requestId=" + requestId + + " partId=" + partId + " resultCode=" + resultCode); + } + } + if (requestId != null) { + final SendResult result = sPendingMessageMap.get(requestId); + if (result != null) { + synchronized (result) { + result.setPartResult(resultCode); + if (!result.hasPending()) { + result.notifyAll(); + } + } + } else { + LogUtil.e(TAG, "SmsSender: ignoring sent result. " + " requestId=" + requestId + + " partId=" + partId + " resultCode=" + resultCode); + } + } + } + + private static String getSendErrorToastMessage(final Context context, final int subId, + final int errorCode) { + final String carrierName = PhoneUtils.get(subId).getCarrierName(); + if (TextUtils.isEmpty(carrierName)) { + return context.getString(R.string.carrier_send_error_unknown_carrier, errorCode); + } else { + return context.getString(R.string.carrier_send_error, carrierName, errorCode); + } + } + + // This should be called from a RequestWriter queue thread + public static SendResult sendMessage(final Context context, final int subId, String dest, + String message, final String serviceCenter, final boolean requireDeliveryReport, + final Uri messageUri) throws SmsException { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SmsSender: sending message. " + + "dest=" + dest + " message=" + message + + " serviceCenter=" + serviceCenter + + " requireDeliveryReport=" + requireDeliveryReport + + " requestId=" + messageUri); + } + if (TextUtils.isEmpty(message)) { + throw new SmsException("SmsSender: empty text message"); + } + // Get the real dest and message for email or alias if dest is email or alias + // Or sanitize the dest if dest is a number + if (!TextUtils.isEmpty(MmsConfig.get(subId).getEmailGateway()) && + (MmsSmsUtils.isEmailAddress(dest) || MmsSmsUtils.isAlias(dest, subId))) { + // The original destination (email address) goes with the message + message = dest + " " + message; + // the new address is the email gateway # + dest = MmsConfig.get(subId).getEmailGateway(); + } else { + // remove spaces and dashes from destination number + // (e.g. "801 555 1212" -> "8015551212") + // (e.g. "+8211-123-4567" -> "+82111234567") + dest = PhoneNumberUtils.stripSeparators(dest); + } + if (TextUtils.isEmpty(dest)) { + throw new SmsException("SmsSender: empty destination address"); + } + // Divide the input message by SMS length limit + final SmsManager smsManager = PhoneUtils.get(subId).getSmsManager(); + final ArrayList messages = smsManager.divideMessage(message); + if (messages == null || messages.size() < 1) { + throw new SmsException("SmsSender: fails to divide message"); + } + // Prepare the send result, which collects the send status for each part + final SendResult pendingResult = new SendResult(messages.size()); + sPendingMessageMap.put(messageUri, pendingResult); + // Actually send the sms + sendInternal( + context, subId, dest, messages, serviceCenter, requireDeliveryReport, messageUri); + // Wait for pending intent to come back + synchronized (pendingResult) { + final long smsSendTimeoutInMillis = BugleGservices.get().getLong( + BugleGservicesKeys.SMS_SEND_TIMEOUT_IN_MILLIS, + BugleGservicesKeys.SMS_SEND_TIMEOUT_IN_MILLIS_DEFAULT); + final long beginTime = SystemClock.elapsedRealtime(); + long waitTime = smsSendTimeoutInMillis; + // We could possibly be woken up while still pending + // so make sure we wait the full timeout period unless + // we have the send results of all parts. + while (pendingResult.hasPending() && waitTime > 0) { + try { + pendingResult.wait(waitTime); + } catch (final InterruptedException e) { + LogUtil.e(TAG, "SmsSender: sending wait interrupted"); + } + waitTime = smsSendTimeoutInMillis - (SystemClock.elapsedRealtime() - beginTime); + } + } + // Either we timed out or have all the results (success or failure) + sPendingMessageMap.remove(messageUri); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "SmsSender: sending completed. " + + "dest=" + dest + " message=" + message + " result=" + pendingResult); + } + return pendingResult; + } + + // Actually sending the message using SmsManager + private static void sendInternal(final Context context, final int subId, String dest, + final ArrayList messages, final String serviceCenter, + final boolean requireDeliveryReport, final Uri messageUri) throws SmsException { + Assert.notNull(context); + final SmsManager smsManager = PhoneUtils.get(subId).getSmsManager(); + final int messageCount = messages.size(); + final ArrayList deliveryIntents = new ArrayList(messageCount); + final ArrayList sentIntents = new ArrayList(messageCount); + for (int i = 0; i < messageCount; i++) { + // Make pending intents different for each message part + final int partId = (messageCount <= 1 ? 0 : i + 1); + if (requireDeliveryReport && (i == (messageCount - 1))) { + // TODO we only care about the delivery status of the last part + // Shall we have better tracking of delivery status of all parts? + deliveryIntents.add(PendingIntent.getBroadcast( + context, + partId, + getSendStatusIntent(context, SendStatusReceiver.MESSAGE_DELIVERED_ACTION, + messageUri, partId, subId), + 0/*flag*/)); + } else { + deliveryIntents.add(null); + } + sentIntents.add(PendingIntent.getBroadcast( + context, + partId, + getSendStatusIntent(context, SendStatusReceiver.MESSAGE_SENT_ACTION, + messageUri, partId, subId), + 0/*flag*/)); + } + if (sSendMultipartSmsAsSeparateMessages == null) { + sSendMultipartSmsAsSeparateMessages = MmsConfig.get(subId) + .getSendMultipartSmsAsSeparateMessages(); + } + try { + if (sSendMultipartSmsAsSeparateMessages) { + // If multipart sms is not supported, send them as separate messages + for (int i = 0; i < messageCount; i++) { + smsManager.sendTextMessage(dest, + serviceCenter, + messages.get(i), + sentIntents.get(i), + deliveryIntents.get(i)); + } + } else { + smsManager.sendMultipartTextMessage( + dest, serviceCenter, messages, sentIntents, deliveryIntents); + } + } catch (final Exception e) { + throw new SmsException("SmsSender: caught exception in sending " + e); + } + } + + private static Intent getSendStatusIntent(final Context context, final String action, + final Uri requestUri, final int partId, final int subId) { + // Encode requestId in intent data + final Intent intent = new Intent(action, requestUri, context, SendStatusReceiver.class); + intent.putExtra(SendStatusReceiver.EXTRA_PART_ID, partId); + intent.putExtra(SendStatusReceiver.EXTRA_SUB_ID, subId); + return intent; + } +} diff --git a/src/com/android/messaging/sms/SmsStorageStatusManager.java b/src/com/android/messaging/sms/SmsStorageStatusManager.java new file mode 100644 index 0000000..ff7b79d --- /dev/null +++ b/src/com/android/messaging/sms/SmsStorageStatusManager.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.sms; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.content.res.Resources; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.NotificationManagerCompat; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.PendingIntentConstants; +import com.android.messaging.util.PhoneUtils; + +/** + * Class that handles SMS auto delete and notification when storage is low + */ +public class SmsStorageStatusManager { + /** + * Handles storage low signal for SMS + */ + public static void handleStorageLow() { + if (!PhoneUtils.getDefault().isSmsEnabled()) { + return; + } + + // TODO: Auto-delete messages, when that setting exists and is enabled + + // Notify low storage for SMS + postStorageLowNotification(); + } + + /** + * Handles storage OK signal for SMS + */ + public static void handleStorageOk() { + if (!PhoneUtils.getDefault().isSmsEnabled()) { + return; + } + cancelStorageLowNotification(); + } + + /** + * Post sms storage low notification + */ + private static void postStorageLowNotification() { + final Context context = Factory.get().getApplicationContext(); + final Resources resources = context.getResources(); + final PendingIntent pendingIntent = UIIntents.get() + .getPendingIntentForLowStorageNotifications(context); + + final NotificationCompat.Builder builder = new NotificationCompat.Builder(context); + builder.setContentTitle(resources.getString(R.string.sms_storage_low_title)) + .setTicker(resources.getString(R.string.sms_storage_low_notification_ticker)) + .setSmallIcon(R.drawable.ic_failed_light) + .setPriority(Notification.PRIORITY_DEFAULT) + .setOngoing(true) // Can't be swiped off + .setAutoCancel(false) // Don't auto cancel + .setContentIntent(pendingIntent); + + final NotificationCompat.BigTextStyle bigTextStyle = + new NotificationCompat.BigTextStyle(builder); + bigTextStyle.bigText(resources.getString(R.string.sms_storage_low_text)); + final Notification notification = bigTextStyle.build(); + + final NotificationManagerCompat notificationManager = + NotificationManagerCompat.from(Factory.get().getApplicationContext()); + + notificationManager.notify(getNotificationTag(), + PendingIntentConstants.SMS_STORAGE_LOW_NOTIFICATION_ID, notification); + } + + /** + * Cancel the notification + */ + public static void cancelStorageLowNotification() { + final NotificationManagerCompat notificationManager = + NotificationManagerCompat.from(Factory.get().getApplicationContext()); + notificationManager.cancel(getNotificationTag(), + PendingIntentConstants.SMS_STORAGE_LOW_NOTIFICATION_ID); + } + + private static String getNotificationTag() { + return Factory.get().getApplicationContext().getPackageName() + ":smsstoragelow"; + } +} diff --git a/src/com/android/messaging/sms/SystemProperties.java b/src/com/android/messaging/sms/SystemProperties.java new file mode 100644 index 0000000..669e448 --- /dev/null +++ b/src/com/android/messaging/sms/SystemProperties.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.sms; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * Hacky way to call the hidden SystemProperties class API + */ +class SystemProperties { + private static Method sSystemPropertiesGetMethod = null; + + public static String get(final String name) { + if (sSystemPropertiesGetMethod == null) { + try { + final Class systemPropertiesClass = Class.forName("android.os.SystemProperties"); + if (systemPropertiesClass != null) { + sSystemPropertiesGetMethod = + systemPropertiesClass.getMethod("get", String.class); + } + } catch (final ClassNotFoundException e) { + // Nothing to do + } catch (final NoSuchMethodException e) { + // Nothing to do + } + } + if (sSystemPropertiesGetMethod != null) { + try { + return (String) sSystemPropertiesGetMethod.invoke(null, name); + } catch (final IllegalArgumentException e) { + // Nothing to do + } catch (final IllegalAccessException e) { + // Nothing to do + } catch (final InvocationTargetException e) { + // Nothing to do + } + } + return null; + } +} diff --git a/src/com/android/messaging/ui/AsyncImageView.java b/src/com/android/messaging/ui/AsyncImageView.java new file mode 100644 index 0000000..9aaf0b1 --- /dev/null +++ b/src/com/android/messaging/ui/AsyncImageView.java @@ -0,0 +1,457 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Path; +import android.graphics.RectF; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.support.annotation.Nullable; +import android.support.rastermill.FrameSequenceDrawable; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.widget.ImageView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.media.BindableMediaRequest; +import com.android.messaging.datamodel.media.GifImageResource; +import com.android.messaging.datamodel.media.ImageRequest; +import com.android.messaging.datamodel.media.ImageRequestDescriptor; +import com.android.messaging.datamodel.media.ImageResource; +import com.android.messaging.datamodel.media.MediaRequest; +import com.android.messaging.datamodel.media.MediaResourceManager; +import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.ThreadUtil; +import com.android.messaging.util.UiUtils; +import com.google.common.annotations.VisibleForTesting; + +import java.util.HashSet; + +/** + * An ImageView used to asynchronously request an image from MediaResourceManager and render it. + */ +public class AsyncImageView extends ImageView implements MediaResourceLoadListener { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + // 100ms delay before disposing the image in case the AsyncImageView is re-added to the UI + private static final int DISPOSE_IMAGE_DELAY = 100; + + // AsyncImageView has a 1-1 binding relationship with an ImageRequest instance that requests + // the image from the MediaResourceManager. Since the request is done asynchronously, we + // want to make sure the image view is always bound to the latest image request that it + // issues, so that when the image is loaded, the ImageRequest (which extends BindableData) + // will be able to figure out whether the binding is still valid and whether the loaded image + // should be delivered to the AsyncImageView via onMediaResourceLoaded() callback. + @VisibleForTesting + public final Binding> mImageRequestBinding; + + /** True if we want the image to fade in when it loads */ + private boolean mFadeIn; + + /** True if we want the image to reveal (scale) when it loads. When set to true, this + * will take precedence over {@link #mFadeIn} */ + private final boolean mReveal; + + // The corner radius for drawing rounded corners around bitmap. The default value is zero + // (no rounded corners) + private final int mCornerRadius; + private final Path mRoundedCornerClipPath; + private int mClipPathWidth; + private int mClipPathHeight; + + // A placeholder drawable that takes the spot of the image when it's loading. The default + // setting is null (no placeholder). + private final Drawable mPlaceholderDrawable; + protected ImageResource mImageResource; + private final Runnable mDisposeRunnable = new Runnable() { + @Override + public void run() { + if (mImageRequestBinding.isBound()) { + mDetachedRequestDescriptor = (ImageRequestDescriptor) + mImageRequestBinding.getData().getDescriptor(); + } + unbindView(); + releaseImageResource(); + } + }; + + private AsyncImageViewDelayLoader mDelayLoader; + private ImageRequestDescriptor mDetachedRequestDescriptor; + + public AsyncImageView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mImageRequestBinding = BindingBase.createBinding(this); + final TypedArray attr = context.obtainStyledAttributes(attrs, R.styleable.AsyncImageView, + 0, 0); + mFadeIn = attr.getBoolean(R.styleable.AsyncImageView_fadeIn, true); + mReveal = attr.getBoolean(R.styleable.AsyncImageView_reveal, false); + mPlaceholderDrawable = attr.getDrawable(R.styleable.AsyncImageView_placeholderDrawable); + mCornerRadius = attr.getDimensionPixelSize(R.styleable.AsyncImageView_cornerRadius, 0); + mRoundedCornerClipPath = new Path(); + + attr.recycle(); + } + + /** + * The main entrypoint for AsyncImageView to load image resource given an ImageRequestDescriptor + * @param descriptor the request descriptor, or null if no image should be displayed + */ + public void setImageResourceId(@Nullable final ImageRequestDescriptor descriptor) { + final String requestKey = (descriptor == null) ? null : descriptor.getKey(); + if (mImageRequestBinding.isBound()) { + if (TextUtils.equals(mImageRequestBinding.getData().getKey(), requestKey)) { + // Don't re-request the bitmap if the new request is for the same resource. + return; + } + unbindView(); + } + setImage(null); + resetTransientViewStates(); + if (!TextUtils.isEmpty(requestKey)) { + maybeSetupPlaceholderDrawable(descriptor); + final BindableMediaRequest imageRequest = + descriptor.buildAsyncMediaRequest(getContext(), this); + requestImage(imageRequest); + } + } + + /** + * Sets a delay loader that centrally manages image request delay loading logic. + */ + public void setDelayLoader(final AsyncImageViewDelayLoader delayLoader) { + Assert.isTrue(mDelayLoader == null); + mDelayLoader = delayLoader; + } + + /** + * Called by the delay loader when we can resume image loading. + */ + public void resumeLoading() { + Assert.notNull(mDelayLoader); + Assert.isTrue(mImageRequestBinding.isBound()); + MediaResourceManager.get().requestMediaResourceAsync(mImageRequestBinding.getData()); + } + + /** + * Setup the placeholder drawable if: + * 1. There's an image to be loaded AND + * 2. We are given a placeholder drawable AND + * 3. The descriptor provided us with source width and height. + */ + private void maybeSetupPlaceholderDrawable(final ImageRequestDescriptor descriptor) { + if (!TextUtils.isEmpty(descriptor.getKey()) && mPlaceholderDrawable != null) { + if (descriptor.sourceWidth != ImageRequest.UNSPECIFIED_SIZE && + descriptor.sourceHeight != ImageRequest.UNSPECIFIED_SIZE) { + // Set a transparent inset drawable to the foreground so it will mimick the final + // size of the image, and use the background to show the actual placeholder + // drawable. + setImageDrawable(PlaceholderInsetDrawable.fromDrawable( + new ColorDrawable(Color.TRANSPARENT), + descriptor.sourceWidth, descriptor.sourceHeight)); + } + setBackground(mPlaceholderDrawable); + } + } + + protected void setImage(final ImageResource resource) { + setImage(resource, false /* isCached */); + } + + protected void setImage(final ImageResource resource, final boolean isCached) { + // Switch reference to the new ImageResource. Make sure we release the current + // resource and addRef() on the new resource so that the underlying bitmaps don't + // get leaked or get recycled by the bitmap cache. + releaseImageResource(); + // Ensure that any pending dispose runnables get removed. + ThreadUtil.getMainThreadHandler().removeCallbacks(mDisposeRunnable); + // The drawable may require work to get if its a static object so try to only make this call + // once. + final Drawable drawable = (resource != null) ? resource.getDrawable(getResources()) : null; + if (drawable != null) { + mImageResource = resource; + mImageResource.addRef(); + setImageDrawable(drawable); + if (drawable instanceof FrameSequenceDrawable) { + ((FrameSequenceDrawable) drawable).start(); + } + + if (getVisibility() == VISIBLE) { + if (mReveal) { + setVisibility(INVISIBLE); + UiUtils.revealOrHideViewWithAnimation(this, VISIBLE, null); + } else if (mFadeIn && !isCached) { + // Hide initially to avoid flash. + setAlpha(0F); + animate().alpha(1F).start(); + } + } + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + if (mImageResource instanceof GifImageResource) { + LogUtil.v(TAG, "setImage size unknown -- it's a GIF"); + } else { + LogUtil.v(TAG, "setImage size: " + mImageResource.getMediaSize() + + " width: " + mImageResource.getBitmap().getWidth() + + " heigh: " + mImageResource.getBitmap().getHeight()); + } + } + } + invalidate(); + } + + private void requestImage(final BindableMediaRequest request) { + mImageRequestBinding.bind(request); + if (mDelayLoader == null || !mDelayLoader.isDelayLoadingImage()) { + MediaResourceManager.get().requestMediaResourceAsync(request); + } else { + mDelayLoader.registerView(this); + } + } + + @Override + public void onMediaResourceLoaded(final MediaRequest request, + final ImageResource resource, final boolean isCached) { + if (mImageResource != resource) { + setImage(resource, isCached); + } + } + + @Override + public void onMediaResourceLoadError( + final MediaRequest request, final Exception exception) { + // Media load failed, unbind and reset bitmap to default. + unbindView(); + setImage(null); + } + + private void releaseImageResource() { + final Drawable drawable = getDrawable(); + if (drawable instanceof FrameSequenceDrawable) { + ((FrameSequenceDrawable) drawable).stop(); + ((FrameSequenceDrawable) drawable).destroy(); + } + if (mImageResource != null) { + mImageResource.release(); + mImageResource = null; + } + setImageDrawable(null); + setBackground(null); + } + + /** + * Resets transient view states (eg. alpha, animations) before rebinding/reusing the view. + */ + private void resetTransientViewStates() { + clearAnimation(); + setAlpha(1F); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + // If it was recently removed, then cancel disposing, we're still using it. + ThreadUtil.getMainThreadHandler().removeCallbacks(mDisposeRunnable); + + // When the image view gets detached and immediately re-attached, any fade-in animation + // will be terminated, leaving the view in a semi-transparent state. Make sure we restore + // alpha when the view is re-attached. + if (mFadeIn) { + setAlpha(1F); + } + + // Check whether we are in a simple reuse scenario: detached from window, and reattached + // later without rebinding. This may be done by containers such as the RecyclerView to + // reuse the views. In this case, we would like to rebind the original image request. + if (!mImageRequestBinding.isBound() && mDetachedRequestDescriptor != null) { + setImageResourceId(mDetachedRequestDescriptor); + } + mDetachedRequestDescriptor = null; + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + // Dispose the bitmap, but if an AysncImageView is removed from the window, then quickly + // re-added, we shouldn't dispose, so wait a short time before disposing + ThreadUtil.getMainThreadHandler().postDelayed(mDisposeRunnable, DISPOSE_IMAGE_DELAY); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + // The base implementation does not honor the minimum sizes. We try to to honor it here. + + final int measuredWidth = getMeasuredWidth(); + final int measuredHeight = getMeasuredHeight(); + if (measuredWidth >= getMinimumWidth() || measuredHeight >= getMinimumHeight()) { + // We are ok if either of the minimum sizes is honored. Note that satisfying both the + // sizes may not be possible, depending on the aspect ratio of the image and whether + // a maximum size has been specified. This implementation only tries to handle the case + // where both the minimum sizes are not being satisfied. + return; + } + + if (!getAdjustViewBounds()) { + // The base implementation is reasonable in this case. If the view bounds cannot be + // changed, it is not possible to satisfy the minimum sizes anyway. + return; + } + + final int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); + final int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); + if (widthSpecMode == MeasureSpec.EXACTLY && heightSpecMode == MeasureSpec.EXACTLY) { + // The base implementation is reasonable in this case. + return; + } + + int width = measuredWidth; + int height = measuredHeight; + // Get the minimum sizes that will honor other constraints as well. + final int minimumWidth = resolveSize( + getMinimumWidth(), getMaxWidth(), widthMeasureSpec); + final int minimumHeight = resolveSize( + getMinimumHeight(), getMaxHeight(), heightMeasureSpec); + final float aspectRatio = measuredWidth / (float) measuredHeight; + if (aspectRatio == 0) { + // If the image is (close to) infinitely high, there is not much we can do. + return; + } + + if (width < minimumWidth) { + height = resolveSize((int) (minimumWidth / aspectRatio), + getMaxHeight(), heightMeasureSpec); + width = (int) (height * aspectRatio); + } + + if (height < minimumHeight) { + width = resolveSize((int) (minimumHeight * aspectRatio), + getMaxWidth(), widthMeasureSpec); + height = (int) (width / aspectRatio); + } + + setMeasuredDimension(width, height); + } + + private static int resolveSize(int desiredSize, int maxSize, int measureSpec) { + final int specMode = MeasureSpec.getMode(measureSpec); + final int specSize = MeasureSpec.getSize(measureSpec); + switch(specMode) { + case MeasureSpec.UNSPECIFIED: + return Math.min(desiredSize, maxSize); + + case MeasureSpec.AT_MOST: + return Math.min(Math.min(desiredSize, specSize), maxSize); + + default: + Assert.fail("Unreachable"); + return specSize; + } + } + + @Override + protected void onDraw(final Canvas canvas) { + if (mCornerRadius > 0) { + final int currentWidth = this.getWidth(); + final int currentHeight = this.getHeight(); + if (mClipPathWidth != currentWidth || mClipPathHeight != currentHeight) { + final RectF rect = new RectF(0, 0, currentWidth, currentHeight); + mRoundedCornerClipPath.reset(); + mRoundedCornerClipPath.addRoundRect(rect, mCornerRadius, mCornerRadius, + Path.Direction.CW); + mClipPathWidth = currentWidth; + mClipPathHeight = currentHeight; + } + + final int saveCount = canvas.getSaveCount(); + canvas.save(); + canvas.clipPath(mRoundedCornerClipPath); + super.onDraw(canvas); + canvas.restoreToCount(saveCount); + } else { + super.onDraw(canvas); + } + } + + private void unbindView() { + if (mImageRequestBinding.isBound()) { + mImageRequestBinding.unbind(); + if (mDelayLoader != null) { + mDelayLoader.unregisterView(this); + } + } + } + + /** + * As a performance optimization, the consumer of the AsyncImageView may opt to delay loading + * the image when it's busy doing other things (such as when a list view is scrolling). In + * order to do this, the consumer can create a new AsyncImageViewDelayLoader instance to be + * shared among all relevant AsyncImageViews (through setDelayLoader() method), and call + * onStartDelayLoading() and onStopDelayLoading() to start and stop delay loading, respectively. + */ + public static class AsyncImageViewDelayLoader { + private boolean mShouldDelayLoad; + private final HashSet mAttachedViews; + + public AsyncImageViewDelayLoader() { + mAttachedViews = new HashSet(); + } + + private void registerView(final AsyncImageView view) { + mAttachedViews.add(view); + } + + private void unregisterView(final AsyncImageView view) { + mAttachedViews.remove(view); + } + + public boolean isDelayLoadingImage() { + return mShouldDelayLoad; + } + + /** + * Called by the consumer of this view to delay loading images + */ + public void onDelayLoading() { + // Don't need to explicitly tell the AsyncImageView to stop loading since + // ImageRequests are not cancellable. + mShouldDelayLoad = true; + } + + /** + * Called by the consumer of this view to resume loading images + */ + public void onResumeLoading() { + if (mShouldDelayLoad) { + mShouldDelayLoad = false; + + // Notify all attached views to resume loading. + for (final AsyncImageView view : mAttachedViews) { + view.resumeLoading(); + } + mAttachedViews.clear(); + } + } + } +} diff --git a/src/com/android/messaging/ui/AttachmentPreview.java b/src/com/android/messaging/ui/AttachmentPreview.java new file mode 100644 index 0000000..7eea14b --- /dev/null +++ b/src/com/android/messaging/ui/AttachmentPreview.java @@ -0,0 +1,331 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.animation.Animator; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.graphics.Rect; +import android.os.Handler; +import android.os.Looper; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.ScrollView; + +import com.android.messaging.R; +import com.android.messaging.annotation.VisibleForAnimation; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.MediaPickerMessagePartData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.PendingAttachmentData; +import com.android.messaging.ui.MultiAttachmentLayout.OnAttachmentClickListener; +import com.android.messaging.ui.animation.PopupTransitionAnimation; +import com.android.messaging.ui.conversation.ComposeMessageView; +import com.android.messaging.ui.conversation.ConversationFragment; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ThreadUtil; +import com.android.messaging.util.UiUtils; + +import java.util.ArrayList; +import java.util.List; + +public class AttachmentPreview extends ScrollView implements OnAttachmentClickListener { + private FrameLayout mAttachmentView; + private ComposeMessageView mComposeMessageView; + private ImageButton mCloseButton; + private int mAnimatedHeight = -1; + private Animator mCloseGapAnimator; + private boolean mPendingFirstUpdate; + private Handler mHandler; + private Runnable mHideRunnable; + private boolean mPendingHideCanceled; + + private static final int CLOSE_BUTTON_REVEAL_STAGGER_MILLIS = 300; + + public AttachmentPreview(final Context context, final AttributeSet attrs) { + super(context, attrs); + mHandler = new Handler(Looper.getMainLooper()); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mCloseButton = (ImageButton) findViewById(R.id.close_button); + mCloseButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View view) { + mComposeMessageView.clearAttachments(); + } + }); + + mAttachmentView = (FrameLayout) findViewById(R.id.attachment_view); + + // The attachment preview is a scroll view so that it can show the bottom portion of the + // attachment whenever the space is tight (e.g. when in landscape mode). Per design + // request we'd like to make the attachment view always scrolled to the bottom. + addOnLayoutChangeListener(new OnLayoutChangeListener() { + @Override + public void onLayoutChange(final View v, final int left, final int top, final int right, + final int bottom, final int oldLeft, final int oldTop, final int oldRight, + final int oldBottom) { + post(new Runnable() { + @Override + public void run() { + final int childCount = getChildCount(); + if (childCount > 0) { + final View lastChild = getChildAt(childCount - 1); + scrollTo(getScrollX(), lastChild.getBottom() - getHeight()); + } + } + }); + } + }); + mPendingFirstUpdate = true; + } + + public void setComposeMessageView(final ComposeMessageView composeMessageView) { + mComposeMessageView = composeMessageView; + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + if (mAnimatedHeight >= 0) { + setMeasuredDimension(getMeasuredWidth(), mAnimatedHeight); + } + } + + private void cancelPendingHide() { + mPendingHideCanceled = true; + } + + public void hideAttachmentPreview() { + if (getVisibility() != GONE) { + UiUtils.revealOrHideViewWithAnimation(mCloseButton, GONE, + null /* onFinishRunnable */); + startCloseGapAnimationOnAttachmentClear(); + + if (mAttachmentView.getChildCount() > 0) { + mPendingHideCanceled = false; + final View viewToHide = mAttachmentView.getChildCount() > 1 ? + mAttachmentView : mAttachmentView.getChildAt(0); + UiUtils.revealOrHideViewWithAnimation(viewToHide, INVISIBLE, + new Runnable() { + @Override + public void run() { + // Only hide if we are didn't get overruled by showing + if (!mPendingHideCanceled) { + mAttachmentView.removeAllViews(); + setVisibility(GONE); + } + } + }); + } else { + mAttachmentView.removeAllViews(); + setVisibility(GONE); + } + } + } + + // returns true if we have attachments + public boolean onAttachmentsChanged(final DraftMessageData draftMessageData) { + final boolean isFirstUpdate = mPendingFirstUpdate; + final List attachments = draftMessageData.getReadOnlyAttachments(); + final List pendingAttachments = + draftMessageData.getReadOnlyPendingAttachments(); + + // Any change in attachments would invalidate the animated height animation. + cancelCloseGapAnimation(); + mPendingFirstUpdate = false; + + final int combinedAttachmentCount = attachments.size() + pendingAttachments.size(); + mCloseButton.setContentDescription(getResources() + .getQuantityString(R.plurals.attachment_preview_close_content_description, + combinedAttachmentCount)); + if (combinedAttachmentCount == 0) { + mHideRunnable = new Runnable() { + @Override + public void run() { + mHideRunnable = null; + // Only start the hiding if there are still no attachments + if (attachments.size() + pendingAttachments.size() == 0) { + hideAttachmentPreview(); + } + } + }; + if (draftMessageData.isSending()) { + // Wait to hide until the message is ready to start animating + // We'll execute immediately when the animation triggers + mHandler.postDelayed(mHideRunnable, + ConversationFragment.MESSAGE_ANIMATION_MAX_WAIT); + } else { + // Run immediately when clearing attachments + mHideRunnable.run(); + } + return false; + } + + cancelPendingHide(); // We're showing + if (getVisibility() != VISIBLE) { + setVisibility(VISIBLE); + mAttachmentView.setVisibility(VISIBLE); + + // Don't animate in the close button if this is the first update after view creation. + // This is the initial draft load from database for pre-existing drafts. + if (!isFirstUpdate) { + // Reveal the close button after the view animates in. + mCloseButton.setVisibility(INVISIBLE); + ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() { + @Override + public void run() { + UiUtils.revealOrHideViewWithAnimation(mCloseButton, VISIBLE, + null /* onFinishRunnable */); + } + }, UiUtils.MEDIAPICKER_TRANSITION_DURATION + CLOSE_BUTTON_REVEAL_STAGGER_MILLIS); + } + } + + // Merge the pending attachment list with real attachment. Design would prefer these be + // in LIFO order user can see added images past the 5th one but we also want them to be in + // order and we want it to be WYSIWYG. + final List combinedAttachments = new ArrayList<>(); + combinedAttachments.addAll(attachments); + combinedAttachments.addAll(pendingAttachments); + + final LayoutInflater layoutInflater = LayoutInflater.from(getContext()); + if (combinedAttachmentCount > 1) { + MultiAttachmentLayout multiAttachmentLayout = null; + Rect transitionRect = null; + if (mAttachmentView.getChildCount() > 0) { + final View firstChild = mAttachmentView.getChildAt(0); + if (firstChild instanceof MultiAttachmentLayout) { + Assert.equals(1, mAttachmentView.getChildCount()); + multiAttachmentLayout = (MultiAttachmentLayout) firstChild; + multiAttachmentLayout.bindAttachments(combinedAttachments, + null /* transitionRect */, combinedAttachmentCount); + } else { + transitionRect = new Rect(firstChild.getLeft(), firstChild.getTop(), + firstChild.getRight(), firstChild.getBottom()); + } + } + if (multiAttachmentLayout == null) { + multiAttachmentLayout = AttachmentPreviewFactory.createMultiplePreview( + getContext(), this); + multiAttachmentLayout.bindAttachments(combinedAttachments, transitionRect, + combinedAttachmentCount); + mAttachmentView.removeAllViews(); + mAttachmentView.addView(multiAttachmentLayout); + } + } else { + final MessagePartData attachment = combinedAttachments.get(0); + boolean shouldAnimate = true; + if (mAttachmentView.getChildCount() > 0) { + // If we are going from N->1 attachments, try to use the current bounds + // bounds as the starting rect. + shouldAnimate = false; + final View firstChild = mAttachmentView.getChildAt(0); + if (firstChild instanceof MultiAttachmentLayout && + attachment instanceof MediaPickerMessagePartData) { + final View leftoverView = ((MultiAttachmentLayout) firstChild) + .findViewForAttachment(attachment); + if (leftoverView != null) { + final Rect currentRect = UiUtils.getMeasuredBoundsOnScreen(leftoverView); + if (!currentRect.isEmpty() && + attachment instanceof MediaPickerMessagePartData) { + ((MediaPickerMessagePartData) attachment).setStartRect(currentRect); + shouldAnimate = true; + } + } + } + } + mAttachmentView.removeAllViews(); + final View attachmentView = AttachmentPreviewFactory.createAttachmentPreview( + layoutInflater, attachment, mAttachmentView, + AttachmentPreviewFactory.TYPE_SINGLE, true /* startImageRequest */, this); + if (attachmentView != null) { + mAttachmentView.addView(attachmentView); + if (shouldAnimate) { + tryAnimateViewIn(attachment, attachmentView); + } + } + } + return true; + } + + public void onMessageAnimationStart() { + if (mHideRunnable == null) { + return; + } + + // Run the hide animation at the same time as the message animation + mHandler.removeCallbacks(mHideRunnable); + setVisibility(View.INVISIBLE); + mHideRunnable.run(); + } + + static void tryAnimateViewIn(final MessagePartData attachmentData, final View view) { + if (attachmentData instanceof MediaPickerMessagePartData) { + final Rect startRect = ((MediaPickerMessagePartData) attachmentData).getStartRect(); + new PopupTransitionAnimation(startRect, view).startAfterLayoutComplete(); + } + } + + @VisibleForAnimation + public void setAnimatedHeight(final int animatedHeight) { + if (mAnimatedHeight != animatedHeight) { + mAnimatedHeight = animatedHeight; + requestLayout(); + } + } + + /** + * Kicks off an animation to animate the layout change for closing the gap between the + * message list and the compose message box when the attachments are cleared. + */ + private void startCloseGapAnimationOnAttachmentClear() { + // Cancel existing animation. + cancelCloseGapAnimation(); + mCloseGapAnimator = ObjectAnimator.ofInt(this, "animatedHeight", getHeight(), 0); + mCloseGapAnimator.start(); + } + + private void cancelCloseGapAnimation() { + if (mCloseGapAnimator != null) { + mCloseGapAnimator.cancel(); + mCloseGapAnimator = null; + } + mAnimatedHeight = -1; + } + + @Override + public boolean onAttachmentClick(final MessagePartData attachment, + final Rect viewBoundsOnScreen, final boolean longPress) { + if (longPress) { + mComposeMessageView.onAttachmentPreviewLongClicked(); + return true; + } + + if (!(attachment instanceof PendingAttachmentData) && attachment.isImage()) { + mComposeMessageView.displayPhoto(attachment.getContentUri(), viewBoundsOnScreen); + return true; + } + return false; + } +} diff --git a/src/com/android/messaging/ui/AttachmentPreviewFactory.java b/src/com/android/messaging/ui/AttachmentPreviewFactory.java new file mode 100644 index 0000000..ed5d4d7 --- /dev/null +++ b/src/com/android/messaging/ui/AttachmentPreviewFactory.java @@ -0,0 +1,299 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Rect; +import android.net.Uri; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.View.OnLongClickListener; +import android.view.ViewGroup; +import android.widget.FrameLayout.LayoutParams; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.PendingAttachmentData; +import com.android.messaging.datamodel.data.PersonItemData; +import com.android.messaging.datamodel.data.VCardContactItemData; +import com.android.messaging.datamodel.media.FileImageRequestDescriptor; +import com.android.messaging.datamodel.media.ImageRequest; +import com.android.messaging.datamodel.media.ImageRequestDescriptor; +import com.android.messaging.datamodel.media.UriImageRequestDescriptor; +import com.android.messaging.ui.MultiAttachmentLayout.OnAttachmentClickListener; +import com.android.messaging.ui.PersonItemView.PersonItemViewListener; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.UiUtils; +import com.android.messaging.util.UriUtil; + +/** + * A view factory that creates previews for single/multiple attachments. + */ +public class AttachmentPreviewFactory { + /** Standalone attachment preview */ + public static final int TYPE_SINGLE = 1; + + /** Attachment preview displayed in a multi-attachment layout */ + public static final int TYPE_MULTIPLE = 2; + + /** Attachment preview displayed in the attachment chooser grid view */ + public static final int TYPE_CHOOSER_GRID = 3; + + public static View createAttachmentPreview(final LayoutInflater layoutInflater, + final MessagePartData attachmentData, final ViewGroup parent, + final int viewType, final boolean startImageRequest, + @Nullable final OnAttachmentClickListener clickListener) { + final String contentType = attachmentData.getContentType(); + View attachmentView = null; + if (attachmentData instanceof PendingAttachmentData) { + attachmentView = createPendingAttachmentPreview(layoutInflater, parent, + (PendingAttachmentData) attachmentData); + } else if (ContentType.isImageType(contentType)) { + attachmentView = createImagePreview(layoutInflater, attachmentData, parent, viewType, + startImageRequest); + } else if (ContentType.isAudioType(contentType)) { + attachmentView = createAudioPreview(layoutInflater, attachmentData, parent, viewType); + } else if (ContentType.isVideoType(contentType)) { + attachmentView = createVideoPreview(layoutInflater, attachmentData, parent, viewType); + } else if (ContentType.isVCardType(contentType)) { + attachmentView = createVCardPreview(layoutInflater, attachmentData, parent, viewType); + } else { + Assert.fail("unsupported attachment type: " + contentType); + return null; + } + + // Some views have a caption, set the text/visibility if one exists + final TextView captionView = (TextView) attachmentView.findViewById(R.id.caption); + if (captionView != null) { + final String caption = attachmentData.getText(); + captionView.setVisibility(TextUtils.isEmpty(caption) ? View.GONE : View.VISIBLE); + captionView.setText(caption); + } + + if (attachmentView != null && clickListener != null) { + attachmentView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View view) { + final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(view); + clickListener.onAttachmentClick(attachmentData, bounds, + false /* longPress */); + } + }); + attachmentView.setOnLongClickListener(new OnLongClickListener() { + @Override + public boolean onLongClick(final View view) { + final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(view); + return clickListener.onAttachmentClick(attachmentData, bounds, + true /* longPress */); + } + }); + } + return attachmentView; + } + + public static MultiAttachmentLayout createMultiplePreview(final Context context, + final OnAttachmentClickListener listener) { + final MultiAttachmentLayout multiAttachmentLayout = + new MultiAttachmentLayout(context, null); + final ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + multiAttachmentLayout.setLayoutParams(layoutParams); + multiAttachmentLayout.setOnAttachmentClickListener(listener); + return multiAttachmentLayout; + } + + public static ImageRequestDescriptor getImageRequestDescriptorForAttachment( + final MessagePartData attachmentData, final int desiredWidth, final int desiredHeight) { + final Uri uri = attachmentData.getContentUri(); + final String contentType = attachmentData.getContentType(); + if (ContentType.isImageType(contentType)) { + final String filePath = UriUtil.getFilePathFromUri(uri); + if (filePath != null) { + return new FileImageRequestDescriptor(filePath, desiredWidth, desiredHeight, + attachmentData.getWidth(), attachmentData.getHeight(), + false /* canUseThumbnail */, true /* allowCompression */, + false /* isStatic */); + } else { + return new UriImageRequestDescriptor(uri, desiredWidth, desiredHeight, + attachmentData.getWidth(), attachmentData.getHeight(), + true /* allowCompression */, false /* isStatic */, false /*cropToCircle*/, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + } + } + return null; + } + + private static View createImagePreview(final LayoutInflater layoutInflater, + final MessagePartData attachmentData, final ViewGroup parent, + final int viewType, final boolean startImageRequest) { + int layoutId = R.layout.attachment_single_image; + switch (viewType) { + case AttachmentPreviewFactory.TYPE_SINGLE: + layoutId = R.layout.attachment_single_image; + break; + case AttachmentPreviewFactory.TYPE_MULTIPLE: + layoutId = R.layout.attachment_multiple_image; + break; + case AttachmentPreviewFactory.TYPE_CHOOSER_GRID: + layoutId = R.layout.attachment_chooser_image; + break; + default: + Assert.fail("unsupported attachment view type!"); + break; + } + final View view = layoutInflater.inflate(layoutId, parent, false /* attachToRoot */); + final AsyncImageView imageView = (AsyncImageView) view.findViewById( + R.id.attachment_image_view); + int maxWidth = imageView.getMaxWidth(); + int maxHeight = imageView.getMaxHeight(); + if (viewType == TYPE_CHOOSER_GRID) { + final Resources resources = layoutInflater.getContext().getResources(); + maxWidth = maxHeight = resources.getDimensionPixelSize( + R.dimen.attachment_grid_image_cell_size); + } + if (maxWidth <= 0 || maxWidth == Integer.MAX_VALUE) { + maxWidth = ImageRequest.UNSPECIFIED_SIZE; + } + if (maxHeight <= 0 || maxHeight == Integer.MAX_VALUE) { + maxHeight = ImageRequest.UNSPECIFIED_SIZE; + } + if (startImageRequest) { + imageView.setImageResourceId(getImageRequestDescriptorForAttachment(attachmentData, + maxWidth, maxHeight)); + } + imageView.setContentDescription( + parent.getResources().getString(R.string.message_image_content_description)); + return view; + } + + private static View createPendingAttachmentPreview(final LayoutInflater layoutInflater, + final ViewGroup parent, final PendingAttachmentData attachmentData) { + final View pendingItemView = layoutInflater.inflate(R.layout.attachment_pending_item, + parent, false); + final ImageView imageView = (ImageView) + pendingItemView.findViewById(R.id.pending_item_view); + final ViewGroup.LayoutParams layoutParams = imageView.getLayoutParams(); + final int defaultSize = layoutInflater.getContext().getResources().getDimensionPixelSize( + R.dimen.pending_attachment_size); + layoutParams.width = attachmentData.getWidth() == MessagePartData.UNSPECIFIED_SIZE ? + defaultSize : attachmentData.getWidth(); + layoutParams.height = attachmentData.getHeight() == MessagePartData.UNSPECIFIED_SIZE ? + defaultSize : attachmentData.getHeight(); + return pendingItemView; + } + + private static View createVCardPreview(final LayoutInflater layoutInflater, + final MessagePartData attachmentData, final ViewGroup parent, + final int viewType) { + int layoutId = R.layout.attachment_single_vcard; + switch (viewType) { + case AttachmentPreviewFactory.TYPE_SINGLE: + layoutId = R.layout.attachment_single_vcard; + break; + case AttachmentPreviewFactory.TYPE_MULTIPLE: + layoutId = R.layout.attachment_multiple_vcard; + break; + case AttachmentPreviewFactory.TYPE_CHOOSER_GRID: + layoutId = R.layout.attachment_chooser_vcard; + break; + default: + Assert.fail("unsupported attachment view type!"); + break; + } + final View view = layoutInflater.inflate(layoutId, parent, false /* attachToRoot */); + final PersonItemView vcardPreview = (PersonItemView) view.findViewById( + R.id.vcard_attachment_view); + vcardPreview.setAvatarOnly(viewType != AttachmentPreviewFactory.TYPE_SINGLE); + vcardPreview.bind(DataModel.get().createVCardContactItemData(layoutInflater.getContext(), + attachmentData)); + vcardPreview.setListener(new PersonItemViewListener() { + @Override + public void onPersonClicked(final PersonItemData data) { + Assert.isTrue(data instanceof VCardContactItemData); + final VCardContactItemData vCardData = (VCardContactItemData) data; + if (vCardData.hasValidVCard()) { + final Uri vCardUri = vCardData.getVCardUri(); + UIIntents.get().launchVCardDetailActivity(vcardPreview.getContext(), vCardUri); + } + } + + @Override + public boolean onPersonLongClicked(final PersonItemData data) { + return false; + } + }); + return view; + } + + private static View createAudioPreview(final LayoutInflater layoutInflater, + final MessagePartData attachmentData, final ViewGroup parent, + final int viewType) { + int layoutId = R.layout.attachment_single_audio; + switch (viewType) { + case AttachmentPreviewFactory.TYPE_SINGLE: + layoutId = R.layout.attachment_single_audio; + break; + case AttachmentPreviewFactory.TYPE_MULTIPLE: + layoutId = R.layout.attachment_multiple_audio; + break; + case AttachmentPreviewFactory.TYPE_CHOOSER_GRID: + layoutId = R.layout.attachment_chooser_audio; + break; + default: + Assert.fail("unsupported attachment view type!"); + break; + } + final View view = layoutInflater.inflate(layoutId, parent, false /* attachToRoot */); + final AudioAttachmentView audioView = (AudioAttachmentView) + view.findViewById(R.id.audio_attachment_view); + audioView.bindMessagePartData(attachmentData, false /* incoming */); + return view; + } + + private static View createVideoPreview(final LayoutInflater layoutInflater, + final MessagePartData attachmentData, final ViewGroup parent, + final int viewType) { + int layoutId = R.layout.attachment_single_video; + switch (viewType) { + case AttachmentPreviewFactory.TYPE_SINGLE: + layoutId = R.layout.attachment_single_video; + break; + case AttachmentPreviewFactory.TYPE_MULTIPLE: + layoutId = R.layout.attachment_multiple_video; + break; + case AttachmentPreviewFactory.TYPE_CHOOSER_GRID: + layoutId = R.layout.attachment_chooser_video; + break; + default: + Assert.fail("unsupported attachment view type!"); + break; + } + final VideoThumbnailView videoThumbnail = (VideoThumbnailView) layoutInflater.inflate( + layoutId, parent, false /* attachToRoot */); + videoThumbnail.setSource(attachmentData, false /* incomingMessage */); + return videoThumbnail; + } +} diff --git a/src/com/android/messaging/ui/AudioAttachmentPlayPauseButton.java b/src/com/android/messaging/ui/AudioAttachmentPlayPauseButton.java new file mode 100644 index 0000000..724c4fe --- /dev/null +++ b/src/com/android/messaging/ui/AudioAttachmentPlayPauseButton.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ImageView; +import android.widget.ViewSwitcher; + +import com.android.messaging.R; + +/** + * Shows a tinted play pause button. + */ +public class AudioAttachmentPlayPauseButton extends ViewSwitcher { + private ImageView mPlayButton; + private ImageView mPauseButton; + + private boolean mShowAsIncoming; + + public AudioAttachmentPlayPauseButton(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mPlayButton = (ImageView) findViewById(R.id.play_button); + mPauseButton = (ImageView) findViewById(R.id.pause_button); + updateAppearance(); + } + + public void setVisualStyle(final boolean showAsIncoming) { + if (mShowAsIncoming != showAsIncoming) { + mShowAsIncoming = showAsIncoming; + updateAppearance(); + } + } + + private void updateAppearance() { + mPlayButton.setImageDrawable(ConversationDrawables.get() + .getPlayButtonDrawable(mShowAsIncoming)); + mPauseButton.setImageDrawable(ConversationDrawables.get() + .getPauseButtonDrawable(mShowAsIncoming)); + } +} diff --git a/src/com/android/messaging/ui/AudioAttachmentView.java b/src/com/android/messaging/ui/AudioAttachmentView.java new file mode 100644 index 0000000..bb649b0 --- /dev/null +++ b/src/com/android/messaging/ui/AudioAttachmentView.java @@ -0,0 +1,329 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Path; +import android.graphics.RectF; +import android.media.AudioManager; +import android.media.MediaPlayer; +import android.media.MediaPlayer.OnCompletionListener; +import android.media.MediaPlayer.OnErrorListener; +import android.media.MediaPlayer.OnPreparedListener; +import android.net.Uri; +import android.os.SystemClock; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.ui.mediapicker.PausableChronometer; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.UiUtils; + +/** + * A reusable widget that hosts an audio player for audio attachment playback. This widget is used + * by both the media picker and the conversation message view to show audio attachments. + */ +public class AudioAttachmentView extends LinearLayout { + /** The normal layout mode where we have the play button, timer and progress bar */ + private static final int LAYOUT_MODE_NORMAL = 0; + + /** The compact layout mode with only the play button and the timer beneath it. Suitable + * for displaying in limited space such as multi-attachment layout */ + private static final int LAYOUT_MODE_COMPACT = 1; + + /** The sub-compact layout mode with only the play button. */ + private static final int LAYOUT_MODE_SUB_COMPACT = 2; + + private static final int PLAY_BUTTON = 0; + private static final int PAUSE_BUTTON = 1; + + private AudioAttachmentPlayPauseButton mPlayPauseButton; + private PausableChronometer mChronometer; + private AudioPlaybackProgressBar mProgressBar; + private MediaPlayer mMediaPlayer; + + private Uri mDataSourceUri; + + // The corner radius for drawing rounded corners. The default value is zero (no rounded corners) + private final int mCornerRadius; + private final Path mRoundedCornerClipPath; + private int mClipPathWidth; + private int mClipPathHeight; + + // Indicates whether the attachment view is to be styled as a part of an incoming message. + private boolean mShowAsIncoming; + + private boolean mPrepared; + private boolean mPlaybackFinished; + private final int mMode; + + public AudioAttachmentView(final Context context, final AttributeSet attrs) { + super(context, attrs); + final TypedArray typedAttributes = + context.obtainStyledAttributes(attrs, R.styleable.AudioAttachmentView); + mMode = typedAttributes.getInt(R.styleable.AudioAttachmentView_layoutMode, + LAYOUT_MODE_NORMAL); + final LayoutInflater inflater = LayoutInflater.from(getContext()); + inflater.inflate(R.layout.audio_attachment_view, this, true); + typedAttributes.recycle(); + + setWillNotDraw(mMode != LAYOUT_MODE_SUB_COMPACT); + mRoundedCornerClipPath = new Path(); + mCornerRadius = context.getResources().getDimensionPixelSize( + R.dimen.conversation_list_image_preview_corner_radius); + setContentDescription(context.getString(R.string.audio_attachment_content_description)); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + mPlayPauseButton = (AudioAttachmentPlayPauseButton) findViewById(R.id.play_pause_button); + mChronometer = (PausableChronometer) findViewById(R.id.timer); + mProgressBar = (AudioPlaybackProgressBar) findViewById(R.id.progress); + mPlayPauseButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View v) { + setupMediaPlayer(); + if (mMediaPlayer != null && mPrepared) { + if (mMediaPlayer.isPlaying()) { + mMediaPlayer.pause(); + mChronometer.pause(); + mProgressBar.pause(); + } else { + playAudio(); + } + } + updatePlayPauseButtonState(); + } + }); + updatePlayPauseButtonState(); + initializeViewsForMode(); + } + + /** + * Bind the audio attachment view with a MessagePartData. + * @param incoming indicates whether the attachment view is to be styled as a part of an + * incoming message. + */ + public void bindMessagePartData(final MessagePartData messagePartData, + final boolean incoming) { + Assert.isTrue(messagePartData == null || + ContentType.isAudioType(messagePartData.getContentType())); + final Uri contentUri = (messagePartData == null) ? null : messagePartData.getContentUri(); + bind(contentUri, incoming); + } + + public void bind(final Uri dataSourceUri, final boolean incoming) { + final String currentUriString = (mDataSourceUri == null) ? "" : mDataSourceUri.toString(); + final String newUriString = (dataSourceUri == null) ? "" : dataSourceUri.toString(); + mShowAsIncoming = incoming; + if (!TextUtils.equals(currentUriString, newUriString)) { + mDataSourceUri = dataSourceUri; + resetToZeroState(); + } + } + + private void playAudio() { + Assert.notNull(mMediaPlayer); + if (mPlaybackFinished) { + mMediaPlayer.seekTo(0); + mChronometer.restart(); + mProgressBar.restart(); + mPlaybackFinished = false; + } else { + mChronometer.resume(); + mProgressBar.resume(); + } + mMediaPlayer.start(); + } + + private void onAudioReplayError(final int what, final int extra, final Exception exception) { + if (exception == null) { + LogUtil.e(LogUtil.BUGLE_TAG, "audio replay failed, what=" + what + + ", extra=" + extra); + } else { + LogUtil.e(LogUtil.BUGLE_TAG, "audio replay failed, exception=" + exception); + } + UiUtils.showToastAtBottom(R.string.audio_recording_replay_failed); + releaseMediaPlayer(); + } + + private void setupMediaPlayer() { + Assert.notNull(mDataSourceUri); + if (mMediaPlayer == null) { + Assert.isTrue(!mPrepared); + mMediaPlayer = new MediaPlayer(); + try { + mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); + mMediaPlayer.setDataSource(Factory.get().getApplicationContext(), mDataSourceUri); + mMediaPlayer.setOnCompletionListener(new OnCompletionListener() { + @Override + public void onCompletion(final MediaPlayer mp) { + updatePlayPauseButtonState(); + mChronometer.reset(); + mChronometer.setBase(SystemClock.elapsedRealtime() - + mMediaPlayer.getDuration()); + mProgressBar.reset(); + + mPlaybackFinished = true; + } + }); + + mMediaPlayer.setOnPreparedListener(new OnPreparedListener() { + @Override + public void onPrepared(final MediaPlayer mp) { + // Set base on the chronometer so we can show the full length of the audio. + mChronometer.setBase(SystemClock.elapsedRealtime() - + mMediaPlayer.getDuration()); + mProgressBar.setDuration(mMediaPlayer.getDuration()); + mMediaPlayer.seekTo(0); + mPrepared = true; + } + }); + + mMediaPlayer.setOnErrorListener(new OnErrorListener() { + @Override + public boolean onError(final MediaPlayer mp, final int what, final int extra) { + onAudioReplayError(what, extra, null); + return true; + } + }); + mMediaPlayer.prepareAsync(); + } catch (final Exception exception) { + onAudioReplayError(0, 0, exception); + releaseMediaPlayer(); + } + } + } + + private void releaseMediaPlayer() { + if (mMediaPlayer != null) { + mMediaPlayer.release(); + mMediaPlayer = null; + mPrepared = false; + mPlaybackFinished = false; + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + releaseMediaPlayer(); + } + + @Override + protected void onDraw(final Canvas canvas) { + if (mMode != LAYOUT_MODE_SUB_COMPACT) { + return; + } + + final int currentWidth = this.getWidth(); + final int currentHeight = this.getHeight(); + if (mClipPathWidth != currentWidth || mClipPathHeight != currentHeight) { + final RectF rect = new RectF(0, 0, currentWidth, currentHeight); + mRoundedCornerClipPath.reset(); + mRoundedCornerClipPath.addRoundRect(rect, mCornerRadius, mCornerRadius, + Path.Direction.CW); + mClipPathWidth = currentWidth; + mClipPathHeight = currentHeight; + } + + canvas.clipPath(mRoundedCornerClipPath); + super.onDraw(canvas); + } + + private void updatePlayPauseButtonState() { + if (mMediaPlayer == null || !mMediaPlayer.isPlaying()) { + mPlayPauseButton.setDisplayedChild(PLAY_BUTTON); + } else { + mPlayPauseButton.setDisplayedChild(PAUSE_BUTTON); + } + } + + private void resetToZeroState() { + // Release the media player so it may be set up with the new audio source. + releaseMediaPlayer(); + mChronometer.reset(); + mProgressBar.reset(); + updateVisualStyle(); + + if (mDataSourceUri != null) { + // Re-ensure the media player, so we can read the duration of the audio. + setupMediaPlayer(); + } + } + + private void updateVisualStyle() { + if (mMode == LAYOUT_MODE_SUB_COMPACT) { + // Sub-compact mode has static visual appearance already set up during initialization. + return; + } + + if (mShowAsIncoming) { + mChronometer.setTextColor(getResources().getColor(R.color.message_text_color_incoming)); + } else { + mChronometer.setTextColor(getResources().getColor(R.color.message_text_color_outgoing)); + } + mProgressBar.setVisualStyle(mShowAsIncoming); + mPlayPauseButton.setVisualStyle(mShowAsIncoming); + updatePlayPauseButtonState(); + } + + private void initializeViewsForMode() { + switch (mMode) { + case LAYOUT_MODE_NORMAL: + setOrientation(HORIZONTAL); + mProgressBar.setVisibility(VISIBLE); + break; + + case LAYOUT_MODE_COMPACT: + setOrientation(VERTICAL); + mProgressBar.setVisibility(GONE); + ((MarginLayoutParams) mPlayPauseButton.getLayoutParams()).setMargins(0, 0, 0, 0); + ((MarginLayoutParams) mChronometer.getLayoutParams()).setMargins(0, 0, 0, 0); + break; + + case LAYOUT_MODE_SUB_COMPACT: + setOrientation(VERTICAL); + mProgressBar.setVisibility(GONE); + mChronometer.setVisibility(GONE); + ((MarginLayoutParams) mPlayPauseButton.getLayoutParams()).setMargins(0, 0, 0, 0); + final ImageView playButton = (ImageView) findViewById(R.id.play_button); + playButton.setImageDrawable( + getResources().getDrawable(R.drawable.ic_preview_play)); + final ImageView pauseButton = (ImageView) findViewById(R.id.pause_button); + pauseButton.setImageDrawable( + getResources().getDrawable(R.drawable.ic_preview_pause)); + break; + + default: + Assert.fail("Unsupported mode for AudioAttachmentView!"); + break; + } + } +} diff --git a/src/com/android/messaging/ui/AudioPlaybackProgressBar.java b/src/com/android/messaging/ui/AudioPlaybackProgressBar.java new file mode 100644 index 0000000..a5b3a57 --- /dev/null +++ b/src/com/android/messaging/ui/AudioPlaybackProgressBar.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.animation.ObjectAnimator; +import android.animation.TimeAnimator; +import android.animation.TimeAnimator.TimeListener; +import android.content.Context; +import android.graphics.drawable.ClipDrawable; +import android.graphics.drawable.Drawable; +import android.os.SystemClock; +import android.util.AttributeSet; +import android.view.Gravity; +import android.widget.ProgressBar; + +/** + * Shows a styled progress bar that is synchronized with the playback state of an audio attachment. + */ +public class AudioPlaybackProgressBar extends ProgressBar implements PlaybackStateView { + private long mDurationInMillis; + private final TimeAnimator mUpdateAnimator; + private long mCumulativeTime = 0; + private long mCurrentPlayStartTime = 0; + private boolean mIncoming = false; + + public AudioPlaybackProgressBar(final Context context, final AttributeSet attrs) { + super(context, attrs); + + mUpdateAnimator = new TimeAnimator(); + mUpdateAnimator.setRepeatCount(ObjectAnimator.INFINITE); + mUpdateAnimator.setTimeListener(new TimeListener() { + @Override + public void onTimeUpdate(final TimeAnimator animation, final long totalTime, + final long deltaTime) { + int progress = 0; + if (mDurationInMillis > 0) { + progress = (int) (((mCumulativeTime + SystemClock.elapsedRealtime() - + mCurrentPlayStartTime) * 1.0f / mDurationInMillis) * 100); + progress = Math.max(Math.min(progress, 100), 0); + } + setProgress(progress); + } + }); + updateAppearance(); + } + + /** + * Sets the duration of the audio that's being played, in milliseconds. + */ + public void setDuration(final long durationInMillis) { + mDurationInMillis = durationInMillis; + } + + @Override + public void restart() { + reset(); + resume(); + } + + @Override + public void reset() { + stopUpdateTicks(); + setProgress(0); + mCumulativeTime = 0; + mCurrentPlayStartTime = 0; + } + + @Override + public void resume() { + mCurrentPlayStartTime = SystemClock.elapsedRealtime(); + startUpdateTicks(); + } + + @Override + public void pause() { + mCumulativeTime += SystemClock.elapsedRealtime() - mCurrentPlayStartTime; + stopUpdateTicks(); + } + + private void startUpdateTicks() { + if (!mUpdateAnimator.isStarted()) { + mUpdateAnimator.start(); + } + } + + private void stopUpdateTicks() { + if (mUpdateAnimator.isStarted()) { + mUpdateAnimator.end(); + } + } + + private void updateAppearance() { + final Drawable drawable = + ConversationDrawables.get().getAudioProgressDrawable(mIncoming); + final ClipDrawable clipDrawable = new ClipDrawable(drawable, Gravity.START, + ClipDrawable.HORIZONTAL); + setProgressDrawable(clipDrawable); + setBackground(ConversationDrawables.get() + .getAudioProgressBackgroundDrawable(mIncoming)); + } + + public void setVisualStyle(final boolean incoming) { + if (mIncoming != incoming) { + mIncoming = incoming; + updateAppearance(); + } + } +} diff --git a/src/com/android/messaging/ui/BaseBugleActivity.java b/src/com/android/messaging/ui/BaseBugleActivity.java new file mode 100644 index 0000000..1236282 --- /dev/null +++ b/src/com/android/messaging/ui/BaseBugleActivity.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.app.Activity; +import android.os.Bundle; + +import com.android.messaging.util.BugleActivityUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.UiUtils; + +/** + * Base class for app activities that would normally derive from Activity. Responsible for + * ensuring app requirements are met during onResume() + */ +public class BaseBugleActivity extends Activity { + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (UiUtils.redirectToPermissionCheckIfNeeded(this)) { + return; + } + } + + @Override + protected void onResume() { + super.onResume(); + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onResume"); + BugleActivityUtil.onActivityResume(this, BaseBugleActivity.this); + } + + @Override + protected void onPause() { + super.onPause(); + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onPause"); + } +} diff --git a/src/com/android/messaging/ui/BaseBugleFragmentActivity.java b/src/com/android/messaging/ui/BaseBugleFragmentActivity.java new file mode 100644 index 0000000..947970f --- /dev/null +++ b/src/com/android/messaging/ui/BaseBugleFragmentActivity.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.app.Activity; + +import com.android.messaging.util.BugleActivityUtil; +import com.android.messaging.util.LogUtil; + +/** + * Base class for app activities that would normally derive from FragmentActivity. Responsible for + * ensuring app requirements are met during onResume() + */ +public class BaseBugleFragmentActivity extends Activity { + @Override + protected void onResume() { + super.onResume(); + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onResume"); + // Ensure we have a sufficient version of Google Play Services, prompting for upgrade and + // disabling the data updates if we don't have the correct version. + BugleActivityUtil.onActivityResume(this, BaseBugleFragmentActivity.this); + } + + @Override + protected void onPause() { + super.onPause(); + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onPause"); + } +} diff --git a/src/com/android/messaging/ui/BasePagerViewHolder.java b/src/com/android/messaging/ui/BasePagerViewHolder.java new file mode 100644 index 0000000..a82ecce --- /dev/null +++ b/src/com/android/messaging/ui/BasePagerViewHolder.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.os.Parcelable; +import android.view.View; +import android.view.ViewGroup; + +/** + * The base pager view holder implementation that handles basic view creation/destruction logic, + * as well as logic to save/restore instance state that's persisted not only for activity + * reconstruction (e.g. during a configuration change), but also cases where the individual + * page view gets destroyed and recreated. + * + * To opt into saving/restoring instance state behavior for a particular page view, let the + * PageView implement PersistentInstanceState to save and restore instance states to/from a + * Parcelable. + */ +public abstract class BasePagerViewHolder implements PagerViewHolder { + protected View mView; + protected Parcelable mSavedState; + + /** + * This is called when the entire view pager is being torn down (due to configuration change + * for example) that will be restored later. + */ + @Override + public Parcelable saveState() { + savePendingState(); + return mSavedState; + } + + /** + * This is called when the view pager is being restored. + */ + @Override + public void restoreState(final Parcelable restoredState) { + if (restoredState != null) { + mSavedState = restoredState; + // If the view is already there, let it restore the state. Otherwise, it will be + // restored the next time the view gets created. + restorePendingState(); + } + } + + @Override + public void resetState() { + mSavedState = null; + if (mView != null && (mView instanceof PersistentInstanceState)) { + ((PersistentInstanceState) mView).resetState(); + } + } + + /** + * This is called when the view itself is being torn down. This may happen when the user + * has flipped to another page in the view pager, so we want to save the current state if + * possible. + */ + @Override + public View destroyView() { + savePendingState(); + final View retView = mView; + mView = null; + return retView; + } + + @Override + public View getView(ViewGroup container) { + if (mView == null) { + mView = createView(container); + // When initially created, check if the view has any saved state. If so restore it. + restorePendingState(); + } + return mView; + } + + private void savePendingState() { + if (mView != null && (mView instanceof PersistentInstanceState)) { + mSavedState = ((PersistentInstanceState) mView).saveState(); + } + } + + private void restorePendingState() { + if (mView != null && (mView instanceof PersistentInstanceState) && (mSavedState != null)) { + ((PersistentInstanceState) mView).restoreState(mSavedState); + } + } + + /** + * Create and initialize a new page view. + */ + protected abstract View createView(ViewGroup container); +} diff --git a/src/com/android/messaging/ui/BlockedParticipantListItemView.java b/src/com/android/messaging/ui/BlockedParticipantListItemView.java new file mode 100644 index 0000000..2ccdebf --- /dev/null +++ b/src/com/android/messaging/ui/BlockedParticipantListItemView.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; +import android.support.v4.text.BidiFormatter; +import android.support.v4.text.TextDirectionHeuristicsCompat; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ParticipantListItemData; + +/** + * View for individual participant in blocked participants list. + * + * Unblocks participant when clicked. + */ +public class BlockedParticipantListItemView extends LinearLayout { + private TextView mNameTextView; + private ContactIconView mContactIconView; + private ParticipantListItemData mData; + + public BlockedParticipantListItemView(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + mNameTextView = (TextView) findViewById(R.id.name); + mContactIconView = (ContactIconView) findViewById(R.id.contact_icon); + setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View v) { + mData.unblock(getContext()); + } + }); + } + + public void bind(final ParticipantListItemData data) { + mData = data; + final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); + mNameTextView.setText(bidiFormatter.unicodeWrap( + data.getDisplayName(), TextDirectionHeuristicsCompat.LTR)); + mContactIconView.setImageResourceUri(data.getAvatarUri(), data.getContactId(), + data.getLookupKey(), data.getNormalizedDestination()); + mNameTextView.setText(data.getDisplayName()); + } +} diff --git a/src/com/android/messaging/ui/BlockedParticipantsActivity.java b/src/com/android/messaging/ui/BlockedParticipantsActivity.java new file mode 100644 index 0000000..b740264 --- /dev/null +++ b/src/com/android/messaging/ui/BlockedParticipantsActivity.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.app.Fragment; +import android.os.Bundle; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; + +/** + * Show a list of currently blocked participants. + */ +public class BlockedParticipantsActivity extends BugleActionBarActivity { + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.blocked_participants_activity); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + @Override + public void onAttachFragment(final Fragment fragment) { + Assert.isTrue(fragment instanceof BlockedParticipantsFragment); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + // Treat the home press as back press so that when we go back to + // ConversationActivity, it doesn't lose its original intent (conversation id etc.) + onBackPressed(); + return true; + + default: + return super.onOptionsItemSelected(item); + } + } +} diff --git a/src/com/android/messaging/ui/BlockedParticipantsFragment.java b/src/com/android/messaging/ui/BlockedParticipantsFragment.java new file mode 100644 index 0000000..3ab54ab --- /dev/null +++ b/src/com/android/messaging/ui/BlockedParticipantsFragment.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.app.Fragment; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.os.Bundle; +import android.support.v4.widget.CursorAdapter; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ListView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.BlockedParticipantsData; +import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.util.Assert; + +/** + * Show a list of currently blocked participants. + */ +public class BlockedParticipantsFragment extends Fragment + implements BlockedParticipantsDataListener { + private ListView mListView; + private BlockedParticipantListAdapter mAdapter; + private final Binding mBinding = + BindingBase.createBinding(this); + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final View view = + inflater.inflate(R.layout.blocked_participants_fragment, container, false); + mListView = (ListView) view.findViewById(android.R.id.list); + mAdapter = new BlockedParticipantListAdapter(getActivity(), null); + mListView.setAdapter(mAdapter); + mBinding.bind(DataModel.get().createBlockedParticipantsData(getActivity(), this)); + mBinding.getData().init(getLoaderManager(), mBinding); + return view; + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + } + + @Override + public void onDestroy() { + super.onDestroy(); + mBinding.unbind(); + } + + /** + * An adapter to display ParticipantListItemView based on ParticipantData. + */ + private class BlockedParticipantListAdapter extends CursorAdapter { + public BlockedParticipantListAdapter(final Context context, final Cursor cursor) { + super(context, cursor, 0); + } + + @Override + public View newView(Context context, Cursor cursor, ViewGroup parent) { + return LayoutInflater.from(context) + .inflate(R.layout.blocked_participant_list_item_view, parent, false); + } + + @Override + public void bindView(View view, Context context, Cursor cursor) { + Assert.isTrue(view instanceof BlockedParticipantListItemView); + ((BlockedParticipantListItemView) view).bind( + mBinding.getData().createParticipantListItemData(cursor)); + } + } + + @Override + public void onBlockedParticipantsCursorUpdated(Cursor cursor) { + mAdapter.swapCursor(cursor); + } +} diff --git a/src/com/android/messaging/ui/BugleActionBarActivity.java b/src/com/android/messaging/ui/BugleActionBarActivity.java new file mode 100644 index 0000000..80dabb8 --- /dev/null +++ b/src/com/android/messaging/ui/BugleActionBarActivity.java @@ -0,0 +1,356 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.graphics.drawable.ColorDrawable; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.support.v7.app.ActionBarActivity; +import android.view.ActionMode; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; + +import com.android.messaging.R; +import com.android.messaging.util.BugleActivityUtil; +import com.android.messaging.util.ImeUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.UiUtils; + +import java.util.HashSet; +import java.util.Set; + +/** + * Base class for app activities that use an action bar. Responsible for logging/telemetry/other + * needs that will be common for all activities. We can break out the common code if/when we need + * a version that doesn't use an actionbar. + */ +public class BugleActionBarActivity extends ActionBarActivity implements ImeUtil.ImeStateHost { + // Tracks the list of observers opting in for IME state change. + private final Set mImeStateObservers = new HashSet<>(); + + // Tracks the soft keyboard display state + private boolean mImeOpen; + + // The ActionMode that represents the modal contextual action bar, using our own implementation + // rather than the built in contextual action bar to reduce jank + private CustomActionMode mActionMode; + + // The menu for the action bar + private Menu mActionBarMenu; + + // Used to determine if a onDisplayHeightChanged was due to the IME opening or rotation of the + // device + private int mLastScreenHeight; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (UiUtils.redirectToPermissionCheckIfNeeded(this)) { + return; + } + + mLastScreenHeight = getResources().getDisplayMetrics().heightPixels; + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onCreate"); + } + } + + @Override + protected void onStart() { + super.onStart(); + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onStart"); + } + } + + @Override + protected void onRestart() { + super.onStop(); + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onRestart"); + } + } + + @Override + protected void onResume() { + super.onResume(); + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onResume"); + } + BugleActivityUtil.onActivityResume(this, BugleActionBarActivity.this); + } + + @Override + protected void onPause() { + super.onPause(); + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onPause"); + } + } + + @Override + protected void onStop() { + super.onStop(); + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onStop"); + } + } + + private boolean mDestroyed; + + @Override + protected void onDestroy() { + super.onDestroy(); + mDestroyed = true; + } + + public boolean getIsDestroyed() { + return mDestroyed; + } + + @Override + public void onDisplayHeightChanged(final int heightSpecification) { + int screenHeight = getResources().getDisplayMetrics().heightPixels; + + if (screenHeight != mLastScreenHeight) { + // Appears to be an orientation change, don't fire ime updates + mLastScreenHeight = screenHeight; + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onDisplayHeightChanged " + + " screenHeight: " + screenHeight + " lastScreenHeight: " + mLastScreenHeight + + " Skipped, appears to be orientation change."); + return; + } + final ActionBar actionBar = getSupportActionBar(); + if (actionBar != null && actionBar.isShowing()) { + screenHeight -= actionBar.getHeight(); + } + final int height = View.MeasureSpec.getSize(heightSpecification); + + final boolean imeWasOpen = mImeOpen; + mImeOpen = screenHeight - height > 100; + + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onDisplayHeightChanged " + + "imeWasOpen: " + imeWasOpen + " mImeOpen: " + mImeOpen + " screenHeight: " + + screenHeight + " height: " + height); + } + + if (imeWasOpen != mImeOpen) { + for (final ImeUtil.ImeStateObserver observer : mImeStateObservers) { + observer.onImeStateChanged(mImeOpen); + } + } + } + + @Override + public void registerImeStateObserver(final ImeUtil.ImeStateObserver observer) { + mImeStateObservers.add(observer); + } + + @Override + public void unregisterImeStateObserver(final ImeUtil.ImeStateObserver observer) { + mImeStateObservers.remove(observer); + } + + @Override + public boolean isImeOpen() { + return mImeOpen; + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + mActionBarMenu = menu; + if (mActionMode != null && + mActionMode.getCallback().onCreateActionMode(mActionMode, menu)) { + return true; + } + return false; + } + + @Override + public boolean onPrepareOptionsMenu(final Menu menu) { + mActionBarMenu = menu; + if (mActionMode != null && + mActionMode.getCallback().onPrepareActionMode(mActionMode, menu)) { + return true; + } + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem menuItem) { + if (mActionMode != null && + mActionMode.getCallback().onActionItemClicked(mActionMode, menuItem)) { + return true; + } + + switch (menuItem.getItemId()) { + case android.R.id.home: + if (mActionMode != null) { + dismissActionMode(); + return true; + } + } + return super.onOptionsItemSelected(menuItem); + } + + @Override + public ActionMode startActionMode(final ActionMode.Callback callback) { + mActionMode = new CustomActionMode(callback); + supportInvalidateOptionsMenu(); + invalidateActionBar(); + return mActionMode; + } + + public void dismissActionMode() { + if (mActionMode != null) { + mActionMode.finish(); + mActionMode = null; + invalidateActionBar(); + } + } + + public ActionMode getActionMode() { + return mActionMode; + } + + protected ActionMode.Callback getActionModeCallback() { + if (mActionMode == null) { + return null; + } + + return mActionMode.getCallback(); + } + + /** + * Receives and handles action bar invalidation request from sub-components of this activity. + * + *

Normally actions have sole control over the action bar, but in order to support seamless + * transitions for components such as the full screen media picker, we have to let it take over + * the action bar and then restore its state afterwards

+ * + *

If a fragment does anything that may change the action bar, it should call this method + * and then it is this method's responsibility to figure out which component "controls" the + * action bar and delegate the updating of the action bar to that component

+ */ + public final void invalidateActionBar() { + if (mActionMode != null) { + mActionMode.updateActionBar(getSupportActionBar()); + } else { + updateActionBar(getSupportActionBar()); + } + } + + protected void updateActionBar(final ActionBar actionBar) { + actionBar.setHomeAsUpIndicator(null); + } + + /** + * Custom ActionMode implementation which allows us to just replace the contents of the main + * action bar rather than overlay over it + */ + private class CustomActionMode extends ActionMode { + private CharSequence mTitle; + private CharSequence mSubtitle; + private View mCustomView; + private final Callback mCallback; + + public CustomActionMode(final Callback callback) { + mCallback = callback; + } + + @Override + public void setTitle(final CharSequence title) { + mTitle = title; + } + + @Override + public void setTitle(final int resId) { + mTitle = getResources().getString(resId); + } + + @Override + public void setSubtitle(final CharSequence subtitle) { + mSubtitle = subtitle; + } + + @Override + public void setSubtitle(final int resId) { + mSubtitle = getResources().getString(resId); + } + + @Override + public void setCustomView(final View view) { + mCustomView = view; + } + + @Override + public void invalidate() { + invalidateActionBar(); + } + + @Override + public void finish() { + mActionMode = null; + mCallback.onDestroyActionMode(this); + supportInvalidateOptionsMenu(); + invalidateActionBar(); + } + + @Override + public Menu getMenu() { + return mActionBarMenu; + } + + @Override + public CharSequence getTitle() { + return mTitle; + } + + @Override + public CharSequence getSubtitle() { + return mSubtitle; + } + + @Override + public View getCustomView() { + return mCustomView; + } + + @Override + public MenuInflater getMenuInflater() { + return BugleActionBarActivity.this.getMenuInflater(); + } + + public Callback getCallback() { + return mCallback; + } + + public void updateActionBar(final ActionBar actionBar) { + actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP); + actionBar.setDisplayShowTitleEnabled(false); + actionBar.setDisplayShowCustomEnabled(false); + mActionMode.getCallback().onPrepareActionMode(mActionMode, mActionBarMenu); + actionBar.setBackgroundDrawable(new ColorDrawable( + getResources().getColor(R.color.contextual_action_bar_background_color))); + actionBar.setHomeAsUpIndicator(R.drawable.ic_cancel_small_dark); + actionBar.show(); + } + } +} diff --git a/src/com/android/messaging/ui/BugleAnimationTags.java b/src/com/android/messaging/ui/BugleAnimationTags.java new file mode 100644 index 0000000..b141f5b --- /dev/null +++ b/src/com/android/messaging/ui/BugleAnimationTags.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + + +/** + * Defines a list of animation tags used as android:transitionName properties used for L's + * hero transitions. + */ +public class BugleAnimationTags { + /** + * The tag for the FAB in the conversation list activity. + */ + public static final String TAG_FABICON = "bugle:fabicon"; + + /** + * The tag for the content view of a conversation list item view. + */ + public static final String TAG_CLIVCONTENT = "bugle:clivcontent"; + + /** + * The tag for the action bar. + */ + public static final String TAG_ACTIONBAR = "bugle:actionbar"; +} diff --git a/src/com/android/messaging/ui/ClassZeroActivity.java b/src/com/android/messaging/ui/ClassZeroActivity.java new file mode 100644 index 0000000..129ec19 --- /dev/null +++ b/src/com/android/messaging/ui/ClassZeroActivity.java @@ -0,0 +1,205 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.ContentValues; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.os.SystemClock; +import android.provider.Telephony.Sms; +import android.text.TextUtils; +import android.util.Log; +import android.view.Window; + +import com.android.messaging.R; +import com.android.messaging.datamodel.action.ReceiveSmsMessageAction; +import com.android.messaging.util.Assert; + +import java.util.ArrayList; + +/** + * Display a class-zero SMS message to the user. Wait for the user to dismiss + * it. + */ +public class ClassZeroActivity extends Activity { + private static final boolean VERBOSE = false; + private static final String TAG = "display_00"; + private static final int ON_AUTO_SAVE = 1; + + /** Default timer to dismiss the dialog. */ + private static final long DEFAULT_TIMER = 5 * 60 * 1000; + + /** To remember the exact time when the timer should fire. */ + private static final String TIMER_FIRE = "timer_fire"; + + private ContentValues mMessageValues = null; + + /** Is the message read. */ + private boolean mRead = false; + + /** The timer to dismiss the dialog automatically. */ + private long mTimerSet = 0; + private AlertDialog mDialog = null; + + private ArrayList mMessageQueue = null; + + private final Handler mHandler = new Handler() { + @Override + public void handleMessage(final Message msg) { + // Do not handle an invalid message. + if (msg.what == ON_AUTO_SAVE) { + mRead = false; + mDialog.dismiss(); + saveMessage(); + processNextMessage(); + } + } + }; + + private boolean queueMsgFromIntent(final Intent msgIntent) { + final ContentValues messageValues = + msgIntent.getParcelableExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_VALUES); + // that takes the format argument is a hidden API right now. + final String message = messageValues.getAsString(Sms.BODY); + if (TextUtils.isEmpty(message)) { + if (mMessageQueue.size() == 0) { + finish(); + } + return false; + } + mMessageQueue.add(messageValues); + return true; + } + + private void processNextMessage() { + if (mMessageQueue.size() > 0) { + mMessageQueue.remove(0); + } + if (mMessageQueue.size() == 0) { + finish(); + } else { + displayZeroMessage(mMessageQueue.get(0)); + } + } + + private void saveMessage() { + mMessageValues.put(Sms.Inbox.READ, mRead ? Integer.valueOf(1) : Integer.valueOf(0)); + final ReceiveSmsMessageAction action = new ReceiveSmsMessageAction(mMessageValues); + action.start(); + } + + @Override + protected void onCreate(final Bundle icicle) { + super.onCreate(icicle); + requestWindowFeature(Window.FEATURE_NO_TITLE); + + if (mMessageQueue == null) { + mMessageQueue = new ArrayList(); + } + if (!queueMsgFromIntent(getIntent())) { + return; + } + Assert.isTrue(mMessageQueue.size() == 1); + if (mMessageQueue.size() == 1) { + displayZeroMessage(mMessageQueue.get(0)); + } + + if (icicle != null) { + mTimerSet = icicle.getLong(TIMER_FIRE, mTimerSet); + } + } + + private void displayZeroMessage(final ContentValues messageValues) { + /* This'll be used by the save action */ + mMessageValues = messageValues; + final String message = messageValues.getAsString(Sms.BODY);; + + mDialog = new AlertDialog.Builder(this).setMessage(message) + .setPositiveButton(R.string.save, mSaveListener) + .setNegativeButton(android.R.string.cancel, mCancelListener) + .setTitle(R.string.class_0_message_activity) + .setCancelable(false).show(); + final long now = SystemClock.uptimeMillis(); + mTimerSet = now + DEFAULT_TIMER; + } + + @Override + protected void onNewIntent(final Intent msgIntent) { + // Running with another visible message, queue this one + queueMsgFromIntent(msgIntent); + } + + @Override + protected void onStart() { + super.onStart(); + final long now = SystemClock.uptimeMillis(); + if (mTimerSet <= now) { + // Save the message if the timer already expired. + mHandler.sendEmptyMessage(ON_AUTO_SAVE); + } else { + mHandler.sendEmptyMessageAtTime(ON_AUTO_SAVE, mTimerSet); + if (VERBOSE) { + Log.d(TAG, "onRestart time = " + Long.toString(mTimerSet) + " " + + this.toString()); + } + } + } + + @Override + protected void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + outState.putLong(TIMER_FIRE, mTimerSet); + if (VERBOSE) { + Log.d(TAG, "onSaveInstanceState time = " + Long.toString(mTimerSet) + + " " + this.toString()); + } + } + + @Override + protected void onStop() { + super.onStop(); + mHandler.removeMessages(ON_AUTO_SAVE); + if (VERBOSE) { + Log.d(TAG, "onStop time = " + Long.toString(mTimerSet) + + " " + this.toString()); + } + } + + private final OnClickListener mCancelListener = new OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int whichButton) { + dialog.dismiss(); + processNextMessage(); + } + }; + + private final OnClickListener mSaveListener = new OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int whichButton) { + mRead = true; + saveMessage(); + dialog.dismiss(); + processNextMessage(); + } + }; +} diff --git a/src/com/android/messaging/ui/CompositeAdapter.java b/src/com/android/messaging/ui/CompositeAdapter.java new file mode 100644 index 0000000..620e511 --- /dev/null +++ b/src/com/android/messaging/ui/CompositeAdapter.java @@ -0,0 +1,288 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; +import android.database.DataSetObserver; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; + +/** + * A general purpose adapter that composes one or more other adapters. It + * appends them in the order they are added. + */ +public class CompositeAdapter extends BaseAdapter { + + private static final int INITIAL_CAPACITY = 2; + + public static class Partition { + boolean mShowIfEmpty; + boolean mHasHeader; + BaseAdapter mAdapter; + + public Partition(final boolean showIfEmpty, final boolean hasHeader, + final BaseAdapter adapter) { + this.mShowIfEmpty = showIfEmpty; + this.mHasHeader = hasHeader; + this.mAdapter = adapter; + } + + /** + * True if the directory should be shown even if no contacts are found. + */ + public boolean showIfEmpty() { + return mShowIfEmpty; + } + + public boolean hasHeader() { + return mHasHeader; + } + + public int getCount() { + int count = mAdapter.getCount(); + if (mHasHeader && (count != 0 || mShowIfEmpty)) { + count++; + } + return count; + } + + public BaseAdapter getAdapter() { + return mAdapter; + } + + public View getHeaderView(final View convertView, final ViewGroup parentView) { + return null; + } + + public void close() { + // do nothing in base class. + } + } + + private class Observer extends DataSetObserver { + @Override + public void onChanged() { + CompositeAdapter.this.notifyDataSetChanged(); + } + + @Override + public void onInvalidated() { + CompositeAdapter.this.notifyDataSetInvalidated(); + } + }; + + protected final Context mContext; + private Partition[] mPartitions; + private int mSize = 0; + private int mCount = 0; + private boolean mCacheValid = true; + private final Observer mObserver; + + public CompositeAdapter(final Context context) { + mContext = context; + mObserver = new Observer(); + mPartitions = new Partition[INITIAL_CAPACITY]; + } + + public Context getContext() { + return mContext; + } + + public void addPartition(final Partition partition) { + if (mSize >= mPartitions.length) { + final int newCapacity = mSize + 2; + final Partition[] newAdapters = new Partition[newCapacity]; + System.arraycopy(mPartitions, 0, newAdapters, 0, mSize); + mPartitions = newAdapters; + } + mPartitions[mSize++] = partition; + partition.getAdapter().registerDataSetObserver(mObserver); + invalidate(); + notifyDataSetChanged(); + } + + public void removePartition(final int index) { + final Partition partition = mPartitions[index]; + partition.close(); + System.arraycopy(mPartitions, index + 1, mPartitions, index, + mSize - index - 1); + mSize--; + partition.getAdapter().unregisterDataSetObserver(mObserver); + invalidate(); + notifyDataSetChanged(); + } + + public void clearPartitions() { + for (int i = 0; i < mSize; i++) { + final Partition partition = mPartitions[i]; + partition.close(); + partition.getAdapter().unregisterDataSetObserver(mObserver); + } + invalidate(); + notifyDataSetChanged(); + } + + public Partition getPartition(final int index) { + return mPartitions[index]; + } + + public int getPartitionAtPosition(final int position) { + ensureCacheValid(); + int start = 0; + for (int i = 0; i < mSize; i++) { + final int end = start + mPartitions[i].getCount(); + if (position >= start && position < end) { + int offset = position - start; + if (mPartitions[i].hasHeader() && + (mPartitions[i].getCount() > 0 || mPartitions[i].showIfEmpty())) { + offset--; + } + if (offset == -1) { + return -1; + } + return i; + } + start = end; + } + return mSize - 1; + } + + public int getPartitionCount() { + return mSize; + } + + public void invalidate() { + mCacheValid = false; + } + + private void ensureCacheValid() { + if (mCacheValid) { + return; + } + mCount = 0; + for (int i = 0; i < mSize; i++) { + mCount += mPartitions[i].getCount(); + } + } + + @Override + public int getCount() { + ensureCacheValid(); + return mCount; + } + + public int getCount(final int index) { + ensureCacheValid(); + return mPartitions[index].getCount(); + } + + @Override + public Object getItem(final int position) { + ensureCacheValid(); + int start = 0; + for (int i = 0; i < mSize; i++) { + final int end = start + mPartitions[i].getCount(); + if (position >= start && position < end) { + final int offset = position - start; + final Partition partition = mPartitions[i]; + if (partition.hasHeader() && offset == 0 && + (partition.getCount() > 0 || partition.showIfEmpty())) { + // This is the header + return null; + } + return mPartitions[i].getAdapter().getItem(offset); + } + start = end; + } + + return null; + } + + @Override + public long getItemId(final int position) { + ensureCacheValid(); + int start = 0; + for (int i = 0; i < mSize; i++) { + final int end = start + mPartitions[i].getCount(); + if (position >= start && position < end) { + final int offset = position - start; + final Partition partition = mPartitions[i]; + if (partition.hasHeader() && offset == 0 && + (partition.getCount() > 0 || partition.showIfEmpty())) { + // Header + return 0; + } + return mPartitions[i].getAdapter().getItemId(offset); + } + start = end; + } + + return 0; + } + + @Override + public boolean isEnabled(int position) { + ensureCacheValid(); + int start = 0; + for (int i = 0; i < mSize; i++) { + final int end = start + mPartitions[i].getCount(); + if (position >= start && position < end) { + final int offset = position - start; + final Partition partition = mPartitions[i]; + if (partition.hasHeader() && offset == 0 && + (partition.getCount() > 0 || partition.showIfEmpty())) { + // This is the header + return false; + } + return true; + } + start = end; + } + return true; + } + + @Override + public View getView(final int position, final View convertView, final ViewGroup parentView) { + ensureCacheValid(); + int start = 0; + for (int i = 0; i < mSize; i++) { + final Partition partition = mPartitions[i]; + final int end = start + partition.getCount(); + if (position >= start && position < end) { + int offset = position - start; + View view; + if (partition.hasHeader() && + (partition.getCount() > 0 || partition.showIfEmpty())) { + offset = offset - 1; + } + if (offset == -1) { + view = partition.getHeaderView(convertView, parentView); + } else { + view = partition.getAdapter().getView(offset, convertView, parentView); + } + if (view == null) { + throw new NullPointerException("View should not be null, partition: " + i + + " position: " + offset); + } + return view; + } + start = end; + } + + throw new ArrayIndexOutOfBoundsException(position); + } +} diff --git a/src/com/android/messaging/ui/ContactIconView.java b/src/com/android/messaging/ui/ContactIconView.java new file mode 100644 index 0000000..44983ab --- /dev/null +++ b/src/com/android/messaging/ui/ContactIconView.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.net.Uri; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.media.AvatarGroupRequestDescriptor; +import com.android.messaging.datamodel.media.AvatarRequestDescriptor; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ContactUtil; + +/** + * A view used to render contact icons. This class derives from AsyncImageView, so it loads contact + * icons from MediaResourceManager, and it handles more rendering logic than an AsyncImageView + * (draws a circular bitmap). + */ +public class ContactIconView extends AsyncImageView { + private static final int NORMAL_ICON_SIZE_ID = 0; + private static final int LARGE_ICON_SIZE_ID = 1; + private static final int SMALL_ICON_SIZE_ID = 2; + + protected final int mIconSize; + private final int mColorPressedId; + + private long mContactId; + private String mContactLookupKey; + private String mNormalizedDestination; + private Uri mAvatarUri; + private boolean mDisableClickHandler; + + public ContactIconView(final Context context, final AttributeSet attrs) { + super(context, attrs); + + final Resources resources = context.getResources(); + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ContactIconView); + + final int iconSizeId = a.getInt(R.styleable.ContactIconView_iconSize, 0); + switch (iconSizeId) { + case NORMAL_ICON_SIZE_ID: + mIconSize = (int) resources.getDimension( + R.dimen.contact_icon_view_normal_size); + break; + case LARGE_ICON_SIZE_ID: + mIconSize = (int) resources.getDimension( + R.dimen.contact_icon_view_large_size); + break; + case SMALL_ICON_SIZE_ID: + mIconSize = (int) resources.getDimension( + R.dimen.contact_icon_view_small_size); + break; + default: + // For the compiler, something has to be set even with the assert. + mIconSize = 0; + Assert.fail("Unsupported ContactIconView icon size attribute"); + } + mColorPressedId = resources.getColor(R.color.contact_avatar_pressed_color); + + setImage(null); + a.recycle(); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + setColorFilter(mColorPressedId); + } else { + clearColorFilter(); + } + return super.onTouchEvent(event); + } + + /** + * Method which allows the automatic hookup of a click handler when the Uri is changed + */ + public void setImageClickHandlerDisabled(final boolean isHandlerDisabled) { + mDisableClickHandler = isHandlerDisabled; + setOnClickListener(null); + setClickable(false); + } + + /** + * A convenience method that sets the URI of the contact icon by creating a new image request. + */ + public void setImageResourceUri(final Uri uri) { + setImageResourceUri(uri, 0, null, null); + } + + public void setImageResourceUri(final Uri uri, final long contactId, + final String contactLookupKey, final String normalizedDestination) { + if (uri == null) { + setImageResourceId(null); + } else { + final String avatarType = AvatarUriUtil.getAvatarType(uri); + if (AvatarUriUtil.TYPE_GROUP_URI.equals(avatarType)) { + setImageResourceId(new AvatarGroupRequestDescriptor(uri, mIconSize, mIconSize)); + } else { + setImageResourceId(new AvatarRequestDescriptor(uri, mIconSize, mIconSize)); + } + } + + mContactId = contactId; + mContactLookupKey = contactLookupKey; + mNormalizedDestination = normalizedDestination; + mAvatarUri = uri; + + maybeInitializeOnClickListener(); + } + + protected void maybeInitializeOnClickListener() { + if ((mContactId > ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED + && !TextUtils.isEmpty(mContactLookupKey)) || + !TextUtils.isEmpty(mNormalizedDestination)) { + if (!mDisableClickHandler) { + setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + ContactUtil.showOrAddContact(view, mContactId, mContactLookupKey, + mAvatarUri, mNormalizedDestination); + } + }); + } + } else { + // This should happen when the phone number is not in the user's contacts or it is a + // group conversation, group conversations don't have contact phone numbers. If this + // is the case then absorb the click to prevent propagation. + setOnClickListener(null); + } + } +} diff --git a/src/com/android/messaging/ui/ConversationDrawables.java b/src/com/android/messaging/ui/ConversationDrawables.java new file mode 100644 index 0000000..cf858e2 --- /dev/null +++ b/src/com/android/messaging/ui/ConversationDrawables.java @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.drawable.Drawable; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.util.ImageUtils; + +/** + * A singleton cache that holds tinted drawable resources for displaying messages, such as + * message bubbles, audio attachments etc. + */ +public class ConversationDrawables { + private static ConversationDrawables sInstance; + + // Cache the color filtered bubble drawables so that we don't need to create a + // new one for each ConversationMessageView. + private Drawable mIncomingBubbleDrawable; + private Drawable mOutgoingBubbleDrawable; + private Drawable mIncomingErrorBubbleDrawable; + private Drawable mIncomingBubbleNoArrowDrawable; + private Drawable mOutgoingBubbleNoArrowDrawable; + private Drawable mAudioPlayButtonDrawable; + private Drawable mAudioPauseButtonDrawable; + private Drawable mIncomingAudioProgressBackgroundDrawable; + private Drawable mOutgoingAudioProgressBackgroundDrawable; + private Drawable mAudioProgressForegroundDrawable; + private Drawable mFastScrollThumbDrawable; + private Drawable mFastScrollThumbPressedDrawable; + private Drawable mFastScrollPreviewDrawableLeft; + private Drawable mFastScrollPreviewDrawableRight; + private final Context mContext; + private int mOutgoingBubbleColor; + private int mIncomingErrorBubbleColor; + private int mIncomingAudioButtonColor; + private int mSelectedBubbleColor; + private int mThemeColor; + + public static ConversationDrawables get() { + if (sInstance == null) { + sInstance = new ConversationDrawables(Factory.get().getApplicationContext()); + } + return sInstance; + } + + private ConversationDrawables(final Context context) { + mContext = context; + // Pre-create all the drawables. + updateDrawables(); + } + + public int getConversationThemeColor() { + return mThemeColor; + } + + public void updateDrawables() { + final Resources resources = mContext.getResources(); + + mIncomingBubbleDrawable = resources.getDrawable(R.drawable.msg_bubble_incoming); + mIncomingBubbleNoArrowDrawable = + resources.getDrawable(R.drawable.message_bubble_incoming_no_arrow); + mIncomingErrorBubbleDrawable = resources.getDrawable(R.drawable.msg_bubble_error); + mOutgoingBubbleDrawable = resources.getDrawable(R.drawable.msg_bubble_outgoing); + mOutgoingBubbleNoArrowDrawable = + resources.getDrawable(R.drawable.message_bubble_outgoing_no_arrow); + mAudioPlayButtonDrawable = resources.getDrawable(R.drawable.ic_audio_play); + mAudioPauseButtonDrawable = resources.getDrawable(R.drawable.ic_audio_pause); + mIncomingAudioProgressBackgroundDrawable = + resources.getDrawable(R.drawable.audio_progress_bar_background_incoming); + mOutgoingAudioProgressBackgroundDrawable = + resources.getDrawable(R.drawable.audio_progress_bar_background_outgoing); + mAudioProgressForegroundDrawable = + resources.getDrawable(R.drawable.audio_progress_bar_progress); + mFastScrollThumbDrawable = resources.getDrawable(R.drawable.fastscroll_thumb); + mFastScrollThumbPressedDrawable = + resources.getDrawable(R.drawable.fastscroll_thumb_pressed); + mFastScrollPreviewDrawableLeft = + resources.getDrawable(R.drawable.fastscroll_preview_left); + mFastScrollPreviewDrawableRight = + resources.getDrawable(R.drawable.fastscroll_preview_right); + mOutgoingBubbleColor = resources.getColor(R.color.message_bubble_color_outgoing); + mIncomingErrorBubbleColor = + resources.getColor(R.color.message_error_bubble_color_incoming); + mIncomingAudioButtonColor = + resources.getColor(R.color.message_audio_button_color_incoming); + mSelectedBubbleColor = resources.getColor(R.color.message_bubble_color_selected); + mThemeColor = resources.getColor(R.color.primary_color); + } + + public Drawable getBubbleDrawable(final boolean selected, final boolean incoming, + final boolean needArrow, final boolean isError) { + final Drawable protoDrawable; + if (needArrow) { + if (incoming) { + protoDrawable = isError && !selected ? + mIncomingErrorBubbleDrawable : mIncomingBubbleDrawable; + } else { + protoDrawable = mOutgoingBubbleDrawable; + } + } else if (incoming) { + protoDrawable = mIncomingBubbleNoArrowDrawable; + } else { + protoDrawable = mOutgoingBubbleNoArrowDrawable; + } + + int color; + if (selected) { + color = mSelectedBubbleColor; + } else if (incoming) { + if (isError) { + color = mIncomingErrorBubbleColor; + } else { + color = mThemeColor; + } + } else { + color = mOutgoingBubbleColor; + } + + return ImageUtils.getTintedDrawable(mContext, protoDrawable, color); + } + + private int getAudioButtonColor(final boolean incoming) { + return incoming ? mIncomingAudioButtonColor : mThemeColor; + } + + public Drawable getPlayButtonDrawable(final boolean incoming) { + return ImageUtils.getTintedDrawable( + mContext, mAudioPlayButtonDrawable, getAudioButtonColor(incoming)); + } + + public Drawable getPauseButtonDrawable(final boolean incoming) { + return ImageUtils.getTintedDrawable( + mContext, mAudioPauseButtonDrawable, getAudioButtonColor(incoming)); + } + + public Drawable getAudioProgressDrawable(final boolean incoming) { + return ImageUtils.getTintedDrawable( + mContext, mAudioProgressForegroundDrawable, getAudioButtonColor(incoming)); + } + + public Drawable getAudioProgressBackgroundDrawable(final boolean incoming) { + return incoming ? mIncomingAudioProgressBackgroundDrawable : + mOutgoingAudioProgressBackgroundDrawable; + } + + public Drawable getFastScrollThumbDrawable(final boolean pressed) { + if (pressed) { + return ImageUtils.getTintedDrawable(mContext, mFastScrollThumbPressedDrawable, + mThemeColor); + } else { + return mFastScrollThumbDrawable; + } + } + + public Drawable getFastScrollPreviewDrawable(boolean positionRight) { + Drawable protoDrawable = positionRight ? mFastScrollPreviewDrawableRight : + mFastScrollPreviewDrawableLeft; + return ImageUtils.getTintedDrawable(mContext, protoDrawable, mThemeColor); + } +} diff --git a/src/com/android/messaging/ui/CursorRecyclerAdapter.java b/src/com/android/messaging/ui/CursorRecyclerAdapter.java new file mode 100644 index 0000000..f1a7b7d --- /dev/null +++ b/src/com/android/messaging/ui/CursorRecyclerAdapter.java @@ -0,0 +1,333 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.content.Context; +import android.database.ContentObserver; +import android.database.Cursor; +import android.database.DataSetObserver; +import android.os.Handler; +import android.support.v7.widget.RecyclerView; +import android.util.Log; +import android.view.ViewGroup; +import android.widget.FilterQueryProvider; + +/** + * Copy of CursorAdapter suited for RecyclerView. + * + * TODO: BUG 16327984. Replace this with a framework supported CursorAdapter for + * RecyclerView when one is available. + */ +public abstract class CursorRecyclerAdapter + extends RecyclerView.Adapter { + /** + * This field should be made private, so it is hidden from the SDK. + * {@hide} + */ + protected boolean mDataValid; + /** + * This field should be made private, so it is hidden from the SDK. + * {@hide} + */ + protected boolean mAutoRequery; + /** + * This field should be made private, so it is hidden from the SDK. + * {@hide} + */ + protected Cursor mCursor; + /** + * This field should be made private, so it is hidden from the SDK. + * {@hide} + */ + protected Context mContext; + /** + * This field should be made private, so it is hidden from the SDK. + * {@hide} + */ + protected int mRowIDColumn; + /** + * This field should be made private, so it is hidden from the SDK. + * {@hide} + */ + protected ChangeObserver mChangeObserver; + /** + * This field should be made private, so it is hidden from the SDK. + * {@hide} + */ + protected DataSetObserver mDataSetObserver; + /** + * This field should be made private, so it is hidden from the SDK. + * {@hide} + */ + protected FilterQueryProvider mFilterQueryProvider; + + /** + * If set the adapter will call requery() on the cursor whenever a content change + * notification is delivered. Implies {@link #FLAG_REGISTER_CONTENT_OBSERVER}. + * + * @deprecated This option is discouraged, as it results in Cursor queries + * being performed on the application's UI thread and thus can cause poor + * responsiveness or even Application Not Responding errors. As an alternative, + * use {@link android.app.LoaderManager} with a {@link android.content.CursorLoader}. + */ + @Deprecated + public static final int FLAG_AUTO_REQUERY = 0x01; + + /** + * If set the adapter will register a content observer on the cursor and will call + * {@link #onContentChanged()} when a notification comes in. Be careful when + * using this flag: you will need to unset the current Cursor from the adapter + * to avoid leaks due to its registered observers. This flag is not needed + * when using a CursorAdapter with a + * {@link android.content.CursorLoader}. + */ + public static final int FLAG_REGISTER_CONTENT_OBSERVER = 0x02; + + /** + * Recommended constructor. + * + * @param c The cursor from which to get the data. + * @param context The context + * @param flags Flags used to determine the behavior of the adapter; may + * be any combination of {@link #FLAG_AUTO_REQUERY} and + * {@link #FLAG_REGISTER_CONTENT_OBSERVER}. + */ + public CursorRecyclerAdapter(final Context context, final Cursor c, final int flags) { + init(context, c, flags); + } + + void init(final Context context, final Cursor c, int flags) { + if ((flags & FLAG_AUTO_REQUERY) == FLAG_AUTO_REQUERY) { + flags |= FLAG_REGISTER_CONTENT_OBSERVER; + mAutoRequery = true; + } else { + mAutoRequery = false; + } + final boolean cursorPresent = c != null; + mCursor = c; + mDataValid = cursorPresent; + mContext = context; + mRowIDColumn = cursorPresent ? c.getColumnIndexOrThrow("_id") : -1; + if ((flags & FLAG_REGISTER_CONTENT_OBSERVER) == FLAG_REGISTER_CONTENT_OBSERVER) { + mChangeObserver = new ChangeObserver(); + mDataSetObserver = new MyDataSetObserver(); + } else { + mChangeObserver = null; + mDataSetObserver = null; + } + + if (cursorPresent) { + if (mChangeObserver != null) { + c.registerContentObserver(mChangeObserver); + } + if (mDataSetObserver != null) { + c.registerDataSetObserver(mDataSetObserver); + } + } + } + + /** + * Returns the cursor. + * @return the cursor. + */ + public Cursor getCursor() { + return mCursor; + } + + @Override + public int getItemCount() { + if (mDataValid && mCursor != null) { + return mCursor.getCount(); + } else { + return 0; + } + } + + /** + * @see android.support.v7.widget.RecyclerView.Adapter#getItem(int) + */ + public Object getItem(final int position) { + if (mDataValid && mCursor != null) { + mCursor.moveToPosition(position); + return mCursor; + } else { + return null; + } + } + + /** + * @see android.support.v7.widget.RecyclerView.Adapter#getItemId(int) + */ + @Override + public long getItemId(final int position) { + if (mDataValid && mCursor != null) { + if (mCursor.moveToPosition(position)) { + return mCursor.getLong(mRowIDColumn); + } else { + return 0; + } + } else { + return 0; + } + } + + @Override + public VH onCreateViewHolder(final ViewGroup parent, final int viewType) { + return createViewHolder(mContext, parent, viewType); + } + + @Override + public void onBindViewHolder(final VH holder, final int position) { + if (!mDataValid) { + throw new IllegalStateException("this should only be called when the cursor is valid"); + } + if (!mCursor.moveToPosition(position)) { + throw new IllegalStateException("couldn't move cursor to position " + position); + } + bindViewHolder(holder, mContext, mCursor); + } + /** + * Bind an existing view to the data pointed to by cursor + * @param view Existing view, returned earlier by newView + * @param context Interface to application's global information + * @param cursor The cursor from which to get the data. The cursor is already + * moved to the correct position. + */ + public abstract void bindViewHolder(VH holder, Context context, Cursor cursor); + + /** + * @see android.support.v7.widget.RecyclerView.Adapter#createViewHolder(Context, ViewGroup, int) + */ + public abstract VH createViewHolder(Context context, ViewGroup parent, int viewType); + + /** + * Change the underlying cursor to a new cursor. If there is an existing cursor it will be + * closed. + * + * @param cursor The new cursor to be used + */ + public void changeCursor(final Cursor cursor) { + final Cursor old = swapCursor(cursor); + if (old != null) { + old.close(); + } + } + + /** + * Swap in a new Cursor, returning the old Cursor. Unlike + * {@link #changeCursor(Cursor)}, the returned old Cursor is not + * closed. + * + * @param newCursor The new cursor to be used. + * @return Returns the previously set Cursor, or null if there wasa not one. + * If the given new Cursor is the same instance is the previously set + * Cursor, null is also returned. + */ + public Cursor swapCursor(final Cursor newCursor) { + if (newCursor == mCursor) { + return null; + } + final Cursor oldCursor = mCursor; + if (oldCursor != null) { + if (mChangeObserver != null) { + oldCursor.unregisterContentObserver(mChangeObserver); + } + if (mDataSetObserver != null) { + oldCursor.unregisterDataSetObserver(mDataSetObserver); + } + } + mCursor = newCursor; + if (newCursor != null) { + if (mChangeObserver != null) { + newCursor.registerContentObserver(mChangeObserver); + } + if (mDataSetObserver != null) { + newCursor.registerDataSetObserver(mDataSetObserver); + } + mRowIDColumn = newCursor.getColumnIndexOrThrow("_id"); + mDataValid = true; + // notify the observers about the new cursor + notifyDataSetChanged(); + } else { + mRowIDColumn = -1; + mDataValid = false; + // notify the observers about the lack of a data set + notifyDataSetChanged(); + } + return oldCursor; + } + + /** + *

Converts the cursor into a CharSequence. Subclasses should override this + * method to convert their results. The default implementation returns an + * empty String for null values or the default String representation of + * the value.

+ * + * @param cursor the cursor to convert to a CharSequence + * @return a CharSequence representing the value + */ + public CharSequence convertToString(final Cursor cursor) { + return cursor == null ? "" : cursor.toString(); + } + + /** + * Called when the {@link ContentObserver} on the cursor receives a change notification. + * The default implementation provides the auto-requery logic, but may be overridden by + * sub classes. + * + * @see ContentObserver#onChange(boolean) + */ + protected void onContentChanged() { + if (mAutoRequery && mCursor != null && !mCursor.isClosed()) { + if (false) { + Log.v("Cursor", "Auto requerying " + mCursor + " due to update"); + } + mDataValid = mCursor.requery(); + } + } + + private class ChangeObserver extends ContentObserver { + public ChangeObserver() { + super(new Handler()); + } + + @Override + public boolean deliverSelfNotifications() { + return true; + } + + @Override + public void onChange(final boolean selfChange) { + onContentChanged(); + } + } + + private class MyDataSetObserver extends DataSetObserver { + @Override + public void onChanged() { + mDataValid = true; + notifyDataSetChanged(); + } + + @Override + public void onInvalidated() { + mDataValid = false; + notifyDataSetChanged(); + } + } + +} diff --git a/src/com/android/messaging/ui/CustomHeaderPagerListViewHolder.java b/src/com/android/messaging/ui/CustomHeaderPagerListViewHolder.java new file mode 100644 index 0000000..1268c53 --- /dev/null +++ b/src/com/android/messaging/ui/CustomHeaderPagerListViewHolder.java @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; +import android.database.Cursor; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AbsListView; +import android.widget.AbsListView.OnScrollListener; +import android.widget.CursorAdapter; +import android.widget.ListView; + +import com.android.messaging.util.ImeUtil; + +/** + * Produces and holds a list view and its tab header to be displayed in a ViewPager. + */ +public abstract class CustomHeaderPagerListViewHolder extends BasePagerViewHolder + implements CustomHeaderPagerViewHolder { + private final Context mContext; + private final CursorAdapter mListAdapter; + private boolean mListCursorInitialized; + private ListView mListView; + + public CustomHeaderPagerListViewHolder(final Context context, + final CursorAdapter adapter) { + mContext = context; + mListAdapter = adapter; + } + + @Override + protected View createView(ViewGroup container) { + final LayoutInflater inflater = (LayoutInflater) + mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + final View view = inflater.inflate( + getLayoutResId(), + null /* root */, + false /* attachToRoot */); + final ListView listView = (ListView) view.findViewById(getListViewResId()); + listView.setAdapter(mListAdapter); + listView.setOnScrollListener(new OnScrollListener() { + @Override + public void onScrollStateChanged(final AbsListView view, final int scrollState) { + if (scrollState != SCROLL_STATE_IDLE) { + ImeUtil.get().hideImeKeyboard(mContext, view); + } + } + + @Override + public void onScroll(final AbsListView view, final int firstVisibleItem, + final int visibleItemCount, final int totalItemCount) { + } + }); + mListView = listView; + maybeSetEmptyView(); + return view; + } + + public void onContactsCursorUpdated(final Cursor data) { + mListAdapter.swapCursor(data); + if (!mListCursorInitialized) { + // We set the emptyView here instead of in create so that the initial load won't show + // the empty UI - the system handles this and doesn't do what we would like. + mListCursorInitialized = true; + maybeSetEmptyView(); + } + } + + /** + * We don't want to show the empty view hint until BOTH conditions are met: + * 1. The view has been created. + * 2. Cursor data has been loaded once. + * Due to timing when data is loaded, the view may not be ready (and vice versa). So we + * are calling this method from both onContactsCursorUpdated & createView. + */ + private void maybeSetEmptyView() { + if (mView != null && mListCursorInitialized) { + final ListEmptyView emptyView = (ListEmptyView) mView.findViewById(getEmptyViewResId()); + if (emptyView != null) { + emptyView.setTextHint(getEmptyViewTitleResId()); + emptyView.setImageHint(getEmptyViewImageResId()); + final ListView listView = (ListView) mView.findViewById(getListViewResId()); + listView.setEmptyView(emptyView); + } + } + } + + public void invalidateList() { + mListAdapter.notifyDataSetChanged(); + } + + /** + * In order for scene transition to work, we toggle the visibility for each individual list + * view items so that they can be properly tracked by the scene transition manager. + * @param show whether the pending transition is to show or hide the list. + */ + public void toggleVisibilityForPendingTransition(final boolean show, final View epicenterView) { + if (mListView == null) { + return; + } + final int childCount = mListView.getChildCount(); + for (int i = 0; i < childCount; i++) { + final View childView = mListView.getChildAt(i); + if (childView != epicenterView) { + childView.setVisibility(show ? View.VISIBLE : View.INVISIBLE); + } + } + } + + @Override + public CharSequence getPageTitle(Context context) { + return context.getString(getPageTitleResId()); + } + + protected abstract int getLayoutResId(); + protected abstract int getPageTitleResId(); + protected abstract int getEmptyViewResId(); + protected abstract int getEmptyViewTitleResId(); + protected abstract int getEmptyViewImageResId(); + protected abstract int getListViewResId(); +} diff --git a/src/com/android/messaging/ui/CustomHeaderPagerViewHolder.java b/src/com/android/messaging/ui/CustomHeaderPagerViewHolder.java new file mode 100644 index 0000000..43802cd --- /dev/null +++ b/src/com/android/messaging/ui/CustomHeaderPagerViewHolder.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; + +/** + * An extension on the standard PagerViewHolder to return a custom header view to be used by + * CustomHeaderViewPager + */ +public interface CustomHeaderPagerViewHolder extends PagerViewHolder { + CharSequence getPageTitle(Context context); +} diff --git a/src/com/android/messaging/ui/CustomHeaderViewPager.java b/src/com/android/messaging/ui/CustomHeaderViewPager.java new file mode 100644 index 0000000..2e8df42 --- /dev/null +++ b/src/com/android/messaging/ui/CustomHeaderViewPager.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; +import android.support.v4.view.PagerAdapter; +import android.support.v4.view.ViewPager; +import android.support.v4.view.ViewPager.OnPageChangeListener; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.widget.LinearLayout; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; + +/** + * A view that contains both a view pager and a tab strip wrapped in a linear layout. + */ +public class CustomHeaderViewPager extends LinearLayout { + public final static int DEFAULT_TAB_STRIP_SIZE = -1; + private final int mDefaultTabStripSize; + private ViewPager mViewPager; + private ViewPagerTabs mTabstrip; + + public CustomHeaderViewPager(final Context context, final AttributeSet attrs) { + super(context, attrs); + + final LayoutInflater inflater = LayoutInflater.from(context); + inflater.inflate(R.layout.custom_header_view_pager, this, true); + setOrientation(LinearLayout.VERTICAL); + + mTabstrip = (ViewPagerTabs) findViewById(R.id.tab_strip); + mViewPager = (ViewPager) findViewById(R.id.pager); + + TypedValue tv = new TypedValue(); + context.getTheme().resolveAttribute(android.R.attr.actionBarSize, tv, true); + mDefaultTabStripSize = context.getResources().getDimensionPixelSize(tv.resourceId); + } + + public void setCurrentItem(final int position) { + mViewPager.setCurrentItem(position); + } + + public void setViewPagerTabHeight(final int tabHeight) { + mTabstrip.getLayoutParams().height = tabHeight == DEFAULT_TAB_STRIP_SIZE ? + mDefaultTabStripSize : tabHeight; + } + + public void setViewHolders(final CustomHeaderPagerViewHolder[] viewHolders) { + Assert.notNull(mViewPager); + final PagerAdapter adapter = new CustomHeaderViewPagerAdapter(viewHolders); + mViewPager.setAdapter(adapter); + mTabstrip.setViewPager(mViewPager); + mViewPager.setOnPageChangeListener(new OnPageChangeListener() { + + @Override + public void onPageScrollStateChanged(int state) { + mTabstrip.onPageScrollStateChanged(state); + } + + @Override + public void onPageScrolled(int position, float positionOffset, + int positionOffsetPixels) { + mTabstrip.onPageScrolled(position, positionOffset, positionOffsetPixels); + } + + @Override + public void onPageSelected(int position) { + mTabstrip.onPageSelected(position); + } + }); + } + + public int getSelectedItemPosition() { + return mTabstrip.getSelectedItemPosition(); + } +} diff --git a/src/com/android/messaging/ui/CustomHeaderViewPagerAdapter.java b/src/com/android/messaging/ui/CustomHeaderViewPagerAdapter.java new file mode 100644 index 0000000..1a5cf6a --- /dev/null +++ b/src/com/android/messaging/ui/CustomHeaderViewPagerAdapter.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import com.android.messaging.Factory; + +public class CustomHeaderViewPagerAdapter extends + FixedViewPagerAdapter { + + public CustomHeaderViewPagerAdapter(final CustomHeaderPagerViewHolder[] viewHolders) { + super(viewHolders); + } + + @Override + public CharSequence getPageTitle(int position) { + // The tab strip will handle RTL internally so we should use raw position. + return getViewHolder(position, false /* rtlAware */) + .getPageTitle(Factory.get().getApplicationContext()); + } +} diff --git a/src/com/android/messaging/ui/FixedViewPagerAdapter.java b/src/com/android/messaging/ui/FixedViewPagerAdapter.java new file mode 100644 index 0000000..bd73b71 --- /dev/null +++ b/src/com/android/messaging/ui/FixedViewPagerAdapter.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.os.Bundle; +import android.os.Parcelable; +import android.support.v4.view.PagerAdapter; +import android.view.View; +import android.view.ViewGroup; + +import com.android.messaging.Factory; +import com.android.messaging.util.Assert; +import com.android.messaging.util.UiUtils; +import com.google.common.annotations.VisibleForTesting; + +/** + * A PagerAdapter that provides a fixed number of paged Views provided by a fixed set of + * {@link PagerViewHolder}'s. This allows us to put a fixed number of Views, instead of fragments, + * into a given ViewPager. + */ +public class FixedViewPagerAdapter extends PagerAdapter { + private final T[] mViewHolders; + + public FixedViewPagerAdapter(final T[] viewHolders) { + Assert.notNull(viewHolders); + mViewHolders = viewHolders; + } + + @Override + public Object instantiateItem(final ViewGroup container, final int position) { + final PagerViewHolder viewHolder = getViewHolder(position); + final View view = viewHolder.getView(container); + if (view == null) { + return null; + } + view.setTag(viewHolder); + container.addView(view); + return viewHolder; + } + + @Override + public void destroyItem(final ViewGroup container, final int position, final Object object) { + final PagerViewHolder viewHolder = getViewHolder(position); + final View destroyedView = viewHolder.destroyView(); + if (destroyedView != null) { + container.removeView(destroyedView); + } + } + + @Override + public int getCount() { + return mViewHolders.length; + } + + @Override + public boolean isViewFromObject(final View view, final Object object) { + return view.getTag() == object; + } + + public T getViewHolder(final int i) { + return getViewHolder(i, true /* rtlAware */); + } + + @VisibleForTesting + public T getViewHolder(final int i, final boolean rtlAware) { + return mViewHolders[rtlAware ? getRtlPosition(i) : i]; + } + + @Override + public Parcelable saveState() { + // The paged views in the view pager gets created and destroyed as the user scrolls through + // them. By default, only the pages to the immediate left and right of the current visible + // page are realized. Moreover, if the activity gets destroyed and recreated, the pages are + // automatically destroyed. Therefore, in order to preserve transient page UI states that + // are not persisted in the DB we'd like to store them in a Bundle when views get + // destroyed. When the views get recreated, we rehydrate them by passing them the saved + // data. When the activity gets destroyed, it invokes saveState() on this adapter to + // add this saved Bundle to the overall saved instance state. + final Bundle savedViewHolderState = new Bundle(Factory.get().getApplicationContext() + .getClassLoader()); + for (int i = 0; i < mViewHolders.length; i++) { + final Parcelable pageState = getViewHolder(i).saveState(); + savedViewHolderState.putParcelable(getInstanceStateKeyForPage(i), pageState); + } + return savedViewHolderState; + } + + @Override + public void restoreState(final Parcelable state, final ClassLoader loader) { + if (state instanceof Bundle) { + final Bundle restoredViewHolderState = (Bundle) state; + ((Bundle) state).setClassLoader(Factory.get().getApplicationContext().getClassLoader()); + for (int i = 0; i < mViewHolders.length; i++) { + final Parcelable pageState = restoredViewHolderState + .getParcelable(getInstanceStateKeyForPage(i)); + getViewHolder(i).restoreState(pageState); + } + } else { + super.restoreState(state, loader); + } + } + + public void resetState() { + for (int i = 0; i < mViewHolders.length; i++) { + getViewHolder(i).resetState(); + } + } + + private String getInstanceStateKeyForPage(final int i) { + return getViewHolder(i).getClass().getCanonicalName() + "_savedstate_" + i; + } + + protected int getRtlPosition(final int position) { + if (UiUtils.isRtlMode()) { + return mViewHolders.length - 1 - position; + } + return position; + } +} diff --git a/src/com/android/messaging/ui/ImeDetectFrameLayout.java b/src/com/android/messaging/ui/ImeDetectFrameLayout.java new file mode 100644 index 0000000..32564ea --- /dev/null +++ b/src/com/android/messaging/ui/ImeDetectFrameLayout.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.FrameLayout; + +import com.android.messaging.util.ImeUtil; +import com.android.messaging.util.LogUtil; + +public class ImeDetectFrameLayout extends FrameLayout { + public ImeDetectFrameLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + final int measuredHeight = getMeasuredHeight(); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_TAG, "ImeDetectFrameLayout " + + "measuredHeight: " + measuredHeight + " getMeasuredHeight(): " + + getMeasuredHeight()); + } + + if (measuredHeight != getMeasuredHeight() && getContext() instanceof ImeUtil.ImeStateHost) { + ((ImeUtil.ImeStateHost) getContext()).onDisplayHeightChanged(heightMeasureSpec); + } + } +} diff --git a/src/com/android/messaging/ui/LicenseActivity.java b/src/com/android/messaging/ui/LicenseActivity.java new file mode 100644 index 0000000..a28da81 --- /dev/null +++ b/src/com/android/messaging/ui/LicenseActivity.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.app.Activity; +import android.os.Bundle; +import android.webkit.WebView; + +import com.android.messaging.R; + +public class LicenseActivity extends Activity { + private final String LICENSE_URL = "file:///android_asset/licenses.html"; + + @Override + public void onCreate(final Bundle bundle) { + super.onCreate(bundle); + setContentView(R.layout.license_activity); + final WebView webView = (WebView) findViewById(R.id.content); + webView.loadUrl(LICENSE_URL); + } +} diff --git a/src/com/android/messaging/ui/LineWrapLayout.java b/src/com/android/messaging/ui/LineWrapLayout.java new file mode 100644 index 0000000..5219811 --- /dev/null +++ b/src/com/android/messaging/ui/LineWrapLayout.java @@ -0,0 +1,232 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.UiUtils; + +import java.util.ArrayList; + +/** +* A line-wrapping flow layout. Arranges children in horizontal flow, packing as many +* child views as possible on each line. When the current line does not +* have enough horizontal space, the layout continues on the next line. +*/ +public class LineWrapLayout extends ViewGroup { + public LineWrapLayout(Context context) { + this(context, null); + } + + public LineWrapLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + final int startPadding = UiUtils.getPaddingStart(this); + final int endPadding = UiUtils.getPaddingEnd(this); + final int widthMode = MeasureSpec.getMode(widthMeasureSpec); + final int widthSize = MeasureSpec.getSize(widthMeasureSpec) - startPadding - endPadding; + final boolean isFixedSize = (widthMode == MeasureSpec.EXACTLY); + + int height = 0; + + int childCount = getChildCount(); + int childWidthSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.AT_MOST); + + int x = startPadding; + int currLineWidth = 0; + int currLineHeight = 0; + int maxLineWidth = 0; + + for (int i = 0; i < childCount; i++) { + View currChild = getChildAt(i); + if (currChild.getVisibility() == GONE) { + continue; + } + LayoutParams layoutParams = (LayoutParams) currChild.getLayoutParams(); + int startMargin = layoutParams.getStartMargin(); + int endMargin = layoutParams.getEndMargin(); + currChild.measure(childWidthSpec, MeasureSpec.UNSPECIFIED); + int childMeasuredWidth = currChild.getMeasuredWidth() + startMargin + endMargin; + int childMeasuredHeight = currChild.getMeasuredHeight() + layoutParams.topMargin + + layoutParams.bottomMargin; + + if ((x + childMeasuredWidth) > widthSize) { + // New line. Update the overall height and reset trackers. + height += currLineHeight; + currLineHeight = 0; + x = startPadding; + currLineWidth = 0; + startMargin = 0; + } + + x += childMeasuredWidth; + currLineWidth += childMeasuredWidth; + currLineHeight = Math.max(currLineHeight, childMeasuredHeight); + maxLineWidth = Math.max(currLineWidth, maxLineWidth); + } + // And account for the height of the last line. + height += currLineHeight; + + int width = isFixedSize ? widthSize : maxLineWidth; + setMeasuredDimension(width + startPadding + endPadding, + height + getPaddingTop() + getPaddingBottom()); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + final int startPadding = UiUtils.getPaddingStart(this); + final int endPadding = UiUtils.getPaddingEnd(this); + int width = getWidth() - startPadding - endPadding; + int y = getPaddingTop(); + int x = startPadding; + int childCount = getChildCount(); + + int currLineHeight = 0; + + // Do a dry-run first to get the line heights. + final ArrayList lineHeights = new ArrayList(); + for (int i = 0; i < childCount; i++) { + View currChild = getChildAt(i); + if (currChild.getVisibility() == GONE) { + continue; + } + LayoutParams layoutParams = (LayoutParams) currChild.getLayoutParams(); + int childWidth = currChild.getMeasuredWidth(); + int childHeight = currChild.getMeasuredHeight(); + int startMargin = layoutParams.getStartMargin(); + int endMargin = layoutParams.getEndMargin(); + + if ((x + childWidth + startMargin + endMargin) > width) { + // new line + lineHeights.add(currLineHeight); + currLineHeight = 0; + x = startPadding; + startMargin = 0; + } + currLineHeight = Math.max(currLineHeight, childHeight + layoutParams.topMargin + + layoutParams.bottomMargin); + x += childWidth + startMargin + endMargin; + } + // Add the last line height. + lineHeights.add(currLineHeight); + + // Now perform the actual layout. + x = startPadding; + currLineHeight = 0; + int lineIndex = 0; + for (int i = 0; i < childCount; i++) { + View currChild = getChildAt(i); + if (currChild.getVisibility() == GONE) { + continue; + } + LayoutParams layoutParams = (LayoutParams) currChild.getLayoutParams(); + int childWidth = currChild.getMeasuredWidth(); + int childHeight = currChild.getMeasuredHeight(); + int startMargin = layoutParams.getStartMargin(); + int endMargin = layoutParams.getEndMargin(); + + if ((x + childWidth + startMargin + endMargin) > width) { + // new line + y += currLineHeight; + currLineHeight = 0; + x = startPadding; + startMargin = 0; + lineIndex++; + } + final int startPositionX = x + startMargin; + int startPositionY = y + layoutParams.topMargin; // default to top gravity + final int majorGravity = layoutParams.gravity & Gravity.VERTICAL_GRAVITY_MASK; + if (majorGravity != Gravity.TOP && lineHeights.size() > lineIndex) { + final int lineHeight = lineHeights.get(lineIndex); + switch (majorGravity) { + case Gravity.BOTTOM: + startPositionY = y + lineHeight - childHeight - layoutParams.bottomMargin; + break; + + case Gravity.CENTER_VERTICAL: + startPositionY = y + (lineHeight - childHeight) / 2; + break; + } + } + + if (OsUtil.isAtLeastJB_MR2() && getResources().getConfiguration() + .getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) { + currChild.layout(width - startPositionX - childWidth, startPositionY, + width - startPositionX, startPositionY + childHeight); + } else { + currChild.layout(startPositionX, startPositionY, startPositionX + childWidth, + startPositionY + childHeight); + } + currLineHeight = Math.max(currLineHeight, childHeight + layoutParams.topMargin + + layoutParams.bottomMargin); + x += childWidth + startMargin + endMargin; + } + } + + @Override + protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) { + return new LayoutParams(p); + } + + @Override + public LayoutParams generateLayoutParams(AttributeSet attrs) { + return new LayoutParams(getContext(), attrs); + } + + @Override + protected LayoutParams generateDefaultLayoutParams() { + return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + } + + public static final class LayoutParams extends FrameLayout.LayoutParams { + public LayoutParams(Context c, AttributeSet attrs) { + super(c, attrs); + } + + public LayoutParams(int width, int height) { + super(width, height); + } + + public LayoutParams(ViewGroup.LayoutParams source) { + super(source); + } + + public int getStartMargin() { + if (OsUtil.isAtLeastJB_MR2()) { + return getMarginStart(); + } else { + return leftMargin; + } + } + + public int getEndMargin() { + if (OsUtil.isAtLeastJB_MR2()) { + return getMarginEnd(); + } else { + return rightMargin; + } + } + } +} diff --git a/src/com/android/messaging/ui/ListEmptyView.java b/src/com/android/messaging/ui/ListEmptyView.java new file mode 100644 index 0000000..8cf3049 --- /dev/null +++ b/src/com/android/messaging/ui/ListEmptyView.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.Gravity; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.LinearLayout.LayoutParams; +import android.widget.TextView; + +import com.android.messaging.R; + +/** + * A common reusable view that shows a hint image and text for an empty list view. + */ +public class ListEmptyView extends LinearLayout { + private ImageView mEmptyImageHint; + private TextView mEmptyTextHint; + + public ListEmptyView(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + mEmptyImageHint = (ImageView) findViewById(R.id.empty_image_hint); + mEmptyTextHint = (TextView) findViewById(R.id.empty_text_hint); + } + + public void setImageHint(final int resId) { + mEmptyImageHint.setImageResource(resId); + } + + public void setTextHint(final int resId) { + mEmptyTextHint.setText(getResources().getText(resId)); + } + + public void setTextHint(final CharSequence hintText) { + mEmptyTextHint.setText(hintText); + } + + public void setIsImageVisible(final boolean isImageVisible) { + mEmptyImageHint.setVisibility(isImageVisible ? VISIBLE : GONE); + } + + public void setIsVerticallyCentered(final boolean isVerticallyCentered) { + int gravity = + isVerticallyCentered ? Gravity.CENTER : Gravity.TOP | Gravity.CENTER_HORIZONTAL; + ((LinearLayout.LayoutParams) mEmptyImageHint.getLayoutParams()).gravity = gravity; + ((LinearLayout.LayoutParams) mEmptyTextHint.getLayoutParams()).gravity = gravity; + getLayoutParams().height = + isVerticallyCentered ? LayoutParams.WRAP_CONTENT : LayoutParams.MATCH_PARENT; + requestLayout(); + } +} diff --git a/src/com/android/messaging/ui/MaxHeightScrollView.java b/src/com/android/messaging/ui/MaxHeightScrollView.java new file mode 100644 index 0000000..a000cd1 --- /dev/null +++ b/src/com/android/messaging/ui/MaxHeightScrollView.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.widget.ScrollView; + +import com.android.messaging.R; + +/** + * A ScrollView that limits the maximum height that it can take. This is to work around android + * layout's limitation of not having android:maxHeight. + */ +public class MaxHeightScrollView extends ScrollView { + private static final int NO_MAX_HEIGHT = -1; + + private final int mMaxHeight; + + public MaxHeightScrollView(final Context context, final AttributeSet attrs) { + super(context, attrs); + final TypedArray attr = context.obtainStyledAttributes(attrs, + R.styleable.MaxHeightScrollView, 0, 0); + mMaxHeight = attr.getDimensionPixelSize(R.styleable.MaxHeightScrollView_android_maxHeight, + NO_MAX_HEIGHT); + attr.recycle(); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + if (mMaxHeight != NO_MAX_HEIGHT) { + setMeasuredDimension(getMeasuredWidth(), Math.min(getMeasuredHeight(), mMaxHeight)); + } + } +} \ No newline at end of file diff --git a/src/com/android/messaging/ui/MultiAttachmentLayout.java b/src/com/android/messaging/ui/MultiAttachmentLayout.java new file mode 100644 index 0000000..f620245 --- /dev/null +++ b/src/com/android/messaging/ui/MultiAttachmentLayout.java @@ -0,0 +1,424 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.content.Context; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.animation.AnimationSet; +import android.view.animation.ScaleAnimation; +import android.view.animation.TranslateAnimation; +import android.widget.FrameLayout; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.MediaPickerMessagePartData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.PendingAttachmentData; +import com.android.messaging.datamodel.media.ImageRequestDescriptor; +import com.android.messaging.ui.AsyncImageView.AsyncImageViewDelayLoader; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.Assert; +import com.android.messaging.util.UiUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; + +/** + * Holds and displays multiple attachments in a 4x2 grid. Each preview image "tile" can take + * one of three sizes - small (1x1), wide (2x1) and large (2x2). We have a number of predefined + * layout settings designed for holding 2, 3, 4+ attachments (these layout settings are + * tweakable by design request to allow for max flexibility). For a visual example, consider the + * following attachment layout: + * + * +---------------+----------------+ + * | | | + * | | B | + * | | | + * | A |-------+--------| + * | | | | + * | | C | D | + * | | | | + * +---------------+-------+--------+ + * + * In the above example, the layout consists of four tiles, A-D. A is a large tile, B is a + * wide tile and C & D are both small tiles. A starts at (0,0) and ends at (1,1), B starts at + * (2,0) and ends at (3,0), and so on. In our layout class we'd have these tiles in the order + * of A-D, so that we make sure the last tile is always the one where we can put the overflow + * indicator (e.g. "+2"). + */ +public class MultiAttachmentLayout extends FrameLayout { + + public interface OnAttachmentClickListener { + boolean onAttachmentClick(MessagePartData attachment, Rect viewBoundsOnScreen, + boolean longPress); + } + + private static final int GRID_WIDTH = 4; // in # of cells + private static final int GRID_HEIGHT = 2; // in # of cells + + /** + * Represents a preview image tile in the layout + */ + private static class Tile { + public final int startX; + public final int startY; + public final int endX; + public final int endY; + + private Tile(final int startX, final int startY, final int endX, final int endY) { + this.startX = startX; + this.startY = startY; + this.endX = endX; + this.endY = endY; + } + + public int getWidthMeasureSpec(final int cellWidth, final int padding) { + return MeasureSpec.makeMeasureSpec((endX - startX + 1) * cellWidth - padding * 2, + MeasureSpec.EXACTLY); + } + + public int getHeightMeasureSpec(final int cellHeight, final int padding) { + return MeasureSpec.makeMeasureSpec((endY - startY + 1) * cellHeight - padding * 2, + MeasureSpec.EXACTLY); + } + + public static Tile large(final int startX, final int startY) { + return new Tile(startX, startY, startX + 1, startY + 1); + } + + public static Tile wide(final int startX, final int startY) { + return new Tile(startX, startY, startX + 1, startY); + } + + public static Tile small(final int startX, final int startY) { + return new Tile(startX, startY, startX, startY); + } + } + + /** + * A layout simply contains a list of tiles, in the order of top-left -> bottom-right. + */ + private static class Layout { + public final List tiles; + public Layout(final Tile[] tilesArray) { + tiles = Arrays.asList(tilesArray); + } + } + + /** + * List of predefined layout configurations w.r.t no. of attachments. + */ + private static final Layout[] ATTACHMENT_LAYOUTS_BY_COUNT = { + null, // Doesn't support zero attachments. + null, // Doesn't support one attachment. Single attachment preview is used instead. + new Layout(new Tile[] { Tile.large(0, 0), Tile.large(2, 0) }), // 2 items + new Layout(new Tile[] { Tile.large(0, 0), Tile.wide(2, 0), Tile.wide(2, 1) }), // 3 items + new Layout(new Tile[] { Tile.large(0, 0), Tile.wide(2, 0), Tile.small(2, 1), // 4+ items + Tile.small(3, 1) }), + }; + + /** + * List of predefined RTL layout configurations w.r.t no. of attachments. + */ + private static final Layout[] ATTACHMENT_RTL_LAYOUTS_BY_COUNT = { + null, // Doesn't support zero attachments. + null, // Doesn't support one attachment. Single attachment preview is used instead. + new Layout(new Tile[] { Tile.large(2, 0), Tile.large(0, 0)}), // 2 items + new Layout(new Tile[] { Tile.large(2, 0), Tile.wide(0, 0), Tile.wide(0, 1) }), // 3 items + new Layout(new Tile[] { Tile.large(2, 0), Tile.wide(0, 0), Tile.small(1, 1), // 4+ items + Tile.small(0, 1) }), + }; + + private Layout mCurrentLayout; + private ArrayList mPreviewViews; + private int mPlusNumber; + private TextView mPlusTextView; + private OnAttachmentClickListener mAttachmentClickListener; + private AsyncImageViewDelayLoader mImageViewDelayLoader; + + public MultiAttachmentLayout(final Context context, final AttributeSet attrs) { + super(context, attrs); + mPreviewViews = new ArrayList(); + } + + public void bindAttachments(final Iterable attachments, + final Rect transitionRect, final int count) { + final ArrayList previousViews = mPreviewViews; + mPreviewViews = new ArrayList(); + removeView(mPlusTextView); + mPlusTextView = null; + + determineLayout(attachments, count); + buildViews(attachments, previousViews, transitionRect); + + // Remove all previous views that couldn't be recycled. + for (final ViewWrapper viewWrapper : previousViews) { + removeView(viewWrapper.view); + } + requestLayout(); + } + + public OnAttachmentClickListener getOnAttachmentClickListener() { + return mAttachmentClickListener; + } + + public void setOnAttachmentClickListener(final OnAttachmentClickListener listener) { + mAttachmentClickListener = listener; + } + + public void setImageViewDelayLoader(final AsyncImageViewDelayLoader delayLoader) { + mImageViewDelayLoader = delayLoader; + } + + public void setColorFilter(int color) { + for (ViewWrapper viewWrapper : mPreviewViews) { + if (viewWrapper.view instanceof AsyncImageView) { + ((AsyncImageView) viewWrapper.view).setColorFilter(color); + } + } + } + + public void clearColorFilter() { + for (ViewWrapper viewWrapper : mPreviewViews) { + if (viewWrapper.view instanceof AsyncImageView) { + ((AsyncImageView) viewWrapper.view).clearColorFilter(); + } + } + } + + private void determineLayout(final Iterable attachments, final int count) { + Assert.isTrue(attachments != null); + final boolean isRtl = AccessibilityUtil.isLayoutRtl(getRootView()); + if (isRtl) { + mCurrentLayout = ATTACHMENT_RTL_LAYOUTS_BY_COUNT[Math.min(count, + ATTACHMENT_RTL_LAYOUTS_BY_COUNT.length - 1)]; + } else { + mCurrentLayout = ATTACHMENT_LAYOUTS_BY_COUNT[Math.min(count, + ATTACHMENT_LAYOUTS_BY_COUNT.length - 1)]; + } + + // We must have a valid layout for the current configuration. + Assert.notNull(mCurrentLayout); + + mPlusNumber = count - mCurrentLayout.tiles.size(); + Assert.isTrue(mPlusNumber >= 0); + } + + private void buildViews(final Iterable attachments, + final ArrayList previousViews, final Rect transitionRect) { + final LayoutInflater layoutInflater = LayoutInflater.from(getContext()); + final int count = mCurrentLayout.tiles.size(); + int i = 0; + final Iterator iterator = attachments.iterator(); + while (iterator.hasNext() && i < count) { + final MessagePartData attachment = iterator.next(); + ViewWrapper attachmentWrapper = null; + // Try to recycle a previous view first + for (int j = 0; j < previousViews.size(); j++) { + final ViewWrapper previousView = previousViews.get(j); + if (previousView.attachment.equals(attachment) && + !(previousView.attachment instanceof PendingAttachmentData)) { + attachmentWrapper = previousView; + previousViews.remove(j); + break; + } + } + + if (attachmentWrapper == null) { + final View view = AttachmentPreviewFactory.createAttachmentPreview(layoutInflater, + attachment, this, AttachmentPreviewFactory.TYPE_MULTIPLE, + false /* startImageRequest */, mAttachmentClickListener); + + if (view == null) { + // createAttachmentPreview can return null if something goes wrong (e.g. + // attachment has unsupported contentType) + continue; + } + if (view instanceof AsyncImageView && mImageViewDelayLoader != null) { + AsyncImageView asyncImageView = (AsyncImageView) view; + asyncImageView.setDelayLoader(mImageViewDelayLoader); + } + addView(view); + attachmentWrapper = new ViewWrapper(view, attachment); + // Help animate from single to multi by copying over the prev location + if (count == 2 && i == 1 && transitionRect != null) { + attachmentWrapper.prevLeft = transitionRect.left; + attachmentWrapper.prevTop = transitionRect.top; + attachmentWrapper.prevWidth = transitionRect.width(); + attachmentWrapper.prevHeight = transitionRect.height(); + } + } + i++; + Assert.notNull(attachmentWrapper); + mPreviewViews.add(attachmentWrapper); + + // The first view will animate in using PopupTransitionAnimation, but the remaining + // views will slide from their previous position to their new position within the + // layout + if (i == 0) { + AttachmentPreview.tryAnimateViewIn(attachment, attachmentWrapper.view); + } + attachmentWrapper.needsSlideAnimation = i > 0; + } + + // Build the plus text view (e.g. "+2") for when there are more attachments than what + // this layout can display. + if (mPlusNumber > 0) { + mPlusTextView = (TextView) layoutInflater.inflate(R.layout.attachment_more_text_view, + null /* parent */); + mPlusTextView.setText(getResources().getString(R.string.attachment_more_items, + mPlusNumber)); + addView(mPlusTextView); + } + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + final int maxWidth = getResources().getDimensionPixelSize( + R.dimen.multiple_attachment_preview_width); + final int maxHeight = getResources().getDimensionPixelSize( + R.dimen.multiple_attachment_preview_height); + final int width = Math.min(MeasureSpec.getSize(widthMeasureSpec), maxWidth); + final int height = maxHeight; + final int cellWidth = width / GRID_WIDTH; + final int cellHeight = height / GRID_HEIGHT; + final int count = mPreviewViews.size(); + final int padding = getResources().getDimensionPixelOffset( + R.dimen.multiple_attachment_preview_padding); + for (int i = 0; i < count; i++) { + final View view = mPreviewViews.get(i).view; + final Tile imageTile = mCurrentLayout.tiles.get(i); + view.measure(imageTile.getWidthMeasureSpec(cellWidth, padding), + imageTile.getHeightMeasureSpec(cellHeight, padding)); + + // Now that we know the size, we can request an appropriately-sized image. + if (view instanceof AsyncImageView) { + final ImageRequestDescriptor imageRequest = + AttachmentPreviewFactory.getImageRequestDescriptorForAttachment( + mPreviewViews.get(i).attachment, + view.getMeasuredWidth(), + view.getMeasuredHeight()); + ((AsyncImageView) view).setImageResourceId(imageRequest); + } + + if (i == count - 1 && mPlusTextView != null) { + // The plus text view always covers the last attachment. + mPlusTextView.measure(imageTile.getWidthMeasureSpec(cellWidth, padding), + imageTile.getHeightMeasureSpec(cellHeight, padding)); + } + } + setMeasuredDimension(width, height); + } + + @Override + protected void onLayout(final boolean changed, final int left, final int top, final int right, + final int bottom) { + final int cellWidth = getMeasuredWidth() / GRID_WIDTH; + final int cellHeight = getMeasuredHeight() / GRID_HEIGHT; + final int padding = getResources().getDimensionPixelOffset( + R.dimen.multiple_attachment_preview_padding); + final int count = mPreviewViews.size(); + for (int i = 0; i < count; i++) { + final ViewWrapper viewWrapper = mPreviewViews.get(i); + final View view = viewWrapper.view; + final Tile imageTile = mCurrentLayout.tiles.get(i); + final int tileLeft = imageTile.startX * cellWidth; + final int tileTop = imageTile.startY * cellHeight; + view.layout(tileLeft + padding, tileTop + padding, + tileLeft + view.getMeasuredWidth(), + tileTop + view.getMeasuredHeight()); + if (viewWrapper.needsSlideAnimation) { + trySlideAttachmentView(viewWrapper); + viewWrapper.needsSlideAnimation = false; + } else { + viewWrapper.prevLeft = view.getLeft(); + viewWrapper.prevTop = view.getTop(); + viewWrapper.prevWidth = view.getWidth(); + viewWrapper.prevHeight = view.getHeight(); + } + + if (i == count - 1 && mPlusTextView != null) { + // The plus text view always covers the last attachment. + mPlusTextView.layout(tileLeft + padding, tileTop + padding, + tileLeft + mPlusTextView.getMeasuredWidth(), + tileTop + mPlusTextView.getMeasuredHeight()); + } + } + } + + private void trySlideAttachmentView(final ViewWrapper viewWrapper) { + if (!(viewWrapper.attachment instanceof MediaPickerMessagePartData)) { + return; + } + final View view = viewWrapper.view; + + + final int xOffset = viewWrapper.prevLeft - view.getLeft(); + final int yOffset = viewWrapper.prevTop - view.getTop(); + final float scaleX = viewWrapper.prevWidth / (float) view.getWidth(); + final float scaleY = viewWrapper.prevHeight / (float) view.getHeight(); + + if (xOffset == 0 && yOffset == 0 && scaleX == 1 && scaleY == 1) { + // Layout hasn't changed + return; + } + + final AnimationSet animationSet = new AnimationSet( + true /* shareInterpolator */); + animationSet.addAnimation(new TranslateAnimation(xOffset, 0, yOffset, 0)); + animationSet.addAnimation(new ScaleAnimation(scaleX, 1, scaleY, 1)); + animationSet.setDuration( + UiUtils.MEDIAPICKER_TRANSITION_DURATION); + animationSet.setInterpolator(UiUtils.DEFAULT_INTERPOLATOR); + view.startAnimation(animationSet); + view.invalidate(); + viewWrapper.prevLeft = view.getLeft(); + viewWrapper.prevTop = view.getTop(); + viewWrapper.prevWidth = view.getWidth(); + viewWrapper.prevHeight = view.getHeight(); + } + + public View findViewForAttachment(final MessagePartData attachment) { + for (ViewWrapper wrapper : mPreviewViews) { + if (wrapper.attachment.equals(attachment) && + !(wrapper.attachment instanceof PendingAttachmentData)) { + return wrapper.view; + } + } + return null; + } + + private static class ViewWrapper { + final View view; + final MessagePartData attachment; + boolean needsSlideAnimation; + int prevLeft; + int prevTop; + int prevWidth; + int prevHeight; + + ViewWrapper(final View view, final MessagePartData attachment) { + this.view = view; + this.attachment = attachment; + } + } +} diff --git a/src/com/android/messaging/ui/OrientedBitmapDrawable.java b/src/com/android/messaging/ui/OrientedBitmapDrawable.java new file mode 100644 index 0000000..9242668 --- /dev/null +++ b/src/com/android/messaging/ui/OrientedBitmapDrawable.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.drawable.BitmapDrawable; +import android.view.Gravity; + +import com.android.messaging.util.exif.ExifInterface; + +/** + * A drawable that draws a bitmap in a flipped or rotated orientation without having to adjust the + * bitmap + */ +public class OrientedBitmapDrawable extends BitmapDrawable { + private final ExifInterface.OrientationParams mOrientationParams; + private final Rect mDstRect; + private int mCenterX; + private int mCenterY; + private boolean mApplyGravity; + + public static BitmapDrawable create(final int orientation, Resources res, Bitmap bitmap) { + if (orientation <= ExifInterface.Orientation.TOP_LEFT) { + // No need to adjust the bitmap, so just use a regular BitmapDrawable + return new BitmapDrawable(res, bitmap); + } else { + // Create an oriented bitmap drawable + return new OrientedBitmapDrawable(orientation, res, bitmap); + } + } + + private OrientedBitmapDrawable(final int orientation, Resources res, Bitmap bitmap) { + super(res, bitmap); + mOrientationParams = ExifInterface.getOrientationParams(orientation); + mApplyGravity = true; + mDstRect = new Rect(); + } + + @Override + public int getIntrinsicWidth() { + if (mOrientationParams.invertDimensions) { + return super.getIntrinsicHeight(); + } + return super.getIntrinsicWidth(); + } + + @Override + public int getIntrinsicHeight() { + if (mOrientationParams.invertDimensions) { + return super.getIntrinsicWidth(); + } + return super.getIntrinsicHeight(); + } + + @Override + protected void onBoundsChange(Rect bounds) { + super.onBoundsChange(bounds); + mApplyGravity = true; + } + + @Override + public void draw(Canvas canvas) { + if (mApplyGravity) { + Gravity.apply(getGravity(), getIntrinsicWidth(), getIntrinsicHeight(), getBounds(), + mDstRect); + mCenterX = mDstRect.centerX(); + mCenterY = mDstRect.centerY(); + if (mOrientationParams.invertDimensions) { + final Matrix matrix = new Matrix(); + matrix.setRotate(mOrientationParams.rotation, mCenterX, mCenterY); + final RectF rotatedRect = new RectF(mDstRect); + matrix.mapRect(rotatedRect); + mDstRect.set((int) rotatedRect.left, (int) rotatedRect.top, (int) rotatedRect.right, + (int) rotatedRect.bottom); + } + + mApplyGravity = false; + } + canvas.save(); + canvas.scale(mOrientationParams.scaleX, mOrientationParams.scaleY, mCenterX, mCenterY); + canvas.rotate(mOrientationParams.rotation, mCenterX, mCenterY); + canvas.drawBitmap(getBitmap(), (Rect) null, mDstRect, getPaint()); + canvas.restore(); + } +} diff --git a/src/com/android/messaging/ui/PagerViewHolder.java b/src/com/android/messaging/ui/PagerViewHolder.java new file mode 100644 index 0000000..2f33a0f --- /dev/null +++ b/src/com/android/messaging/ui/PagerViewHolder.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.view.View; +import android.view.ViewGroup; + +/** + * Holds reusable View(s) for a {@link FixedViewPagerAdapter} to display a page. By using + * reusable Views inside ViewPagers this allows us to get rid of nested fragments and the messy + * activity lifecycle problems they entail. + */ +public interface PagerViewHolder extends PersistentInstanceState { + /** Instructs the pager to clean up any view related resources + * @return the destroyed View so that the adapter may remove it from the container, or + * null if no View has been created. */ + View destroyView(); + + /** @return The view that presents the page view to the user */ + View getView(ViewGroup container); +} diff --git a/src/com/android/messaging/ui/PagingAwareViewPager.java b/src/com/android/messaging/ui/PagingAwareViewPager.java new file mode 100644 index 0000000..cc7b2cd --- /dev/null +++ b/src/com/android/messaging/ui/PagingAwareViewPager.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; +import android.support.v4.view.PagerAdapter; +import android.support.v4.view.ViewPager; +import android.util.AttributeSet; +import android.view.MotionEvent; + +import com.android.messaging.util.UiUtils; + +/** + * A simple extension on the standard ViewPager which lets you turn paging on/off. + */ +public class PagingAwareViewPager extends ViewPager { + private boolean mPagingEnabled = true; + + public PagingAwareViewPager(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void setCurrentItem(int item, boolean smoothScroll) { + super.setCurrentItem(getRtlPosition(item), smoothScroll); + } + + @Override + public void setCurrentItem(int item) { + super.setCurrentItem(getRtlPosition(item)); + } + + @Override + public int getCurrentItem() { + int position = super.getCurrentItem(); + return getRtlPosition(position); + } + + /** + * Switches position in pager to be adjusted for if we are in RtL mode + * + * @param position + * @return position adjusted if in rtl mode + */ + protected int getRtlPosition(final int position) { + final PagerAdapter adapter = getAdapter(); + if (adapter != null && UiUtils.isRtlMode()) { + return adapter.getCount() - 1 - position; + } + return position; + } + + @Override + public boolean onTouchEvent(final MotionEvent event) { + if (!mPagingEnabled) { + return false; + } + return super.onTouchEvent(event); + } + + @Override + public boolean onInterceptTouchEvent(final MotionEvent event) { + if (!mPagingEnabled) { + return false; + } + return super.onInterceptTouchEvent(event); + } + + public void setPagingEnabled(final boolean enabled) { + this.mPagingEnabled = enabled; + } + + /** This prevents touch-less scrolling eg. while doing accessibility navigation. */ + @Override + public boolean canScrollHorizontally(int direction) { + if (mPagingEnabled) { + return super.canScrollHorizontally(direction); + } else { + return false; + } + } +} diff --git a/src/com/android/messaging/ui/PermissionCheckActivity.java b/src/com/android/messaging/ui/PermissionCheckActivity.java new file mode 100644 index 0000000..e992a10 --- /dev/null +++ b/src/com/android/messaging/ui/PermissionCheckActivity.java @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.os.SystemClock; +import android.provider.Settings; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.TextView; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.UiUtils; + +/** + * Activity to check if the user has required permissions. If not, it will try to prompt the user + * to grant permissions. However, the OS may not actually prompt the user if the user had + * previously checked the "Never ask again" checkbox while denying the required permissions. + */ +public class PermissionCheckActivity extends Activity { + private static final int REQUIRED_PERMISSIONS_REQUEST_CODE = 1; + private static final long AUTOMATED_RESULT_THRESHOLD_MILLLIS = 250; + private static final String PACKAGE_URI_PREFIX = "package:"; + private long mRequestTimeMillis; + private TextView mNextView; + private TextView mSettingsView; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (redirectIfNeeded()) { + return; + } + + setContentView(R.layout.permission_check_activity); + UiUtils.setStatusBarColor(this, getColor(R.color.permission_check_activity_background)); + + findViewById(R.id.exit).setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View view) { + finish(); + } + }); + + mNextView = (TextView) findViewById(R.id.next); + mNextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View view) { + tryRequestPermission(); + } + }); + + mSettingsView = (TextView) findViewById(R.id.settings); + mSettingsView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View view) { + final Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse(PACKAGE_URI_PREFIX + getPackageName())); + startActivity(intent); + } + }); + } + + @Override + public void onResume() { + super.onResume(); + + if (redirectIfNeeded()) { + return; + } + } + + private void tryRequestPermission() { + final String[] missingPermissions = OsUtil.getMissingRequiredPermissions(); + if (missingPermissions.length == 0) { + redirect(); + return; + } + + mRequestTimeMillis = SystemClock.elapsedRealtime(); + requestPermissions(missingPermissions, REQUIRED_PERMISSIONS_REQUEST_CODE); + } + + @Override + public void onRequestPermissionsResult( + final int requestCode, final String permissions[], final int[] grantResults) { + if (requestCode == REQUIRED_PERMISSIONS_REQUEST_CODE) { + // We do not use grantResults as some of the granted permissions might have been + // revoked while the permissions dialog box was being shown for the missing permissions. + if (OsUtil.hasRequiredPermissions()) { + Factory.get().onRequiredPermissionsAcquired(); + redirect(); + } else { + final long currentTimeMillis = SystemClock.elapsedRealtime(); + // If the permission request completes very quickly, it must be because the system + // automatically denied. This can happen if the user had previously denied it + // and checked the "Never ask again" check box. + if ((currentTimeMillis - mRequestTimeMillis) < AUTOMATED_RESULT_THRESHOLD_MILLLIS) { + mNextView.setVisibility(View.GONE); + + mSettingsView.setVisibility(View.VISIBLE); + findViewById(R.id.enable_permission_procedure).setVisibility(View.VISIBLE); + } + } + } + } + + /** Returns true if the redirecting was performed */ + private boolean redirectIfNeeded() { + if (!OsUtil.hasRequiredPermissions()) { + return false; + } + + redirect(); + return true; + } + + private void redirect() { + UIIntents.get().launchConversationListActivity(this); + finish(); + } +} diff --git a/src/com/android/messaging/ui/PersistentInstanceState.java b/src/com/android/messaging/ui/PersistentInstanceState.java new file mode 100644 index 0000000..3cc1856 --- /dev/null +++ b/src/com/android/messaging/ui/PersistentInstanceState.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.os.Parcelable; + +/** + * Wraps around functionality to save, restore and reset a particular UI component's state. + */ +public interface PersistentInstanceState { + /** + * Saves necessary information about the current state of the instance to later restore + * the instance to its original state. + */ + Parcelable saveState(); + + /** + * Given a previously saved instance state, attempt to restore to its original view state. + */ + void restoreState(Parcelable restoredState); + + /** + * Called when any current/preserved state should be reset to zero state. + */ + void resetState(); +} diff --git a/src/com/android/messaging/ui/PersonItemView.java b/src/com/android/messaging/ui/PersonItemView.java new file mode 100644 index 0000000..afd1a99 --- /dev/null +++ b/src/com/android/messaging/ui/PersonItemView.java @@ -0,0 +1,242 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; +import android.content.Intent; +import android.support.v4.text.BidiFormatter; +import android.support.v4.text.TextDirectionHeuristicsCompat; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnLayoutChangeListener; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.binding.DetachableBinding; +import com.android.messaging.datamodel.data.PersonItemData; +import com.android.messaging.datamodel.data.PersonItemData.PersonItemDataListener; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.UiUtils; + +/** + * Shows a view for a "person" - could be a contact or a participant. This always shows a + * contact icon on the left, and the person's display name on the right. + * + * This view is always bound to an abstract PersonItemData class, so to use it for a specific + * scenario, all you need to do is to create a concrete PersonItemData subclass that bridges + * between the underlying data (e.g. ParticipantData) and what the UI wants (e.g. display name). + */ +public class PersonItemView extends LinearLayout implements PersonItemDataListener, + OnLayoutChangeListener { + public interface PersonItemViewListener { + void onPersonClicked(PersonItemData data); + boolean onPersonLongClicked(PersonItemData data); + } + + protected final DetachableBinding mBinding; + private TextView mNameTextView; + private TextView mDetailsTextView; + private ContactIconView mContactIconView; + private View mDetailsContainer; + private PersonItemViewListener mListener; + private boolean mAvatarOnly; + + public PersonItemView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mBinding = BindingBase.createDetachableBinding(this); + LayoutInflater.from(getContext()).inflate(R.layout.person_item_view, this, true); + } + + @Override + protected void onFinishInflate() { + mNameTextView = (TextView) findViewById(R.id.name); + mDetailsTextView = (TextView) findViewById(R.id.details); + mContactIconView = (ContactIconView) findViewById(R.id.contact_icon); + mDetailsContainer = findViewById(R.id.details_container); + mNameTextView.addOnLayoutChangeListener(this); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (mBinding.isBound()) { + mBinding.detach(); + } + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + mBinding.reAttachIfPossible(); + } + + /** + * Binds to a person item data which will provide us info to be displayed. + * @param personData the PersonItemData to be bound to. + */ + public void bind(final PersonItemData personData) { + if (mBinding.isBound()) { + if (mBinding.getData().equals(personData)) { + // Don't rebind if we are requesting the same data. + return; + } + mBinding.unbind(); + } + + if (personData != null) { + mBinding.bind(personData); + mBinding.getData().setListener(this); + + // Accessibility reason : in case phone numbers are mixed in the display name, + // we need to vocalize it for talkback. + final String vocalizedDisplayName = AccessibilityUtil.getVocalizedPhoneNumber( + getResources(), getDisplayName()); + mNameTextView.setContentDescription(vocalizedDisplayName); + } + updateViewAppearance(); + } + + /** + * @return Display name, possibly comma-ellipsized. + */ + private String getDisplayName() { + final int width = mNameTextView.getMeasuredWidth(); + final String displayName = mBinding.getData().getDisplayName(); + if (width == 0 || TextUtils.isEmpty(displayName) || !displayName.contains(",")) { + return displayName; + } + final String plusOneString = getContext().getString(R.string.plus_one); + final String plusNString = getContext().getString(R.string.plus_n); + return BidiFormatter.getInstance().unicodeWrap( + UiUtils.commaEllipsize( + displayName, + mNameTextView.getPaint(), + width, + plusOneString, + plusNString).toString(), + TextDirectionHeuristicsCompat.LTR); + } + + @Override + public void onLayoutChange(final View v, final int left, final int top, final int right, + final int bottom, final int oldLeft, final int oldTop, final int oldRight, + final int oldBottom) { + if (mBinding.isBound() && v == mNameTextView) { + setNameTextView(); + } + } + + /** + * When set to true, we display only the avatar of the person and hide everything else. + */ + public void setAvatarOnly(final boolean avatarOnly) { + mAvatarOnly = avatarOnly; + mDetailsContainer.setVisibility(avatarOnly ? GONE : VISIBLE); + } + + public boolean isAvatarOnly() { + return mAvatarOnly; + } + + public void setNameTextColor(final int color) { + mNameTextView.setTextColor(color); + } + + public void setDetailsTextColor(final int color) { + mDetailsTextView.setTextColor(color); + } + + public void setListener(final PersonItemViewListener listener) { + mListener = listener; + if (mListener == null) { + return; + } + setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View v) { + if (mListener != null && mBinding.isBound()) { + mListener.onPersonClicked(mBinding.getData()); + } + } + }); + final OnLongClickListener onLongClickListener = new OnLongClickListener() { + @Override + public boolean onLongClick(View v) { + if (mListener != null && mBinding.isBound()) { + return mListener.onPersonLongClicked(mBinding.getData()); + } + return false; + } + }; + setOnLongClickListener(onLongClickListener); + mContactIconView.setOnLongClickListener(onLongClickListener); + } + + public void performClickOnAvatar() { + mContactIconView.performClick(); + } + + protected void updateViewAppearance() { + if (mBinding.isBound()) { + setNameTextView(); + + final String details = mBinding.getData().getDetails(); + if (TextUtils.isEmpty(details)) { + mDetailsTextView.setVisibility(GONE); + } else { + mDetailsTextView.setVisibility(VISIBLE); + mDetailsTextView.setText(details); + } + + mContactIconView.setImageResourceUri(mBinding.getData().getAvatarUri(), + mBinding.getData().getContactId(), mBinding.getData().getLookupKey(), + mBinding.getData().getNormalizedDestination()); + } else { + mNameTextView.setText(""); + mContactIconView.setImageResourceUri(null); + } + } + + private void setNameTextView() { + final String displayName = getDisplayName(); + if (TextUtils.isEmpty(displayName)) { + mNameTextView.setVisibility(GONE); + } else { + mNameTextView.setVisibility(VISIBLE); + mNameTextView.setText(displayName); + } + } + + @Override + public void onPersonDataUpdated(final PersonItemData data) { + mBinding.ensureBound(data); + updateViewAppearance(); + } + + @Override + public void onPersonDataFailed(final PersonItemData data, final Exception exception) { + mBinding.ensureBound(data); + updateViewAppearance(); + } + + public Intent getClickIntent() { + return mBinding.getData().getClickIntent(); + } +} diff --git a/src/com/android/messaging/ui/PlaceholderInsetDrawable.java b/src/com/android/messaging/ui/PlaceholderInsetDrawable.java new file mode 100644 index 0000000..dda2e7b --- /dev/null +++ b/src/com/android/messaging/ui/PlaceholderInsetDrawable.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.graphics.drawable.Drawable; +import android.graphics.drawable.InsetDrawable; + +/** + * A "placeholder" drawable that has the same sizing properties as the real UI element it + * replaces. + * + * This is an InsetDrawable that takes a placeholder drawable (an animation list, or simply + * a color drawable) and place it in the center of the inset drawable that's sized to the + * requested source width and height of the image that it replaces. Unlike the base + * InsetDrawable, this implementation returns the true width and height of the real image + * that it's placeholding, instead of the intrinsic size of the contained drawable, so that + * when used in an ImageView, it may be positioned/scaled/cropped the same way the real + * image is. + */ +public class PlaceholderInsetDrawable extends InsetDrawable { + // The dimensions of the real image that this drawable is replacing. + private final int mSourceWidth; + private final int mSourceHeight; + + /** + * Given a source drawable, wraps it around in this placeholder drawable by placing the + * drawable at the center of the container if possible (or fill the container if the + * drawable doesn't have intrinsic size such as color drawable). + */ + public static PlaceholderInsetDrawable fromDrawable(final Drawable drawable, + final int sourceWidth, final int sourceHeight) { + final int drawableWidth = drawable.getIntrinsicWidth(); + final int drawableHeight = drawable.getIntrinsicHeight(); + final int insetHorizontal = drawableWidth < 0 || drawableWidth > sourceWidth ? + 0 : (sourceWidth - drawableWidth) / 2; + final int insetVertical = drawableHeight < 0 || drawableHeight > sourceHeight ? + 0 : (sourceHeight - drawableHeight) / 2; + return new PlaceholderInsetDrawable(drawable, insetHorizontal, insetVertical, + insetHorizontal, insetVertical, sourceWidth, sourceHeight); + } + + private PlaceholderInsetDrawable(final Drawable drawable, final int insetLeft, + final int insetTop, final int insetRight, final int insetBottom, + final int sourceWidth, final int sourceHeight) { + super(drawable, insetLeft, insetTop, insetRight, insetBottom); + mSourceWidth = sourceWidth; + mSourceHeight = sourceHeight; + } + + @Override + public int getIntrinsicWidth() { + return mSourceWidth; + } + + @Override + public int getIntrinsicHeight() { + return mSourceHeight; + } +} \ No newline at end of file diff --git a/src/com/android/messaging/ui/PlainTextEditText.java b/src/com/android/messaging/ui/PlainTextEditText.java new file mode 100644 index 0000000..8d6e784 --- /dev/null +++ b/src/com/android/messaging/ui/PlainTextEditText.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.util.AttributeSet; +import android.widget.EditText; + +/** + * We want the EditText used in Conversations to convert text to plain text on paste. This + * conversion would happen anyway on send, so without this class it could appear to the user + * that we would send e.g. bold or italic formatting, but in the sent message it would just be + * plain text. + */ +public class PlainTextEditText extends EditText { + private static final char OBJECT_UNICODE = '\uFFFC'; + + public PlainTextEditText(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + // Intercept and modify the paste event. Let everything else through unchanged. + @Override + public boolean onTextContextMenuItem(final int id) { + if (id == android.R.id.paste) { + // We can use this to know where the text position was originally before we pasted + final int selectionStartPrePaste = getSelectionStart(); + + // Let the EditText's normal paste routine fire, then modify the content after. + // This is simpler than re-implementing the paste logic, which we'd have to do + // if we want to get the text from the clipboard ourselves and then modify it. + + final boolean result = super.onTextContextMenuItem(id); + CharSequence text = getText(); + int selectionStart = getSelectionStart(); + int selectionEnd = getSelectionEnd(); + + // There is an option in the Chrome mobile app to copy image; however, instead of the + // image in the form of the uri, Chrome gives us the html source for the image, which + // the platform paste code turns into the unicode object character. The below section + // of code looks for that edge case and replaces it with the url for the image. + final int startIndex = selectionStart - 1; + final int pasteStringLength = selectionStart - selectionStartPrePaste; + // Only going to handle the case where the pasted object is the image + if (pasteStringLength == 1 && text.charAt(startIndex) == OBJECT_UNICODE) { + final ClipboardManager clipboard = + (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); + final ClipData clip = clipboard.getPrimaryClip(); + if (clip != null) { + ClipData.Item item = clip.getItemAt(0); + StringBuilder sb = new StringBuilder(text); + final String url = item.getText().toString(); + sb.replace(selectionStartPrePaste, selectionStart, url); + text = sb.toString(); + selectionStart = selectionStartPrePaste + url.length(); + selectionEnd = selectionStart; + } + } + + // This removes the formatting due to the conversion to string. + setText(text.toString(), BufferType.EDITABLE); + + // Restore the cursor selection state. + setSelection(selectionStart, selectionEnd); + return result; + } else { + return super.onTextContextMenuItem(id); + } + } +} diff --git a/src/com/android/messaging/ui/PlaybackStateView.java b/src/com/android/messaging/ui/PlaybackStateView.java new file mode 100644 index 0000000..8d9aac7 --- /dev/null +++ b/src/com/android/messaging/ui/PlaybackStateView.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +/** + * An interface for a UI element ("View") that reflects the playback state of a piece of media + * content. It needs to support the ability to take common playback commands (play, pause, stop, + * restart) and reflect the state in UI (through timer or progress bar etc.) + */ +public interface PlaybackStateView { + /** + * Restart the playback. + */ + void restart(); + + /** + * Reset ("stop") the playback to the starting position. + */ + void reset(); + + /** + * Resume the playback, or start it if it hasn't been started yet. + */ + void resume(); + + /** + * Pause the playback. + */ + void pause(); +} diff --git a/src/com/android/messaging/ui/RemoteInputEntrypointActivity.java b/src/com/android/messaging/ui/RemoteInputEntrypointActivity.java new file mode 100644 index 0000000..c731164 --- /dev/null +++ b/src/com/android/messaging/ui/RemoteInputEntrypointActivity.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Intent; +import android.os.Bundle; +import android.telephony.TelephonyManager; + +import com.android.messaging.datamodel.NoConfirmationSmsSendService; +import com.android.messaging.util.LogUtil; + +public class RemoteInputEntrypointActivity extends BaseBugleActivity { + private static final String TAG = LogUtil.BUGLE_TAG; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Intent intent = getIntent(); + if (intent == null) { + LogUtil.w(TAG, "No intent attached"); + setResult(RESULT_CANCELED); + finish(); + return; + } + + // Perform some action depending on the intent + String action = intent.getAction(); + if (Intent.ACTION_SENDTO.equals(action)) { + // Build and send the intent + final Intent sendIntent = new Intent(this, NoConfirmationSmsSendService.class); + sendIntent.setAction(TelephonyManager.ACTION_RESPOND_VIA_MESSAGE); + sendIntent.putExtras(intent); + // Wear apparently passes all of its extras via the clip data. Must pass it along. + sendIntent.setClipData(intent.getClipData()); + startService(sendIntent); + setResult(RESULT_OK); + } else { + LogUtil.w(TAG, "Unrecognized intent action: " + action); + setResult(RESULT_CANCELED); + } + // This activity should never stick around after processing the intent + finish(); + } +} diff --git a/src/com/android/messaging/ui/SmsStorageLowWarningActivity.java b/src/com/android/messaging/ui/SmsStorageLowWarningActivity.java new file mode 100644 index 0000000..6b3e84b --- /dev/null +++ b/src/com/android/messaging/ui/SmsStorageLowWarningActivity.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.app.FragmentTransaction; +import android.os.Bundle; + +/** + * Activity to contain the dialog of warning sms storage low. + */ +public class SmsStorageLowWarningActivity extends BaseBugleFragmentActivity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + FragmentTransaction ft = getFragmentManager().beginTransaction(); + SmsStorageLowWarningFragment fragment = + SmsStorageLowWarningFragment.newInstance(); + ft.add(fragment, null/*tag*/); + ft.commit(); + } +} diff --git a/src/com/android/messaging/ui/SmsStorageLowWarningFragment.java b/src/com/android/messaging/ui/SmsStorageLowWarningFragment.java new file mode 100644 index 0000000..3ebfdcf --- /dev/null +++ b/src/com/android/messaging/ui/SmsStorageLowWarningFragment.java @@ -0,0 +1,267 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.app.Fragment; +import android.app.FragmentTransaction; +import android.content.Context; +import android.content.DialogInterface; +import android.content.res.Resources; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.action.HandleLowStorageAction; +import com.android.messaging.sms.SmsReleaseStorage; +import com.android.messaging.sms.SmsReleaseStorage.Duration; +import com.android.messaging.sms.SmsStorageStatusManager; +import com.android.messaging.util.Assert; +import com.google.common.collect.Lists; + +import java.util.List; + +/** + * Dialog to show the sms storage low warning + */ +public class SmsStorageLowWarningFragment extends Fragment { + private SmsStorageLowWarningFragment() { + } + + public static SmsStorageLowWarningFragment newInstance() { + return new SmsStorageLowWarningFragment(); + } + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final FragmentTransaction ft = getFragmentManager().beginTransaction(); + final ChooseActionDialogFragment dialog = ChooseActionDialogFragment.newInstance(); + dialog.setTargetFragment(this, 0/*requestCode*/); + dialog.show(ft, null/*tag*/); + } + + /** + * Perform confirm action for a specific action + * + * @param actionIndex + */ + private void confirm(final int actionIndex) { + final FragmentTransaction ft = getFragmentManager().beginTransaction(); + final ConfirmationDialog dialog = ConfirmationDialog.newInstance(actionIndex); + dialog.setTargetFragment(this, 0/*requestCode*/); + dialog.show(ft, null/*tag*/); + } + + /** + * The dialog is cancelled at any step + */ + private void cancel() { + getActivity().finish(); + } + + /** + * The dialog to show for user to choose what delete actions to take when storage is low + */ + private static class ChooseActionDialogFragment extends DialogFragment { + public static ChooseActionDialogFragment newInstance() { + return new ChooseActionDialogFragment(); + } + + @Override + public Dialog onCreateDialog(final Bundle savedInstanceState) { + final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + + final LayoutInflater inflater = getActivity().getLayoutInflater(); + final View dialogLayout = inflater.inflate( + R.layout.sms_storage_low_warning_dialog, null); + final ListView actionListView = (ListView) dialogLayout.findViewById( + R.id.free_storage_action_list); + final List actions = loadFreeStorageActions(getActivity().getResources()); + final ActionListAdapter listAdapter = new ActionListAdapter(getActivity(), actions); + actionListView.setAdapter(listAdapter); + + builder.setTitle(R.string.sms_storage_low_title) + .setView(dialogLayout) + .setNegativeButton(R.string.ignore, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + dialog.cancel(); + } + }); + + final Dialog dialog = builder.create(); + dialog.setCanceledOnTouchOutside(false); + return dialog; + } + + @Override + public void onCancel(final DialogInterface dialog) { + ((SmsStorageLowWarningFragment) getTargetFragment()).cancel(); + } + + private class ActionListAdapter extends ArrayAdapter { + public ActionListAdapter(final Context context, final List actions) { + super(context, R.layout.sms_free_storage_action_item_view, actions); + } + + @Override + public View getView(final int position, final View view, final ViewGroup parent) { + TextView actionItemView; + if (view == null || !(view instanceof TextView)) { + final LayoutInflater inflater = LayoutInflater.from(getContext()); + actionItemView = (TextView) inflater.inflate( + R.layout.sms_free_storage_action_item_view, parent, false); + } else { + actionItemView = (TextView) view; + } + + final String action = getItem(position); + actionItemView.setText(action); + actionItemView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View view) { + dismiss(); + ((SmsStorageLowWarningFragment) getTargetFragment()).confirm(position); + } + }); + return actionItemView; + } + } + } + + private static final String KEY_ACTION_INDEX = "action_index"; + + /** + * The dialog to confirm user's delete action + */ + private static class ConfirmationDialog extends DialogFragment { + private Duration mDuration; + private String mDurationString; + + public static ConfirmationDialog newInstance(final int actionIndex) { + final ConfirmationDialog dialog = new ConfirmationDialog(); + final Bundle args = new Bundle(); + args.putInt(KEY_ACTION_INDEX, actionIndex); + dialog.setArguments(args); + return dialog; + } + + @Override + public void onCancel(final DialogInterface dialog) { + ((SmsStorageLowWarningFragment) getTargetFragment()).cancel(); + } + + @Override + public Dialog onCreateDialog(final Bundle savedInstanceState) { + mDuration = SmsReleaseStorage.parseMessageRetainingDuration(); + mDurationString = SmsReleaseStorage.getMessageRetainingDurationString(mDuration); + + final int actionIndex = getArguments().getInt(KEY_ACTION_INDEX); + if (actionIndex < 0 || actionIndex > 1) { + return null; + } + final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setTitle(R.string.sms_storage_low_title) + .setMessage(getConfirmDialogMessage(actionIndex)) + .setNegativeButton(android.R.string.cancel, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, + final int button) { + dismiss(); + ((SmsStorageLowWarningFragment) getTargetFragment()).cancel(); + } + }) + .setPositiveButton(android.R.string.ok, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, + final int button) { + dismiss(); + handleAction(actionIndex); + getActivity().finish(); + SmsStorageStatusManager.cancelStorageLowNotification(); + } + }); + return builder.create(); + } + + private void handleAction(final int actionIndex) { + final long durationInMillis = + SmsReleaseStorage.durationToTimeInMillis(mDuration); + switch (actionIndex) { + case 0: + HandleLowStorageAction.handleDeleteMediaMessages(durationInMillis); + break; + + case 1: + HandleLowStorageAction.handleDeleteOldMessages(durationInMillis); + break; + + default: + Assert.fail("Unsupported action"); + break; + } + } + + /** + * Get the confirm dialog text for a specific delete action + * @param index The action index + * @return + */ + private String getConfirmDialogMessage(final int index) { + switch (index) { + case 0: + return getString(R.string.delete_all_media_confirmation, mDurationString); + case 1: + return getString(R.string.delete_oldest_messages_confirmation, mDurationString); + case 2: + return getString(R.string.auto_delete_oldest_messages_confirmation, + mDurationString); + } + throw new IllegalArgumentException( + "SmsStorageLowWarningFragment: invalid action index " + index); + } + } + + /** + * Load the text of delete message actions + * + * @param resources + * @return + */ + private static List loadFreeStorageActions(final Resources resources) { + final Duration duration = SmsReleaseStorage.parseMessageRetainingDuration(); + final String durationString = SmsReleaseStorage.getMessageRetainingDurationString(duration); + final List actions = Lists.newArrayList(); + actions.add(resources.getString(R.string.delete_all_media)); + actions.add(resources.getString(R.string.delete_oldest_messages, durationString)); + + // TODO: Auto-purging is disabled for Bugle V1. + // actions.add(resources.getString(R.string.auto_delete_oldest_messages, durationString)); + return actions; + } +} diff --git a/src/com/android/messaging/ui/SnackBar.java b/src/com/android/messaging/ui/SnackBar.java new file mode 100644 index 0000000..a278040 --- /dev/null +++ b/src/com/android/messaging/ui/SnackBar.java @@ -0,0 +1,314 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup.MarginLayoutParams; +import android.widget.FrameLayout; +import android.widget.TextView; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.util.Assert; + +import java.util.ArrayList; +import java.util.List; + +public class SnackBar { + public static final int LONG_DURATION_IN_MS = 5000; + public static final int SHORT_DURATION_IN_MS = 1000; + public static final int MAX_DURATION_IN_MS = 10000; + + public interface SnackBarListener { + void onActionClick(); + } + + /** + * Defines an action to be performed when the user clicks on the action button on the snack bar + */ + public static class Action { + private final Runnable mActionRunnable; + private final String mActionLabel; + + public final static int SNACK_BAR_UNDO = 0; + public final static int SNACK_BAR_RETRY = 1; + + private Action(@Nullable Runnable actionRunnable, @Nullable String actionLabel) { + mActionRunnable = actionRunnable; + mActionLabel = actionLabel; + } + + Runnable getActionRunnable() { + return mActionRunnable; + } + + String getActionLabel() { + return mActionLabel; + } + + public static Action createUndoAction(final Runnable undoRunnable) { + return createCustomAction(undoRunnable, Factory.get().getApplicationContext() + .getString(R.string.snack_bar_undo)); + } + + public static Action createRetryAction(final Runnable retryRunnable) { + return createCustomAction(retryRunnable, Factory.get().getApplicationContext() + .getString(R.string.snack_bar_retry)); + } + + + public static Action createCustomAction(final Runnable runnable, final String actionLabel) { + return new Action(runnable, actionLabel); + } + } + + /** + * Defines the placement of the snack bar (e.g. anchored view, anchor gravity). + */ + public static class Placement { + private final View mAnchorView; + private final boolean mAnchorAbove; + + private Placement(@NonNull final View anchorView, final boolean anchorAbove) { + Assert.notNull(anchorView); + mAnchorView = anchorView; + mAnchorAbove = anchorAbove; + } + + public View getAnchorView() { + return mAnchorView; + } + + public boolean getAnchorAbove() { + return mAnchorAbove; + } + + /** + * Anchor the snack bar above the given {@code anchorView}. + */ + public static Placement above(final View anchorView) { + return new Placement(anchorView, true); + } + + /** + * Anchor the snack bar below the given {@code anchorView}. + */ + public static Placement below(final View anchorView) { + return new Placement(anchorView, false); + } + } + + public static class Builder { + private static final List NO_INTERACTIONS = + new ArrayList(); + + private final Context mContext; + private final SnackBarManager mSnackBarManager; + + private String mSnackBarMessage; + private int mDuration = LONG_DURATION_IN_MS; + private List mInteractions = NO_INTERACTIONS; + private Action mAction; + private Placement mPlacement; + // The parent view is only used to get a window token and doesn't affect the layout + private View mParentView; + + public Builder(final SnackBarManager snackBarManager, final View parentView) { + Assert.notNull(snackBarManager); + Assert.notNull(parentView); + mSnackBarManager = snackBarManager; + mContext = parentView.getContext(); + mParentView = parentView; + } + + public Builder setText(final String snackBarMessage) { + Assert.isTrue(!TextUtils.isEmpty(snackBarMessage)); + mSnackBarMessage = snackBarMessage; + return this; + } + + public Builder setAction(final Action action) { + mAction = action; + return this; + } + + /** + * Sets the duration to show this toast for in milliseconds. + */ + public Builder setDuration(final int duration) { + Assert.isTrue(0 < duration && duration < MAX_DURATION_IN_MS); + mDuration = duration; + return this; + } + + /** + * Sets the components that this toast's animation will interact with. These components may + * be animated to make room for the toast. + */ + public Builder withInteractions(final List interactions) { + mInteractions = interactions; + return this; + } + + /** + * Place the snack bar with the given placement requirement. + */ + public Builder withPlacement(final Placement placement) { + Assert.isNull(mPlacement); + mPlacement = placement; + return this; + } + + public SnackBar build() { + return new SnackBar(this); + } + + public void show() { + mSnackBarManager.show(build()); + } + } + + private final View mRootView; + private final Context mContext; + private final View mSnackBarView; + private final String mText; + private final int mDuration; + private final List mInteractions; + private final Action mAction; + private final Placement mPlacement; + private final TextView mActionTextView; + private final TextView mMessageView; + private final FrameLayout mMessageWrapper; + private final View mParentView; + + private SnackBarListener mListener; + + private SnackBar(final Builder builder) { + mContext = builder.mContext; + mRootView = LayoutInflater.from(mContext).inflate(R.layout.snack_bar, + null /* WindowManager will show this in show() below */); + mSnackBarView = mRootView.findViewById(R.id.snack_bar); + mText = builder.mSnackBarMessage; + mDuration = builder.mDuration; + mAction = builder.mAction; + mPlacement = builder.mPlacement; + mParentView = builder.mParentView; + if (builder.mInteractions == null) { + mInteractions = new ArrayList(); + } else { + mInteractions = builder.mInteractions; + } + + mActionTextView = (TextView) mRootView.findViewById(R.id.snack_bar_action); + mMessageView = (TextView) mRootView.findViewById(R.id.snack_bar_message); + mMessageWrapper = (FrameLayout) mRootView.findViewById(R.id.snack_bar_message_wrapper); + + setUpButton(); + setUpTextLines(); + } + + public Context getContext() { + return mContext; + } + + public View getRootView() { + return mRootView; + } + + public View getParentView() { + return mParentView; + } + + public View getSnackBarView() { + return mSnackBarView; + } + + public String getMessageText() { + return mText; + } + + public String getActionLabel() { + if (mAction == null) { + return null; + } + return mAction.getActionLabel(); + } + + public int getDuration() { + return mDuration; + } + + public Placement getPlacement() { + return mPlacement; + } + + public List getInteractions() { + return mInteractions; + } + + public void setEnabled(final boolean enabled) { + mActionTextView.setClickable(enabled); + } + + public void setListener(final SnackBarListener snackBarListener) { + mListener = snackBarListener; + } + + private void setUpButton() { + if (mAction == null || mAction.getActionRunnable() == null) { + mActionTextView.setVisibility(View.GONE); + // In the XML layout we add left/right padding to the button to add space between + // the message text and the button and on the right side we add padding to put space + // between the button and the edge of the snack bar. This is so the button can use the + // padding area as part of it's click target. Since we have no button, we need to put + // some margin on the right side. While the left margin is already set on the wrapper, + // we're setting it again to not have to make a special case for RTL. + final MarginLayoutParams lp = (MarginLayoutParams) mMessageWrapper.getLayoutParams(); + final int leftRightMargin = mContext.getResources().getDimensionPixelSize( + R.dimen.snack_bar_left_right_margin); + lp.leftMargin = leftRightMargin; + lp.rightMargin = leftRightMargin; + mMessageWrapper.setLayoutParams(lp); + } else { + mActionTextView.setVisibility(View.VISIBLE); + mActionTextView.setText(mAction.getActionLabel()); + mActionTextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View v) { + mAction.getActionRunnable().run(); + if (mListener != null) { + mListener.onActionClick(); + } + } + }); + } + } + + private void setUpTextLines() { + if (mText == null) { + mMessageView.setVisibility(View.GONE); + } else { + mMessageView.setVisibility(View.VISIBLE); + mMessageView.setText(mText); + } + } +} diff --git a/src/com/android/messaging/ui/SnackBarInteraction.java b/src/com/android/messaging/ui/SnackBarInteraction.java new file mode 100644 index 0000000..f723caa --- /dev/null +++ b/src/com/android/messaging/ui/SnackBarInteraction.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.view.Gravity; +import android.view.View; +import android.view.ViewPropertyAnimator; + +import com.google.common.base.Preconditions; + +/** + * An interface that defines how a component can be animated with an {@link SnackBar}. + */ +public interface SnackBarInteraction { + /** + * Returns the animator that will be run in reaction to the given SnackBar being shown. + * + * Implementations may return null here if it determines that the given SnackBar does not need + * to animate this component. + */ + ViewPropertyAnimator animateOnSnackBarShow(SnackBar snackBar); + + /** + * Returns the animator that will be run in reaction to the given SnackBar being dismissed. + * + * Implementations may return null here if it determines that the given SnackBar does not need + * to animate this component. + */ + ViewPropertyAnimator animateOnSnackBarDismiss(SnackBar snackBar); + + /** + * A basic implementation of {@link SnackBarInteraction} that assumes that the + * {@link SnackBar} is always shown with {@link Gravity#BOTTOM} and that the provided View will + * always need to be translated up to make room for the SnackBar. + */ + public static class BasicSnackBarInteraction implements SnackBarInteraction { + private final View mView; + + public BasicSnackBarInteraction(final View view) { + mView = Preconditions.checkNotNull(view); + } + + @Override + public ViewPropertyAnimator animateOnSnackBarShow(final SnackBar snackBar) { + final View rootView = snackBar.getRootView(); + return mView.animate().translationY(-rootView.getMeasuredHeight()); + } + + @Override + public ViewPropertyAnimator animateOnSnackBarDismiss(final SnackBar snackBar) { + return mView.animate().translationY(0); + } + } +} \ No newline at end of file diff --git a/src/com/android/messaging/ui/SnackBarManager.java b/src/com/android/messaging/ui/SnackBarManager.java new file mode 100644 index 0000000..e107999 --- /dev/null +++ b/src/com/android/messaging/ui/SnackBarManager.java @@ -0,0 +1,365 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; +import android.graphics.Point; +import android.graphics.Rect; +import android.os.Handler; +import android.text.TextUtils; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.MeasureSpec; +import android.view.View.OnTouchListener; +import android.view.ViewGroup; +import android.view.ViewGroup.LayoutParams; +import android.view.ViewPropertyAnimator; +import android.view.ViewTreeObserver.OnGlobalLayoutListener; +import android.view.WindowManager; +import android.widget.PopupWindow; +import android.widget.PopupWindow.OnDismissListener; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.ui.SnackBar.Placement; +import com.android.messaging.ui.SnackBar.SnackBarListener; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.TextUtil; +import com.android.messaging.util.UiUtils; +import com.google.common.base.Joiner; + +import java.util.List; + +public class SnackBarManager { + + private static SnackBarManager sInstance; + + public static SnackBarManager get() { + if (sInstance == null) { + synchronized (SnackBarManager.class) { + if (sInstance == null) { + sInstance = new SnackBarManager(); + } + } + } + return sInstance; + } + + private final Runnable mDismissRunnable = new Runnable() { + @Override + public void run() { + dismiss(); + } + }; + + private final OnTouchListener mDismissOnTouchListener = new OnTouchListener() { + @Override + public boolean onTouch(final View view, final MotionEvent event) { + // Dismiss the {@link SnackBar} but don't consume the event. + dismiss(); + return false; + } + }; + + private final SnackBarListener mDismissOnUserTapListener = new SnackBarListener() { + @Override + public void onActionClick() { + dismiss(); + } + }; + + private final int mTranslationDurationMs; + private final Handler mHideHandler; + + private SnackBar mCurrentSnackBar; + private SnackBar mLatestSnackBar; + private SnackBar mNextSnackBar; + private boolean mIsCurrentlyDismissing; + private PopupWindow mPopupWindow; + + private SnackBarManager() { + mTranslationDurationMs = Factory.get().getApplicationContext().getResources().getInteger( + R.integer.snackbar_translation_duration_ms); + mHideHandler = new Handler(); + } + + public SnackBar getLatestSnackBar() { + return mLatestSnackBar; + } + + public SnackBar.Builder newBuilder(final View parentView) { + return new SnackBar.Builder(this, parentView); + } + + /** + * The given snackBar is not guaranteed to be shown. If the previous snackBar is animating away, + * and another snackBar is requested to show after this one, this snackBar will be skipped. + */ + public void show(final SnackBar snackBar) { + Assert.notNull(snackBar); + + if (mCurrentSnackBar != null) { + LogUtil.d(LogUtil.BUGLE_TAG, "Showing snack bar, but currentSnackBar was not null."); + + // Dismiss the current snack bar. That will cause the next snack bar to be shown on + // completion. + mNextSnackBar = snackBar; + mLatestSnackBar = snackBar; + dismiss(); + return; + } + + mCurrentSnackBar = snackBar; + mLatestSnackBar = snackBar; + + // We want to know when either button was tapped so we can dismiss. + snackBar.setListener(mDismissOnUserTapListener); + + // Cancel previous dismisses & set dismiss for the delay time. + mHideHandler.removeCallbacks(mDismissRunnable); + mHideHandler.postDelayed(mDismissRunnable, snackBar.getDuration()); + + snackBar.setEnabled(false); + + // For some reason, the addView function does not respect layoutParams. + // We need to explicitly set it first here. + final View rootView = snackBar.getRootView(); + + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) { + LogUtil.d(LogUtil.BUGLE_TAG, "Showing snack bar: " + snackBar); + } + // Measure the snack bar root view so we know how much to translate by. + measureSnackBar(snackBar); + mPopupWindow = new PopupWindow(snackBar.getContext()); + mPopupWindow.setWidth(LayoutParams.MATCH_PARENT); + mPopupWindow.setHeight(LayoutParams.WRAP_CONTENT); + mPopupWindow.setBackgroundDrawable(null); + mPopupWindow.setContentView(rootView); + final Placement placement = snackBar.getPlacement(); + if (placement == null) { + mPopupWindow.showAtLocation( + snackBar.getParentView(), Gravity.BOTTOM | Gravity.START, + 0, getScreenBottomOffset(snackBar)); + } else { + final View anchorView = placement.getAnchorView(); + + // You'd expect PopupWindow.showAsDropDown to ensure the popup moves with the anchor + // view, which it does for scrolling, but not layout changes, so we have to manually + // update while the snackbar is showing + final OnGlobalLayoutListener listener = new OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + mPopupWindow.update(anchorView, 0, getRelativeOffset(snackBar), + anchorView.getWidth(), LayoutParams.WRAP_CONTENT); + } + }; + anchorView.getViewTreeObserver().addOnGlobalLayoutListener(listener); + mPopupWindow.setOnDismissListener(new OnDismissListener() { + @Override + public void onDismiss() { + anchorView.getViewTreeObserver().removeOnGlobalLayoutListener(listener); + } + }); + mPopupWindow.showAsDropDown(anchorView, 0, getRelativeOffset(snackBar)); + } + + + // Animate the toast bar into view. + placeSnackBarOffScreen(snackBar); + animateSnackBarOnScreen(snackBar).withEndAction(new Runnable() { + @Override + public void run() { + mCurrentSnackBar.setEnabled(true); + makeCurrentSnackBarDismissibleOnTouch(); + // Fire an accessibility event as needed + String snackBarText = snackBar.getMessageText(); + if (!TextUtils.isEmpty(snackBarText) && + TextUtils.getTrimmedLength(snackBarText) > 0) { + snackBarText = snackBarText.trim(); + final String snackBarActionText = snackBar.getActionLabel(); + if (!TextUtil.isAllWhitespace(snackBarActionText)) { + snackBarText = Joiner.on(", ").join(snackBarText, snackBarActionText); + } + AccessibilityUtil.announceForAccessibilityCompat(snackBar.getSnackBarView(), + null /*accessibilityManager*/, snackBarText); + } + } + }); + + // Animate any interaction views out of the way. + animateInteractionsOnShow(snackBar); + } + + /** + * Dismisses the current toast that is showing. If there is a toast waiting to be shown, that + * toast will be shown when the current one has been dismissed. + */ + public void dismiss() { + mHideHandler.removeCallbacks(mDismissRunnable); + + if (mCurrentSnackBar == null || mIsCurrentlyDismissing) { + return; + } + + final SnackBar snackBar = mCurrentSnackBar; + + LogUtil.d(LogUtil.BUGLE_TAG, "Dismissing snack bar."); + mIsCurrentlyDismissing = true; + + snackBar.setEnabled(false); + + // Animate the toast bar down. + final View rootView = snackBar.getRootView(); + animateSnackBarOffScreen(snackBar).withEndAction(new Runnable() { + @Override + public void run() { + rootView.setVisibility(View.GONE); + try { + mPopupWindow.dismiss(); + } catch (IllegalArgumentException e) { + // PopupWindow.dismiss() will fire an IllegalArgumentException if the activity + // has already ended while we were animating + } + + mCurrentSnackBar = null; + mIsCurrentlyDismissing = false; + + // Show the next toast if one is waiting. + if (mNextSnackBar != null) { + final SnackBar localNextSnackBar = mNextSnackBar; + mNextSnackBar = null; + show(localNextSnackBar); + } + } + }); + + // Animate any interaction views back. + animateInteractionsOnDismiss(snackBar); + } + + private void makeCurrentSnackBarDismissibleOnTouch() { + // Set touching on the entire view, the {@link SnackBar} itself, as + // well as the button's dismiss the toast. + mCurrentSnackBar.getRootView().setOnTouchListener(mDismissOnTouchListener); + mCurrentSnackBar.getSnackBarView().setOnTouchListener(mDismissOnTouchListener); + } + + private void measureSnackBar(final SnackBar snackBar) { + final View rootView = snackBar.getRootView(); + final Point displaySize = new Point(); + getWindowManager(snackBar.getContext()).getDefaultDisplay().getSize(displaySize); + final int widthSpec = ViewGroup.getChildMeasureSpec( + MeasureSpec.makeMeasureSpec(displaySize.x, MeasureSpec.EXACTLY), + 0, LayoutParams.MATCH_PARENT); + final int heightSpec = ViewGroup.getChildMeasureSpec( + MeasureSpec.makeMeasureSpec(displaySize.y, MeasureSpec.EXACTLY), + 0, LayoutParams.WRAP_CONTENT); + rootView.measure(widthSpec, heightSpec); + } + + private void placeSnackBarOffScreen(final SnackBar snackBar) { + final View rootView = snackBar.getRootView(); + final View snackBarView = snackBar.getSnackBarView(); + snackBarView.setTranslationY(rootView.getMeasuredHeight()); + } + + private ViewPropertyAnimator animateSnackBarOnScreen(final SnackBar snackBar) { + final View snackBarView = snackBar.getSnackBarView(); + return normalizeAnimator(snackBarView.animate()).translationX(0).translationY(0); + } + + private ViewPropertyAnimator animateSnackBarOffScreen(final SnackBar snackBar) { + final View rootView = snackBar.getRootView(); + final View snackBarView = snackBar.getSnackBarView(); + return normalizeAnimator(snackBarView.animate()).translationY(rootView.getHeight()); + } + + private void animateInteractionsOnShow(final SnackBar snackBar) { + final List interactions = snackBar.getInteractions(); + for (final SnackBarInteraction interaction : interactions) { + if (interaction != null) { + final ViewPropertyAnimator animator = interaction.animateOnSnackBarShow(snackBar); + if (animator != null) { + normalizeAnimator(animator); + } + } + } + } + + private void animateInteractionsOnDismiss(final SnackBar snackBar) { + final List interactions = snackBar.getInteractions(); + for (final SnackBarInteraction interaction : interactions) { + if (interaction != null) { + final ViewPropertyAnimator animator = + interaction.animateOnSnackBarDismiss(snackBar); + if (animator != null) { + normalizeAnimator(animator); + } + } + } + } + + private ViewPropertyAnimator normalizeAnimator(final ViewPropertyAnimator animator) { + return animator + .setInterpolator(UiUtils.DEFAULT_INTERPOLATOR) + .setDuration(mTranslationDurationMs); + } + + private WindowManager getWindowManager(final Context context) { + return (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + } + + /** + * Get the offset from the bottom of the screen where the snack bar should be placed. + */ + private int getScreenBottomOffset(final SnackBar snackBar) { + final WindowManager windowManager = getWindowManager(snackBar.getContext()); + final DisplayMetrics displayMetrics = new DisplayMetrics(); + if (OsUtil.isAtLeastL()) { + windowManager.getDefaultDisplay().getRealMetrics(displayMetrics); + } else { + windowManager.getDefaultDisplay().getMetrics(displayMetrics); + } + final int screenHeight = displayMetrics.heightPixels; + + if (OsUtil.isAtLeastL()) { + // In L, the navigation bar is included in the space for the popup window, so we have to + // offset by the size of the navigation bar + final Rect displayRect = new Rect(); + snackBar.getParentView().getRootView().getWindowVisibleDisplayFrame(displayRect); + return screenHeight - displayRect.bottom; + } + + return 0; + } + + private int getRelativeOffset(final SnackBar snackBar) { + final Placement placement = snackBar.getPlacement(); + Assert.notNull(placement); + final View anchorView = placement.getAnchorView(); + if (placement.getAnchorAbove()) { + return -snackBar.getRootView().getMeasuredHeight() - anchorView.getHeight(); + } else { + // Use the default dropdown positioning + return 0; + } + } +} diff --git a/src/com/android/messaging/ui/TestActivity.java b/src/com/android/messaging/ui/TestActivity.java new file mode 100644 index 0000000..1693660 --- /dev/null +++ b/src/com/android/messaging/ui/TestActivity.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.app.Fragment; +import android.os.Bundle; +import android.support.v4.app.FragmentActivity; +import android.view.View; +import android.view.WindowManager; +import android.widget.FrameLayout; + +import com.android.messaging.R; +import com.android.messaging.util.LogUtil; +import com.google.common.annotations.VisibleForTesting; + +/** + * An empty activity that can be used to host a fragment or view for unit testing purposes. Lives in + * app code vs test code due to requirement of ActivityInstrumentationTestCase2. + */ +public class TestActivity extends FragmentActivity { + private FragmentEventListener mFragmentEventListener; + + public interface FragmentEventListener { + public void onAttachFragment(Fragment fragment); + } + + @Override + protected void onCreate(final Bundle bundle) { + super.onCreate(bundle); + + if (bundle != null) { + // The test case may have configured the fragment, and recreating the activity will + // lose that configuration. The real activity is the only activity that would know + // how to reapply that configuration. + throw new IllegalStateException("TestActivity cannot get recreated"); + } + + // There is a race condition, but this often makes it possible for tests to run with the + // key guard up + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON | + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD | + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED | + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON); + + setContentView(R.layout.test_activity); + } + + @VisibleForTesting + public void setFragment(final Fragment fragment) { + LogUtil.i(LogUtil.BUGLE_TAG, "TestActivity.setFragment"); + getFragmentManager() + .beginTransaction() + .replace(R.id.test_content, fragment) + .commit(); + getFragmentManager().executePendingTransactions(); + } + + @VisibleForTesting + public void setView(final View view) { + LogUtil.i(LogUtil.BUGLE_TAG, "TestActivity.setView"); + ((FrameLayout) findViewById(R.id.test_content)).addView(view); + } + + @Override + public void onAttachFragment(final Fragment fragment) { + if (mFragmentEventListener != null) { + mFragmentEventListener.onAttachFragment(fragment); + } + } + + public void setFragmentEventListener(final FragmentEventListener fragmentEventListener) { + mFragmentEventListener = fragmentEventListener; + } +} diff --git a/src/com/android/messaging/ui/UIIntents.java b/src/com/android/messaging/ui/UIIntents.java new file mode 100644 index 0000000..e5f8a52 --- /dev/null +++ b/src/com/android/messaging/ui/UIIntents.java @@ -0,0 +1,378 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.app.Activity; +import android.app.Fragment; +import android.app.PendingIntent; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.graphics.Point; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Bundle; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.util.ConversationIdSet; + +/** + * A central repository of Intents used to start activities. + */ +public abstract class UIIntents { + public static UIIntents get() { + return Factory.get().getUIIntents(); + } + + // Intent extras + public static final String UI_INTENT_EXTRA_CONVERSATION_ID = "conversation_id"; + + // Sending draft data (from share intent / message forwarding) to the ConversationActivity. + public static final String UI_INTENT_EXTRA_DRAFT_DATA = "draft_data"; + + // The request code for picking image from the Document picker. + public static final int REQUEST_PICK_IMAGE_FROM_DOCUMENT_PICKER = 1400; + + // Indicates what type of notification this applies to (See BugleNotifications: + // UPDATE_NONE, UPDATE_MESSAGES, UPDATE_ERRORS, UPDATE_ALL) + public static final String UI_INTENT_EXTRA_NOTIFICATIONS_UPDATE = "notifications_update"; + + // Pass a set of conversation id's. + public static final String UI_INTENT_EXTRA_CONVERSATION_ID_SET = "conversation_id_set"; + + // Sending class zero message to its activity + public static final String UI_INTENT_EXTRA_MESSAGE_VALUES = "message_values"; + + // For the widget to go to the ConversationList from the Conversation. + public static final String UI_INTENT_EXTRA_GOTO_CONVERSATION_LIST = "goto_conv_list"; + + // Indicates whether a conversation is launched with custom transition. + public static final String UI_INTENT_EXTRA_WITH_CUSTOM_TRANSITION = "with_custom_transition"; + + public static final String ACTION_RESET_NOTIFICATIONS = + "com.android.messaging.reset_notifications"; + + // Sending VCard uri to VCard detail activity + public static final String UI_INTENT_EXTRA_VCARD_URI = "vcard_uri"; + + public static final String CMAS_COMPONENT = "com.android.cellbroadcastreceiver"; + + // Intent action for local broadcast receiver for conversation self id change. + public static final String CONVERSATION_SELF_ID_CHANGE_BROADCAST_ACTION = + "conversation_self_id_change"; + + // Conversation self id + public static final String UI_INTENT_EXTRA_CONVERSATION_SELF_ID = "conversation_self_id"; + + // For opening an APN editor on a particular row in the apn database. + public static final String UI_INTENT_EXTRA_APN_ROW_ID = "apn_row_id"; + + // Subscription id + public static final String UI_INTENT_EXTRA_SUB_ID = "sub_id"; + + // Per-Subscription setting activity title + public static final String UI_INTENT_EXTRA_PER_SUBSCRIPTION_SETTING_TITLE = + "per_sub_setting_title"; + + // Is application settings launched as the top level settings activity? + public static final String UI_INTENT_EXTRA_TOP_LEVEL_SETTINGS = "top_level_settings"; + + // Sending attachment uri from widget + public static final String UI_INTENT_EXTRA_ATTACHMENT_URI = "attachment_uri"; + + // Sending attachment content type from widget + public static final String UI_INTENT_EXTRA_ATTACHMENT_TYPE = "attachment_type"; + + public static final String ACTION_WIDGET_CONVERSATION = + "com.android.messaging.widget_conversation:"; + + public static final String UI_INTENT_EXTRA_REQUIRES_MMS = "requires_mms"; + + public static final String UI_INTENT_EXTRA_SELF_ID = "self_id"; + + // Message position to scroll to. + public static final String UI_INTENT_EXTRA_MESSAGE_POSITION = "message_position"; + + /** + * Launch the permission check activity + */ + public abstract void launchPermissionCheckActivity(final Context context); + + public abstract void launchConversationListActivity(final Context context); + + /** + * Launch an activity to show a conversation. This method by default provides no additional + * activity options. + */ + public void launchConversationActivity(final Context context, + final String conversationId, final MessageData draft) { + launchConversationActivity(context, conversationId, draft, null, + false /* withCustomTransition */); + } + + /** + * Launch an activity to show a conversation. + */ + public abstract void launchConversationActivity(final Context context, + final String conversationId, final MessageData draft, final Bundle activityOptions, + final boolean withCustomTransition); + + + /** + * Launch an activity to show conversation with conversation list in back stack. + */ + public abstract void launchConversationActivityWithParentStack(Context context, + String conversationId, String smsBody); + + /** + * Launch an activity to show a conversation as a new task. + */ + public abstract void launchConversationActivityNewTask(final Context context, + final String conversationId); + + /** + * Launch an activity to start a new conversation + */ + public abstract void launchCreateNewConversationActivity(final Context context, + final MessageData draft); + + /** + * Launch debug activity to set MMS config options. + */ + public abstract void launchDebugMmsConfigActivity(final Context context); + + /** + * Launch an activity to change settings. + */ + public abstract void launchSettingsActivity(final Context context); + + /** + * Launch an activity to add a contact with a given destination. + */ + public abstract void launchAddContactActivity(final Context context, final String destination); + + /** + * Launch an activity to show the document picker to pick an image. + * @param fragment the requesting fragment + */ + public abstract void launchDocumentImagePicker(final Fragment fragment); + + /** + * Launch an activity to show people & options for a given conversation. + */ + public abstract void launchPeopleAndOptionsActivity(final Activity context, + final String conversationId); + + /** + * Launch an external activity to handle a phone call + * @param phoneNumber the phone number to call + * @param clickPosition is the location tapped to start this launch for transition use + */ + public abstract void launchPhoneCallActivity(final Context context, final String phoneNumber, + final Point clickPosition); + + /** + * Launch an activity to show archived conversations. + */ + public abstract void launchArchivedConversationsActivity(final Context context); + + /** + * Launch an activity to show blocked participants. + */ + public abstract void launchBlockedParticipantsActivity(final Context context); + + /** + * Launch an activity to show a class zero message + */ + public abstract void launchClassZeroActivity(Context context, ContentValues messageValues); + + /** + * Launch an activity to let the user forward a message + */ + public abstract void launchForwardMessageActivity(Context context, MessageData message); + + /** + * Launch an activity to show details for a VCard + */ + public abstract void launchVCardDetailActivity(Context context, Uri vcardUri); + + /** + * Launch an external activity that handles the intent to add VCard to contacts + */ + public abstract void launchSaveVCardToContactsActivity(Context context, Uri vcardUri); + + /** + * Launch an activity to let the user select & unselect the list of attachments to send. + */ + public abstract void launchAttachmentChooserActivity(final Activity activity, + final String conversationId, final int requestCode); + + /** + * Launch full screen video viewer. + */ + public abstract void launchFullScreenVideoViewer(Context context, Uri videoUri); + + /** + * Launch full screen photo viewer. + */ + public abstract void launchFullScreenPhotoViewer(Activity activity, Uri initialPhoto, + Rect initialPhotoBounds, Uri photosUri); + + /** + * Launch an activity to show general app settings + * @param topLevel indicates whether the app settings is launched as the top-level settings + * activity (instead of SettingsActivity which shows a collapsed view of the app + * settings + one settings item per subscription). This is true when there's only one + * active SIM in the system so we can show this activity directly. + */ + public abstract void launchApplicationSettingsActivity(Context context, boolean topLevel); + + /** + * Launch an activity to show per-subscription settings + */ + public abstract void launchPerSubscriptionSettingsActivity(Context context, int subId, + String settingTitle); + + /** + * Get a ACTION_VIEW intent + * @param url display the data in the url to users + */ + public abstract Intent getViewUrlIntent(final String url); + + /** + * Get an intent to launch the ringtone picker + * @param title the title to show in the ringtone picker + * @param existingUri the currently set uri + * @param defaultUri the default uri if none is currently set + * @param toneType type of ringtone to pick, maybe any of RingtoneManager.TYPE_* + */ + public abstract Intent getRingtonePickerIntent(final String title, final Uri existingUri, + final Uri defaultUri, final int toneType); + + /** + * Get an intent to launch the wireless alert viewer. + */ + public abstract Intent getWirelessAlertsIntent(); + + /** + * Get an intent to launch the dialog for changing the default SMS App. + */ + public abstract Intent getChangeDefaultSmsAppIntent(final Activity activity); + + /** + * Broadcast conversation self id change so it may be reflected in the message compose UI. + */ + public abstract void broadcastConversationSelfIdChange(final Context context, + final String conversationId, final String conversationSelfId); + + /** + * Get a PendingIntent for starting conversation list from notifications. + */ + public abstract PendingIntent getPendingIntentForConversationListActivity( + final Context context); + + /** + * Get a PendingIntent for starting conversation list from widget. + */ + public abstract PendingIntent getWidgetPendingIntentForConversationListActivity( + final Context context); + + /** + * Get a PendingIntent for showing a conversation from notifications. + */ + public abstract PendingIntent getPendingIntentForConversationActivity(final Context context, + final String conversationId, final MessageData draft); + + /** + * Get an Intent for showing a conversation from the widget. + */ + public abstract Intent getIntentForConversationActivity(final Context context, + final String conversationId, final MessageData draft); + + /** + * Get a PendingIntent for sending a message to a conversation, without opening the Bugle UI. + * + *

This is intended to be used by the Android Wear companion app when sending transcribed + * voice replies. + */ + public abstract PendingIntent getPendingIntentForSendingMessageToConversation( + final Context context, final String conversationId, final String selfId, + final boolean requiresMms, final int requestCode); + + /** + * Get a PendingIntent for clearing notifications. + * + *

This is intended to be used by notifications. + */ + public abstract PendingIntent getPendingIntentForClearingNotifications(final Context context, + final int updateTargets, final ConversationIdSet conversationIdSet, + final int requestCode); + + /** + * Get a PendingIntent for showing low storage notifications. + */ + public abstract PendingIntent getPendingIntentForLowStorageNotifications(final Context context); + + /** + * Get a PendingIntent for showing a new message to a secondary user. + */ + public abstract PendingIntent getPendingIntentForSecondaryUserNewMessageNotification( + final Context context); + + /** + * Get an intent for showing the APN editor. + */ + public abstract Intent getApnEditorIntent(final Context context, final String rowId, int subId); + + /** + * Get an intent for showing the APN settings. + */ + public abstract Intent getApnSettingsIntent(final Context context, final int subId); + + /** + * Get an intent for showing advanced settings. + */ + public abstract Intent getAdvancedSettingsIntent(final Context context); + + /** + * Get an intent for the LaunchConversationActivity. + */ + public abstract Intent getLaunchConversationActivityIntent(final Context context); + + /** + * Tell MediaScanner to re-scan the specified volume. + */ + public abstract void kickMediaScanner(final Context context, final String volume); + + /** + * Launch to browser for a url. + */ + public abstract void launchBrowserForUrl(final Context context, final String url); + + /** + * Get a PendingIntent for the widget conversation template. + */ + public abstract PendingIntent getWidgetPendingIntentForConversationActivity( + final Context context, final String conversationId, final int requestCode); + + /** + * Get a PendingIntent for the conversation widget configuration activity template. + */ + public abstract PendingIntent getWidgetPendingIntentForConfigurationActivity( + final Context context, final int appWidgetId); + +} diff --git a/src/com/android/messaging/ui/UIIntentsImpl.java b/src/com/android/messaging/ui/UIIntentsImpl.java new file mode 100644 index 0000000..b7db719 --- /dev/null +++ b/src/com/android/messaging/ui/UIIntentsImpl.java @@ -0,0 +1,577 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.app.Activity; +import android.app.Fragment; +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.content.ActivityNotFoundException; +import android.content.ClipData; +import android.content.ComponentName; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.graphics.Point; +import android.graphics.Rect; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.Bundle; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Intents; +import android.provider.MediaStore; +import android.provider.Telephony; +import android.support.annotation.Nullable; +import android.support.v4.app.TaskStackBuilder; +import android.support.v4.content.LocalBroadcastManager; +import android.text.TextUtils; + +import com.android.ex.photo.Intents.PhotoViewIntentBuilder; +import com.android.messaging.R; +import com.android.messaging.datamodel.ConversationImagePartsView; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.receiver.NotificationReceiver; +import com.android.messaging.sms.MmsSmsUtils; +import com.android.messaging.ui.appsettings.ApnEditorActivity; +import com.android.messaging.ui.appsettings.ApnSettingsActivity; +import com.android.messaging.ui.appsettings.ApplicationSettingsActivity; +import com.android.messaging.ui.appsettings.PerSubscriptionSettingsActivity; +import com.android.messaging.ui.appsettings.SettingsActivity; +import com.android.messaging.ui.attachmentchooser.AttachmentChooserActivity; +import com.android.messaging.ui.conversation.ConversationActivity; +import com.android.messaging.ui.conversation.LaunchConversationActivity; +import com.android.messaging.ui.conversationlist.ArchivedConversationListActivity; +import com.android.messaging.ui.conversationlist.ConversationListActivity; +import com.android.messaging.ui.conversationlist.ForwardMessageActivity; +import com.android.messaging.ui.conversationsettings.PeopleAndOptionsActivity; +import com.android.messaging.ui.debug.DebugMmsConfigActivity; +import com.android.messaging.ui.photoviewer.BuglePhotoViewActivity; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.ConversationIdSet; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.UiUtils; +import com.android.messaging.util.UriUtil; + +/** + * A central repository of Intents used to start activities. + */ +public class UIIntentsImpl extends UIIntents { + private static final String CELL_BROADCAST_LIST_ACTIVITY = + "com.android.cellbroadcastreceiver.CellBroadcastListActivity"; + private static final String CALL_TARGET_CLICK_KEY = "touchPoint"; + private static final String CALL_TARGET_CLICK_EXTRA_KEY = + "android.telecom.extra.OUTGOING_CALL_EXTRAS"; + private static final String MEDIA_SCANNER_CLASS = + "com.android.providers.media.MediaScannerService"; + private static final String MEDIA_SCANNER_PACKAGE = "com.android.providers.media"; + private static final String MEDIA_SCANNER_SCAN_ACTION = "android.media.IMediaScannerService"; + + /** + * Get an intent which takes you to a conversation + */ + private Intent getConversationActivityIntent(final Context context, + final String conversationId, final MessageData draft, + final boolean withCustomTransition) { + final Intent intent = new Intent(context, ConversationActivity.class); + + // Always try to reuse the same ConversationActivity in the current task so that we don't + // have two conversation activities in the back stack. + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + + // Otherwise we're starting a new conversation + if (conversationId != null) { + intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId); + } + if (draft != null) { + intent.putExtra(UI_INTENT_EXTRA_DRAFT_DATA, draft); + + // If draft attachments came from an external content provider via a share intent, we + // need to propagate the URI permissions through to ConversationActivity. This requires + // putting the URIs into the ClipData (setData also works, but accepts only one URI). + ClipData clipData = null; + for (final MessagePartData partData : draft.getParts()) { + if (partData.isAttachment()) { + final Uri uri = partData.getContentUri(); + if (clipData == null) { + clipData = ClipData.newRawUri("Attachments", uri); + } else { + clipData.addItem(new ClipData.Item(uri)); + } + } + } + if (clipData != null) { + intent.setClipData(clipData); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + } + if (withCustomTransition) { + intent.putExtra(UI_INTENT_EXTRA_WITH_CUSTOM_TRANSITION, true); + } + + if (!(context instanceof Activity)) { + // If the caller supplies an application context, and not an activity context, we must + // include this flag + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + } + return intent; + } + + @Override + public void launchPermissionCheckActivity(final Context context) { + final Intent intent = new Intent(context, PermissionCheckActivity.class); + context.startActivity(intent); + } + + /** + * Get an intent which takes you to the conversation list + */ + private Intent getConversationListActivityIntent(final Context context) { + return new Intent(context, ConversationListActivity.class); + } + + @Override + public void launchConversationListActivity(final Context context) { + final Intent intent = getConversationListActivityIntent(context); + context.startActivity(intent); + } + + /** + * Get an intent which shows the low storage warning activity. + */ + private Intent getSmsStorageLowWarningActivityIntent(final Context context) { + return new Intent(context, SmsStorageLowWarningActivity.class); + } + + @Override + public void launchConversationActivity(final Context context, + final String conversationId, final MessageData draft, final Bundle activityOptions, + final boolean withCustomTransition) { + Assert.isTrue(!withCustomTransition || activityOptions != null); + final Intent intent = getConversationActivityIntent(context, conversationId, draft, + withCustomTransition); + context.startActivity(intent, activityOptions); + } + + @Override + public void launchConversationActivityNewTask( + final Context context, final String conversationId) { + final Intent intent = getConversationActivityIntent(context, conversationId, null, + false /* withCustomTransition */); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } + + @Override + public void launchConversationActivityWithParentStack(final Context context, + final String conversationId, final String smsBody) { + final MessageData messageData = TextUtils.isEmpty(smsBody) + ? null + : MessageData.createDraftSmsMessage(conversationId, null, smsBody); + TaskStackBuilder.create(context) + .addNextIntentWithParentStack( + getConversationActivityIntent(context, conversationId, messageData, + false /* withCustomTransition */)) + .startActivities(); + } + + @Override + public void launchCreateNewConversationActivity(final Context context, + final MessageData draft) { + final Intent intent = getConversationActivityIntent(context, null, draft, + false /* withCustomTransition */); + context.startActivity(intent); + } + + @Override + public void launchDebugMmsConfigActivity(final Context context) { + context.startActivity(new Intent(context, DebugMmsConfigActivity.class)); + } + + @Override + public void launchAddContactActivity(final Context context, final String destination) { + final Intent intent = new Intent(Intent.ACTION_INSERT_OR_EDIT); + final String destinationType = MmsSmsUtils.isEmailAddress(destination) ? + Intents.Insert.EMAIL : Intents.Insert.PHONE; + intent.setType(Contacts.CONTENT_ITEM_TYPE); + intent.putExtra(destinationType, destination); + startExternalActivity(context, intent); + } + + @Override + public void launchSettingsActivity(final Context context) { + final Intent intent = new Intent(context, SettingsActivity.class); + context.startActivity(intent); + } + + @Override + public void launchArchivedConversationsActivity(final Context context) { + final Intent intent = new Intent(context, ArchivedConversationListActivity.class); + context.startActivity(intent); + } + + @Override + public void launchBlockedParticipantsActivity(final Context context) { + final Intent intent = new Intent(context, BlockedParticipantsActivity.class); + context.startActivity(intent); + } + + @Override + public void launchDocumentImagePicker(final Fragment fragment) { + final Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.putExtra(Intent.EXTRA_MIME_TYPES, MessagePartData.ACCEPTABLE_IMAGE_TYPES); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType(ContentType.IMAGE_UNSPECIFIED); + + fragment.startActivityForResult(intent, REQUEST_PICK_IMAGE_FROM_DOCUMENT_PICKER); + } + + @Override + public void launchPeopleAndOptionsActivity(final Activity activity, + final String conversationId) { + final Intent intent = new Intent(activity, PeopleAndOptionsActivity.class); + intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId); + activity.startActivityForResult(intent, 0); + } + + @Override + public void launchPhoneCallActivity(final Context context, final String phoneNumber, + final Point clickPosition) { + final Intent intent = new Intent(Intent.ACTION_CALL, + Uri.parse(UriUtil.SCHEME_TEL + phoneNumber)); + final Bundle extras = new Bundle(); + extras.putParcelable(CALL_TARGET_CLICK_KEY, clickPosition); + intent.putExtra(CALL_TARGET_CLICK_EXTRA_KEY, extras); + startExternalActivity(context, intent); + } + + @Override + public void launchClassZeroActivity(final Context context, final ContentValues messageValues) { + final Intent classZeroIntent = new Intent(context, ClassZeroActivity.class) + .putExtra(UI_INTENT_EXTRA_MESSAGE_VALUES, messageValues) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_MULTIPLE_TASK); + context.startActivity(classZeroIntent); + } + + @Override + public void launchForwardMessageActivity(final Context context, final MessageData message) { + final Intent forwardMessageIntent = new Intent(context, ForwardMessageActivity.class) + .putExtra(UI_INTENT_EXTRA_DRAFT_DATA, message); + context.startActivity(forwardMessageIntent); + } + + @Override + public void launchVCardDetailActivity(final Context context, final Uri vcardUri) { + final Intent vcardDetailIntent = new Intent(context, VCardDetailActivity.class) + .putExtra(UI_INTENT_EXTRA_VCARD_URI, vcardUri); + context.startActivity(vcardDetailIntent); + } + + @Override + public void launchSaveVCardToContactsActivity(final Context context, final Uri vcardUri) { + Assert.isTrue(MediaScratchFileProvider.isMediaScratchSpaceUri(vcardUri)); + final Intent intent = new Intent(); + intent.setAction(Intent.ACTION_VIEW); + intent.setDataAndType(vcardUri, ContentType.TEXT_VCARD.toLowerCase()); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startExternalActivity(context, intent); + } + + @Override + public void launchAttachmentChooserActivity(final Activity activity, + final String conversationId, final int requestCode) { + final Intent intent = new Intent(activity, AttachmentChooserActivity.class); + intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId); + activity.startActivityForResult(intent, requestCode); + } + + @Override + public void launchFullScreenVideoViewer(final Context context, final Uri videoUri) { + final Intent intent = new Intent(Intent.ACTION_VIEW); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + + // So we don't see "surrounding" images in Gallery + intent.putExtra("SingleItemOnly", true); + intent.setDataAndType(videoUri, ContentType.VIDEO_UNSPECIFIED); + startExternalActivity(context, intent); + } + + @Override + public void launchFullScreenPhotoViewer(final Activity activity, final Uri initialPhoto, + final Rect initialPhotoBounds, final Uri photosUri) { + final PhotoViewIntentBuilder builder = + com.android.ex.photo.Intents.newPhotoViewIntentBuilder( + activity, BuglePhotoViewActivity.class); + builder.setPhotosUri(photosUri.toString()); + builder.setInitialPhotoUri(initialPhoto.toString()); + builder.setProjection(ConversationImagePartsView.PhotoViewQuery.PROJECTION); + + // Set the location of the imageView so that the photoviewer can animate from that location + // to full screen. + builder.setScaleAnimation(initialPhotoBounds.left, initialPhotoBounds.top, + initialPhotoBounds.width(), initialPhotoBounds.height()); + + builder.setDisplayThumbsFullScreen(false); + builder.setMaxInitialScale(8); + activity.startActivity(builder.build()); + activity.overridePendingTransition(0, 0); + } + + @Override + public void launchApplicationSettingsActivity(final Context context, final boolean topLevel) { + final Intent intent = new Intent(context, ApplicationSettingsActivity.class); + intent.putExtra(UI_INTENT_EXTRA_TOP_LEVEL_SETTINGS, topLevel); + context.startActivity(intent); + } + + @Override + public void launchPerSubscriptionSettingsActivity(final Context context, final int subId, + final String settingTitle) { + final Intent intent = getPerSubscriptionSettingsIntent(context, subId, settingTitle); + context.startActivity(intent); + } + + @Override + public Intent getViewUrlIntent(final String url) { + final Uri uri = Uri.parse(url); + return new Intent(Intent.ACTION_VIEW, uri); + } + + @Override + public void broadcastConversationSelfIdChange(final Context context, + final String conversationId, final String conversationSelfId) { + final Intent intent = new Intent(CONVERSATION_SELF_ID_CHANGE_BROADCAST_ACTION); + intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId); + intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_SELF_ID, conversationSelfId); + LocalBroadcastManager.getInstance(context).sendBroadcast(intent); + } + + @Override + public PendingIntent getPendingIntentForConversationListActivity(final Context context) { + final Intent intent = getConversationListActivityIntent(context); + return getPendingIntentWithParentStack(context, intent, 0); + } + + @Override + public PendingIntent getPendingIntentForConversationActivity(final Context context, + final String conversationId, final MessageData draft) { + final Intent intent = getConversationActivityIntent(context, conversationId, draft, + false /* withCustomTransition */); + // Ensure that the platform doesn't reuse PendingIntents across conversations + intent.setData(MessagingContentProvider.buildConversationMetadataUri(conversationId)); + return getPendingIntentWithParentStack(context, intent, 0); + } + + @Override + public Intent getIntentForConversationActivity(final Context context, + final String conversationId, final MessageData draft) { + final Intent intent = getConversationActivityIntent(context, conversationId, draft, + false /* withCustomTransition */); + return intent; + } + + @Override + public PendingIntent getPendingIntentForSendingMessageToConversation(final Context context, + final String conversationId, final String selfId, final boolean requiresMms, + final int requestCode) { + final Intent intent = new Intent(context, RemoteInputEntrypointActivity.class); + intent.setAction(Intent.ACTION_SENDTO); + // Ensure that the platform doesn't reuse PendingIntents across conversations + intent.setData(MessagingContentProvider.buildConversationMetadataUri(conversationId)); + intent.putExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID, conversationId); + intent.putExtra(UIIntents.UI_INTENT_EXTRA_SELF_ID, selfId); + intent.putExtra(UIIntents.UI_INTENT_EXTRA_REQUIRES_MMS, requiresMms); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + return getPendingIntentWithParentStack(context, intent, requestCode); + } + + @Override + public PendingIntent getPendingIntentForClearingNotifications(final Context context, + final int updateTargets, final ConversationIdSet conversationIdSet, + final int requestCode) { + final Intent intent = new Intent(context, NotificationReceiver.class); + intent.setAction(ACTION_RESET_NOTIFICATIONS); + intent.putExtra(UI_INTENT_EXTRA_NOTIFICATIONS_UPDATE, updateTargets); + if (conversationIdSet != null) { + intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID_SET, + conversationIdSet.getDelimitedString()); + } + return PendingIntent.getBroadcast(context, + requestCode, intent, + PendingIntent.FLAG_UPDATE_CURRENT); + } + + /** + * Gets a PendingIntent associated with an Intent to start an Activity. All notifications + * that starts an Activity must use this method to get a PendingIntent, which achieves two + * goals: + * 1. The target activities will be created, with any existing ones destroyed. This ensures + * we don't end up with multiple instances of ConversationListActivity, for example. + * 2. The target activity, when launched, will have its backstack correctly constructed so + * back navigation will work correctly. + */ + private static PendingIntent getPendingIntentWithParentStack(final Context context, + final Intent intent, final int requestCode) { + final TaskStackBuilder stackBuilder = TaskStackBuilder.create(context); + // Adds the back stack for the Intent (plus the Intent itself) + stackBuilder.addNextIntentWithParentStack(intent); + final PendingIntent resultPendingIntent = + stackBuilder.getPendingIntent(requestCode, PendingIntent.FLAG_UPDATE_CURRENT); + return resultPendingIntent; + } + + @Override + public Intent getRingtonePickerIntent(final String title, final Uri existingUri, + final Uri defaultUri, final int toneType) { + return new Intent(RingtoneManager.ACTION_RINGTONE_PICKER) + .putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, toneType) + .putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, title) + .putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, existingUri) + .putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, defaultUri); + } + + @Override + public PendingIntent getPendingIntentForLowStorageNotifications(final Context context) { + final TaskStackBuilder taskStackBuilder = TaskStackBuilder.create(context); + final Intent conversationListIntent = getConversationListActivityIntent(context); + taskStackBuilder.addNextIntent(conversationListIntent); + taskStackBuilder.addNextIntentWithParentStack( + getSmsStorageLowWarningActivityIntent(context)); + + return taskStackBuilder.getPendingIntent( + 0, PendingIntent.FLAG_UPDATE_CURRENT); + } + + @Override + public PendingIntent getPendingIntentForSecondaryUserNewMessageNotification( + final Context context) { + return getPendingIntentForConversationListActivity(context); + } + + @Override + public Intent getWirelessAlertsIntent() { + final Intent intent = new Intent(Intent.ACTION_MAIN); + intent.setComponent(new ComponentName(CMAS_COMPONENT, CELL_BROADCAST_LIST_ACTIVITY)); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return intent; + } + + @Override + public Intent getApnEditorIntent(final Context context, final String rowId, final int subId) { + final Intent intent = new Intent(context, ApnEditorActivity.class); + intent.putExtra(UI_INTENT_EXTRA_APN_ROW_ID, rowId); + intent.putExtra(UI_INTENT_EXTRA_SUB_ID, subId); + return intent; + } + + @Override + public Intent getApnSettingsIntent(final Context context, final int subId) { + final Intent intent = new Intent(context, ApnSettingsActivity.class) + .putExtra(UI_INTENT_EXTRA_SUB_ID, subId); + return intent; + } + + @Override + public Intent getAdvancedSettingsIntent(final Context context) { + return getPerSubscriptionSettingsIntent(context, ParticipantData.DEFAULT_SELF_SUB_ID, null); + } + + @Override + public Intent getChangeDefaultSmsAppIntent(final Activity activity) { + final Intent intent = new Intent(Telephony.Sms.Intents.ACTION_CHANGE_DEFAULT); + intent.putExtra(Telephony.Sms.Intents.EXTRA_PACKAGE_NAME, activity.getPackageName()); + return intent; + } + + @Override + public void launchBrowserForUrl(final Context context, final String url) { + final Intent intent = getViewUrlIntent(url); + startExternalActivity(context, intent); + } + + /** + * Provides a safe way to handle external activities which may not exist. + */ + private void startExternalActivity(final Context context, final Intent intent) { + try { + context.startActivity(intent); + } catch (final ActivityNotFoundException ex) { + LogUtil.w(LogUtil.BUGLE_TAG, "Couldn't find activity:", ex); + UiUtils.showToastAtBottom(R.string.activity_not_found_message); + } + } + + private Intent getPerSubscriptionSettingsIntent(final Context context, final int subId, + @Nullable final String settingTitle) { + return new Intent(context, PerSubscriptionSettingsActivity.class) + .putExtra(UI_INTENT_EXTRA_SUB_ID, subId) + .putExtra(UI_INTENT_EXTRA_PER_SUBSCRIPTION_SETTING_TITLE, settingTitle); + } + + @Override + public Intent getLaunchConversationActivityIntent(final Context context) { + final Intent intent = new Intent(context, LaunchConversationActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NO_HISTORY); + return intent; + } + + @Override + public void kickMediaScanner(final Context context, final String volume) { + final Intent intent = new Intent(MEDIA_SCANNER_SCAN_ACTION) + .putExtra(MediaStore.MEDIA_SCANNER_VOLUME, volume) + .setClassName(MEDIA_SCANNER_PACKAGE, MEDIA_SCANNER_CLASS); + context.startService(intent); + } + + @Override + public PendingIntent getWidgetPendingIntentForConversationActivity(final Context context, + final String conversationId, final int requestCode) { + final Intent intent = getConversationActivityIntent(context, null, null, + false /* withCustomTransition */); + if (conversationId != null) { + intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId); + + // Set the action to something unique to this conversation so if someone calls this + // function again on a different conversation, they'll get a new PendingIntent instead + // of the old one. + intent.setAction(ACTION_WIDGET_CONVERSATION + conversationId); + } + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return getPendingIntentWithParentStack(context, intent, requestCode); + } + + @Override + public PendingIntent getWidgetPendingIntentForConversationListActivity( + final Context context) { + final Intent intent = getConversationListActivityIntent(context); + return getPendingIntentWithParentStack(context, intent, 0); + } + + @Override + public PendingIntent getWidgetPendingIntentForConfigurationActivity(final Context context, + final int appWidgetId) { + final Intent configureIntent = new Intent(context, WidgetPickConversationActivity.class); + configureIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); + configureIntent.setAction(AppWidgetManager.ACTION_APPWIDGET_CONFIGURE); + configureIntent.setData(Uri.parse(configureIntent.toUri(Intent.URI_INTENT_SCHEME))); + configureIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NO_HISTORY); + return getPendingIntentWithParentStack(context, configureIntent, 0); + } +} diff --git a/src/com/android/messaging/ui/VCardDetailActivity.java b/src/com/android/messaging/ui/VCardDetailActivity.java new file mode 100644 index 0000000..fecdc34 --- /dev/null +++ b/src/com/android/messaging/ui/VCardDetailActivity.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.app.Fragment; +import android.net.Uri; +import android.os.Bundle; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; + +/** + * An activity that hosts VCardDetailFragment that shows the content of a VCard that contains one + * or more contacts. + */ +public class VCardDetailActivity extends BugleActionBarActivity { + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.vcard_detail_activity); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + @Override + public void onAttachFragment(final Fragment fragment) { + Assert.isTrue(fragment instanceof VCardDetailFragment); + final Uri vCardUri = getIntent().getParcelableExtra(UIIntents.UI_INTENT_EXTRA_VCARD_URI); + Assert.notNull(vCardUri); + final VCardDetailFragment vCardDetailFragment = (VCardDetailFragment) fragment; + vCardDetailFragment.setVCardUri(vCardUri); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + // Treat the home press as back press so that when we go back to + // ConversationActivity, it doesn't lose its original intent (conversation id etc.) + onBackPressed(); + return true; + + default: + return super.onOptionsItemSelected(item); + } + } +} diff --git a/src/com/android/messaging/ui/VCardDetailAdapter.java b/src/com/android/messaging/ui/VCardDetailAdapter.java new file mode 100644 index 0000000..cfdd836 --- /dev/null +++ b/src/com/android/messaging/ui/VCardDetailAdapter.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseExpandableListAdapter; + +import com.android.messaging.R; +import com.android.messaging.datamodel.media.VCardResourceEntry; +import com.android.messaging.datamodel.media.VCardResourceEntry.VCardResourceEntryDestinationItem; + +import java.util.List; + +/** + * Displays a list of expandable contact cards shown in the VCardDetailActivity. + */ +public class VCardDetailAdapter extends BaseExpandableListAdapter { + private final List mVCards; + private final LayoutInflater mInflater; + + public VCardDetailAdapter(final Context context, final List vCards) { + mVCards = vCards; + mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + } + + @Override + public Object getChild(final int groupPosition, final int childPosition) { + return mVCards.get(groupPosition).getContactInfo().get(childPosition); + } + + @Override + public long getChildId(final int groupPosition, final int childPosition) { + return childPosition; + } + + @Override + public View getChildView(final int groupPosition, final int childPosition, + final boolean isLastChild, final View convertView, final ViewGroup parent) { + PersonItemView v; + if (convertView == null) { + v = instantiateView(parent); + } else { + v = (PersonItemView) convertView; + } + + final VCardResourceEntryDestinationItem item = (VCardResourceEntryDestinationItem) + getChild(groupPosition, childPosition); + + v.bind(item.getDisplayItem()); + return v; + } + + @Override + public int getChildrenCount(final int groupPosition) { + return mVCards.get(groupPosition).getContactInfo().size(); + } + + @Override + public Object getGroup(final int groupPosition) { + return mVCards.get(groupPosition); + } + + @Override + public int getGroupCount() { + return mVCards.size(); + } + + @Override + public long getGroupId(final int groupPosition) { + return groupPosition; + } + + @Override + public View getGroupView(final int groupPosition, final boolean isExpanded, + final View convertView, final ViewGroup parent) { + PersonItemView v; + if (convertView == null) { + v = instantiateView(parent); + } else { + v = (PersonItemView) convertView; + } + + final VCardResourceEntry item = (VCardResourceEntry) getGroup(groupPosition); + v.bind(item.getDisplayItem()); + return v; + } + + @Override + public boolean isChildSelectable(final int groupPosition, final int childPosition) { + return true; + } + + @Override + public boolean hasStableIds() { + return true; + } + + private PersonItemView instantiateView(final ViewGroup parent) { + final PersonItemView v = (PersonItemView) mInflater.inflate(R.layout.people_list_item_view, + parent, false); + v.setClickable(false); + return v; + } +} diff --git a/src/com/android/messaging/ui/VCardDetailFragment.java b/src/com/android/messaging/ui/VCardDetailFragment.java new file mode 100644 index 0000000..1b2b88d --- /dev/null +++ b/src/com/android/messaging/ui/VCardDetailFragment.java @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.app.Fragment; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnLayoutChangeListener; +import android.view.ViewGroup; +import android.widget.ExpandableListAdapter; +import android.widget.ExpandableListView; +import android.widget.ExpandableListView.OnChildClickListener; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.PersonItemData; +import com.android.messaging.datamodel.data.VCardContactItemData; +import com.android.messaging.datamodel.data.PersonItemData.PersonItemDataListener; +import com.android.messaging.util.Assert; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.UiUtils; +import com.android.messaging.util.UriUtil; + +/** + * A fragment that shows the content of a VCard that contains one or more contacts. + */ +public class VCardDetailFragment extends Fragment implements PersonItemDataListener { + private final Binding mBinding = + BindingBase.createBinding(this); + private ExpandableListView mListView; + private VCardDetailAdapter mAdapter; + private Uri mVCardUri; + + /** + * We need to persist the VCard in the scratch directory before letting the user view it. + * We save this Uri locally, so that if the user cancels the action and re-perform the add + * to contacts action we don't have to persist it again. + */ + private Uri mScratchSpaceUri; + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + Assert.notNull(mVCardUri); + final View view = inflater.inflate(R.layout.vcard_detail_fragment, container, false); + mListView = (ExpandableListView) view.findViewById(R.id.list); + mListView.addOnLayoutChangeListener(new OnLayoutChangeListener() { + @Override + public void onLayoutChange(final View v, final int left, final int top, final int right, + final int bottom, final int oldLeft, final int oldTop, final int oldRight, + final int oldBottom) { + mListView.setIndicatorBounds(mListView.getWidth() - getResources() + .getDimensionPixelSize(R.dimen.vcard_detail_group_indicator_width), + mListView.getWidth()); + } + }); + mListView.setOnChildClickListener(new OnChildClickListener() { + @Override + public boolean onChildClick(ExpandableListView expandableListView, View clickedView, + int groupPosition, int childPosition, long childId) { + if (!(clickedView instanceof PersonItemView)) { + return false; + } + final Intent intent = ((PersonItemView) clickedView).getClickIntent(); + if (intent != null) { + try { + startActivity(intent); + } catch (ActivityNotFoundException e) { + return false; + } + return true; + } + return false; + } + }); + mBinding.bind(DataModel.get().createVCardContactItemData(getActivity(), mVCardUri)); + mBinding.getData().setListener(this); + return view; + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (mBinding.isBound()) { + mBinding.unbind(); + } + mListView.setAdapter((ExpandableListAdapter) null); + } + + private boolean shouldShowAddToContactsItem() { + return mBinding.isBound() && mBinding.getData().hasValidVCard(); + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + inflater.inflate(R.menu.vcard_detail_fragment_menu, menu); + final MenuItem addToContactsItem = menu.findItem(R.id.action_add_contact); + addToContactsItem.setVisible(shouldShowAddToContactsItem()); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case R.id.action_add_contact: + mBinding.ensureBound(); + final Uri vCardUri = mBinding.getData().getVCardUri(); + + // We have to do things in the background in case we need to copy the vcard data. + new SafeAsyncTask() { + @Override + protected Uri doInBackgroundTimed(final Void... params) { + // We can't delete the persisted vCard file because we don't know when to + // delete it, since the app that uses it (contacts, dialer) may start or + // shut down at any point. Therefore, we rely on the system to clean up + // the cache directory for us. + return mScratchSpaceUri != null ? mScratchSpaceUri : + UriUtil.persistContentToScratchSpace(vCardUri); + } + + @Override + protected void onPostExecute(final Uri result) { + if (result != null) { + mScratchSpaceUri = result; + if (getActivity() != null) { + MediaScratchFileProvider.addUriToDisplayNameEntry( + result, mBinding.getData().getDisplayName()); + UIIntents.get().launchSaveVCardToContactsActivity(getActivity(), + result); + } + } + } + }.executeOnThreadPool(); + return true; + + default: + return super.onOptionsItemSelected(item); + } + } + + public void setVCardUri(final Uri vCardUri) { + Assert.isTrue(!mBinding.isBound()); + mVCardUri = vCardUri; + } + + @Override + public void onPersonDataUpdated(final PersonItemData data) { + Assert.isTrue(data instanceof VCardContactItemData); + mBinding.ensureBound(); + final VCardContactItemData vCardData = (VCardContactItemData) data; + Assert.isTrue(vCardData.hasValidVCard()); + mAdapter = new VCardDetailAdapter(getActivity(), vCardData.getVCardResource().getVCards()); + mListView.setAdapter(mAdapter); + + // Expand the contact card if there's only one contact. + if (mAdapter.getGroupCount() == 1) { + mListView.expandGroup(0); + } + getActivity().invalidateOptionsMenu(); + } + + @Override + public void onPersonDataFailed(final PersonItemData data, final Exception exception) { + mBinding.ensureBound(); + UiUtils.showToastAtBottom(R.string.failed_loading_vcard); + getActivity().finish(); + } +} diff --git a/src/com/android/messaging/ui/VideoThumbnailView.java b/src/com/android/messaging/ui/VideoThumbnailView.java new file mode 100644 index 0000000..966336e --- /dev/null +++ b/src/com/android/messaging/ui/VideoThumbnailView.java @@ -0,0 +1,343 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.content.Context; +import android.content.res.TypedArray; +import android.media.MediaPlayer; +import android.net.Uri; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.ImageView.ScaleType; +import android.widget.VideoView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.media.ImageRequest; +import com.android.messaging.datamodel.media.MessagePartVideoThumbnailRequestDescriptor; +import com.android.messaging.datamodel.media.VideoThumbnailRequest; +import com.android.messaging.util.Assert; + +/** + * View that encapsulates a video preview (either as a thumbnail image, or video player), and the + * a play button to overlay it. Ensures that the video preview maintains the aspect ratio of the + * original video while trying to respect minimum width/height and constraining to the available + * bounds + */ +public class VideoThumbnailView extends FrameLayout { + /** + * When in this mode the VideoThumbnailView is a lightweight AsyncImageView with an ImageButton + * to play the video. Clicking play will launch a full screen player + */ + private static final int MODE_IMAGE_THUMBNAIL = 0; + + /** + * When in this mode the VideoThumbnailVideo will include a VideoView, and the play button will + * play the video inline. When in this mode, the loop and playOnLoad attributes can be applied + * to auto-play or loop the video. + */ + private static final int MODE_PLAYABLE_VIDEO = 1; + + private final int mMode; + private final boolean mPlayOnLoad; + private final boolean mAllowCrop; + private final VideoView mVideoView; + private final ImageButton mPlayButton; + private final AsyncImageView mThumbnailImage; + private int mVideoWidth; + private int mVideoHeight; + private Uri mVideoSource; + private boolean mAnimating; + private boolean mVideoLoaded; + + public VideoThumbnailView(final Context context, final AttributeSet attrs) { + super(context, attrs); + final TypedArray typedAttributes = + context.obtainStyledAttributes(attrs, R.styleable.VideoThumbnailView); + + final LayoutInflater inflater = LayoutInflater.from(context); + inflater.inflate(R.layout.video_thumbnail_view, this, true); + + mPlayOnLoad = typedAttributes.getBoolean(R.styleable.VideoThumbnailView_playOnLoad, false); + final boolean loop = + typedAttributes.getBoolean(R.styleable.VideoThumbnailView_loop, false); + mMode = typedAttributes.getInt(R.styleable.VideoThumbnailView_mode, MODE_IMAGE_THUMBNAIL); + mAllowCrop = typedAttributes.getBoolean(R.styleable.VideoThumbnailView_allowCrop, false); + + mVideoWidth = ImageRequest.UNSPECIFIED_SIZE; + mVideoHeight = ImageRequest.UNSPECIFIED_SIZE; + + if (mMode == MODE_PLAYABLE_VIDEO) { + mVideoView = new VideoView(context); + // Video view tries to request focus on start which pulls focus from the user's intended + // focus when we add this control. Remove focusability to prevent this. The play + // button can still be focused + mVideoView.setFocusable(false); + mVideoView.setFocusableInTouchMode(false); + mVideoView.clearFocus(); + addView(mVideoView, 0, new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + mVideoView.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { + @Override + public void onPrepared(final MediaPlayer mediaPlayer) { + mVideoLoaded = true; + mVideoWidth = mediaPlayer.getVideoWidth(); + mVideoHeight = mediaPlayer.getVideoHeight(); + mediaPlayer.setLooping(loop); + trySwitchToVideo(); + } + }); + mVideoView.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(final MediaPlayer mediaPlayer) { + mPlayButton.setVisibility(View.VISIBLE); + } + }); + mVideoView.setOnErrorListener(new MediaPlayer.OnErrorListener() { + @Override + public boolean onError(final MediaPlayer mediaPlayer, final int i, final int i2) { + return true; + } + }); + } else { + mVideoView = null; + } + + mPlayButton = (ImageButton) findViewById(R.id.video_thumbnail_play_button); + if (loop) { + mPlayButton.setVisibility(View.GONE); + } else { + mPlayButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View view) { + if (mVideoSource == null) { + return; + } + + if (mMode == MODE_PLAYABLE_VIDEO) { + mVideoView.seekTo(0); + start(); + } else { + UIIntents.get().launchFullScreenVideoViewer(getContext(), mVideoSource); + } + } + }); + mPlayButton.setOnLongClickListener(new OnLongClickListener() { + @Override + public boolean onLongClick(final View view) { + // Button prevents long click from propagating up, do it manually + VideoThumbnailView.this.performLongClick(); + return true; + } + }); + } + + mThumbnailImage = (AsyncImageView) findViewById(R.id.video_thumbnail_image); + if (mAllowCrop) { + mThumbnailImage.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; + mThumbnailImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; + mThumbnailImage.setScaleType(ScaleType.CENTER_CROP); + } else { + // This is the default setting in the layout, so No-op. + } + final int maxHeight = typedAttributes.getDimensionPixelSize( + R.styleable.VideoThumbnailView_android_maxHeight, ImageRequest.UNSPECIFIED_SIZE); + if (maxHeight != ImageRequest.UNSPECIFIED_SIZE) { + mThumbnailImage.setMaxHeight(maxHeight); + mThumbnailImage.setAdjustViewBounds(true); + } + + typedAttributes.recycle(); + } + + @Override + protected void onAnimationStart() { + super.onAnimationStart(); + mAnimating = true; + } + + @Override + protected void onAnimationEnd() { + super.onAnimationEnd(); + mAnimating = false; + trySwitchToVideo(); + } + + private void trySwitchToVideo() { + if (mAnimating) { + // Don't start video or hide image until after animation completes + return; + } + + if (!mVideoLoaded) { + // Video hasn't loaded, nothing more to do + return; + } + + if (mPlayOnLoad) { + start(); + } else { + mVideoView.seekTo(0); + } + } + + private boolean hasVideoSize() { + return mVideoWidth != ImageRequest.UNSPECIFIED_SIZE && + mVideoHeight != ImageRequest.UNSPECIFIED_SIZE; + } + + public void start() { + Assert.equals(MODE_PLAYABLE_VIDEO, mMode); + mPlayButton.setVisibility(View.GONE); + mThumbnailImage.setVisibility(View.GONE); + mVideoView.start(); + } + + // TODO: The check could be added to MessagePartData itself so that all users of MessagePartData + // get the right behavior, instead of requiring all the users to do similar checks. + private static boolean shouldUseGenericVideoIcon(final boolean incomingMessage) { + return incomingMessage && !VideoThumbnailRequest.shouldShowIncomingVideoThumbnails(); + } + + public void setSource(final MessagePartData part, final boolean incomingMessage) { + if (part == null) { + clearSource(); + } else { + mVideoSource = part.getContentUri(); + if (shouldUseGenericVideoIcon(incomingMessage)) { + mThumbnailImage.setImageResource(R.drawable.generic_video_icon); + mVideoWidth = ImageRequest.UNSPECIFIED_SIZE; + mVideoHeight = ImageRequest.UNSPECIFIED_SIZE; + } else { + mThumbnailImage.setImageResourceId( + new MessagePartVideoThumbnailRequestDescriptor(part)); + if (mVideoView != null) { + mVideoView.setVideoURI(mVideoSource); + } + mVideoWidth = part.getWidth(); + mVideoHeight = part.getHeight(); + } + } + } + + public void setSource(final Uri videoSource, final boolean incomingMessage) { + if (videoSource == null) { + clearSource(); + } else { + mVideoSource = videoSource; + if (shouldUseGenericVideoIcon(incomingMessage)) { + mThumbnailImage.setImageResource(R.drawable.generic_video_icon); + mVideoWidth = ImageRequest.UNSPECIFIED_SIZE; + mVideoHeight = ImageRequest.UNSPECIFIED_SIZE; + } else { + mThumbnailImage.setImageResourceId( + new MessagePartVideoThumbnailRequestDescriptor(videoSource)); + if (mVideoView != null) { + mVideoView.setVideoURI(videoSource); + } + } + } + } + + private void clearSource() { + mVideoSource = null; + mThumbnailImage.setImageResourceId(null); + mVideoWidth = ImageRequest.UNSPECIFIED_SIZE; + mVideoHeight = ImageRequest.UNSPECIFIED_SIZE; + if (mVideoView != null) { + mVideoView.setVideoURI(null); + } + } + + @Override + public void setMinimumWidth(final int minWidth) { + super.setMinimumWidth(minWidth); + if (mVideoView != null) { + mVideoView.setMinimumWidth(minWidth); + } + } + + @Override + public void setMinimumHeight(final int minHeight) { + super.setMinimumHeight(minHeight); + if (mVideoView != null) { + mVideoView.setMinimumHeight(minHeight); + } + } + + public void setColorFilter(int color) { + mThumbnailImage.setColorFilter(color); + mPlayButton.setColorFilter(color); + } + + public void clearColorFilter() { + mThumbnailImage.clearColorFilter(); + mPlayButton.clearColorFilter(); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + if (mAllowCrop) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + return; + } + int desiredWidth = 1; + int desiredHeight = 1; + if (mVideoView != null) { + mVideoView.measure(widthMeasureSpec, heightMeasureSpec); + } + mThumbnailImage.measure(widthMeasureSpec, heightMeasureSpec); + if (hasVideoSize()) { + desiredWidth = mVideoWidth; + desiredHeight = mVideoHeight; + } else { + desiredWidth = mThumbnailImage.getMeasuredWidth(); + desiredHeight = mThumbnailImage.getMeasuredHeight(); + } + + final int minimumWidth = getMinimumWidth(); + final int minimumHeight = getMinimumHeight(); + + // Constrain the scale to fit within the supplied size + final float maxScale = Math.max( + MeasureSpec.getSize(widthMeasureSpec) / (float) desiredWidth, + MeasureSpec.getSize(heightMeasureSpec) / (float) desiredHeight); + + // Scale up to reach minimum width/height + final float widthScale = Math.max(1, minimumWidth / (float) desiredWidth); + final float heightScale = Math.max(1, minimumHeight / (float) desiredHeight); + final float scale = Math.min(maxScale, Math.max(widthScale, heightScale)); + desiredWidth = (int) (desiredWidth * scale); + desiredHeight = (int) (desiredHeight * scale); + + setMeasuredDimension(desiredWidth, desiredHeight); + } + + @Override + protected void onLayout(final boolean changed, final int left, final int top, final int right, + final int bottom) { + final int count = getChildCount(); + for (int i = 0; i < count; i++) { + final View child = getChildAt(i); + child.layout(0, 0, right - left, bottom - top); + } + } +} diff --git a/src/com/android/messaging/ui/ViewPagerTabStrip.java b/src/com/android/messaging/ui/ViewPagerTabStrip.java new file mode 100644 index 0000000..e088296 --- /dev/null +++ b/src/com/android/messaging/ui/ViewPagerTabStrip.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; + +import com.android.messaging.R; +import com.android.messaging.util.OsUtil; + +public class ViewPagerTabStrip extends LinearLayout { + private int mSelectedUnderlineThickness; + private final Paint mSelectedUnderlinePaint; + + private int mIndexForSelection; + private float mSelectionOffset; + + public ViewPagerTabStrip(Context context) { + this(context, null); + } + + public ViewPagerTabStrip(Context context, AttributeSet attrs) { + super(context, attrs); + + final Resources res = context.getResources(); + + mSelectedUnderlineThickness = + res.getDimensionPixelSize(R.dimen.pager_tab_underline_selected); + int underlineColor = res.getColor(R.color.contact_picker_tab_underline); + int backgroundColor = res.getColor(R.color.action_bar_background_color); + + mSelectedUnderlinePaint = new Paint(); + mSelectedUnderlinePaint.setColor(underlineColor); + + setBackgroundColor(backgroundColor); + setWillNotDraw(false); + } + + /** + * Notifies this view that view pager has been scrolled. We save the tab index + * and selection offset for interpolating the position and width of selection + * underline. + */ + void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + mIndexForSelection = position; + mSelectionOffset = positionOffset; + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + int childCount = getChildCount(); + + // Thick colored underline below the current selection + if (childCount > 0) { + View selectedTitle = getChildAt(mIndexForSelection); + int selectedLeft = selectedTitle.getLeft(); + int selectedRight = selectedTitle.getRight(); + final boolean isRtl = isRtl(); + final boolean hasNextTab = isRtl ? mIndexForSelection > 0 + : (mIndexForSelection < (getChildCount() - 1)); + if ((mSelectionOffset > 0.0f) && hasNextTab) { + // Draw the selection partway between the tabs + View nextTitle = getChildAt(mIndexForSelection + (isRtl ? -1 : 1)); + int nextLeft = nextTitle.getLeft(); + int nextRight = nextTitle.getRight(); + + selectedLeft = (int) (mSelectionOffset * nextLeft + + (1.0f - mSelectionOffset) * selectedLeft); + selectedRight = (int) (mSelectionOffset * nextRight + + (1.0f - mSelectionOffset) * selectedRight); + } + + int height = getHeight(); + canvas.drawRect(selectedLeft, height - mSelectedUnderlineThickness, + selectedRight, height, mSelectedUnderlinePaint); + } + } + + private boolean isRtl() { + return OsUtil.isAtLeastJB_MR2() ? getLayoutDirection() == View.LAYOUT_DIRECTION_RTL : false; + } +} \ No newline at end of file diff --git a/src/com/android/messaging/ui/ViewPagerTabs.java b/src/com/android/messaging/ui/ViewPagerTabs.java new file mode 100644 index 0000000..f31a071 --- /dev/null +++ b/src/com/android/messaging/ui/ViewPagerTabs.java @@ -0,0 +1,236 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.Outline; +import android.graphics.drawable.ColorDrawable; +import android.support.v4.view.PagerAdapter; +import android.support.v4.view.ViewPager; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import android.view.ViewOutlineProvider; +import android.widget.FrameLayout; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.util.OsUtil; + +/** + * Lightweight implementation of ViewPager tabs. This looks similar to traditional actionBar tabs, + * but allows for the view containing the tabs to be placed anywhere on screen. Text-related + * attributes can also be assigned in XML - these will get propogated to the child TextViews + * automatically. + * + * Note: this file is taken from the AOSP /packages/apps/ContactsCommon/src/com/android/contacts/ + * common/list/ViewPagerTabs.java. Some platform specific API calls (e.g. ViewOutlineProvider which + * assumes L and above) have been modified to support down to Api Level 16. + */ +public class ViewPagerTabs extends HorizontalScrollView implements ViewPager.OnPageChangeListener { + + ViewPager mPager; + private ViewPagerTabStrip mTabStrip; + + /** + * Linearlayout that will contain the TextViews serving as tabs. This is the only child + * of the parent HorizontalScrollView. + */ + final int mTextStyle; + final ColorStateList mTextColor; + final int mTextSize; + final boolean mTextAllCaps; + int mPrevSelected = -1; + int mSidePadding; + + private static final int TAB_SIDE_PADDING_IN_DPS = 10; + + // TODO: This should use in the future + private static final int[] ATTRS = new int[] { + android.R.attr.textSize, + android.R.attr.textStyle, + android.R.attr.textColor, + android.R.attr.textAllCaps + }; + + /** + * Shows a toast with the tab description when long-clicked. + */ + private class OnTabLongClickListener implements OnLongClickListener { + final String mTabDescription; + + public OnTabLongClickListener(String tabDescription) { + mTabDescription = tabDescription; + } + + @Override + public boolean onLongClick(View v) { + final int[] screenPos = new int[2]; + getLocationOnScreen(screenPos); + + final Context context = getContext(); + final int width = getWidth(); + final int height = getHeight(); + final int screenWidth = context.getResources().getDisplayMetrics().widthPixels; + + Toast toast = Toast.makeText(context, mTabDescription, Toast.LENGTH_SHORT); + + // Show the toast under the tab + toast.setGravity(Gravity.TOP | Gravity.CENTER_HORIZONTAL, + (screenPos[0] + width / 2) - screenWidth / 2, screenPos[1] + height); + + toast.show(); + return true; + } + } + + public ViewPagerTabs(Context context) { + this(context, null); + } + + public ViewPagerTabs(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ViewPagerTabs(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + setFillViewport(true); + + mSidePadding = (int) (getResources().getDisplayMetrics().density * TAB_SIDE_PADDING_IN_DPS); + + final TypedArray a = context.obtainStyledAttributes(attrs, ATTRS); + mTextSize = a.getDimensionPixelSize(0, 0); + mTextStyle = a.getInt(1, 0); + mTextColor = a.getColorStateList(2); + mTextAllCaps = a.getBoolean(3, false); + + mTabStrip = new ViewPagerTabStrip(context); + addView(mTabStrip, + new FrameLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT)); + a.recycle(); + + // enable shadow casting from view bounds + if (OsUtil.isAtLeastL()) { + setOutlineProvider(new ViewOutlineProvider() { + @Override + public void getOutline(View view, Outline outline) { + outline.setRect(0, 0, view.getWidth(), view.getHeight()); + } + }); + } + } + + public void setViewPager(ViewPager viewPager) { + mPager = viewPager; + addTabs(mPager.getAdapter()); + } + + private void addTabs(PagerAdapter adapter) { + mTabStrip.removeAllViews(); + + final int count = adapter.getCount(); + for (int i = 0; i < count; i++) { + addTab(adapter.getPageTitle(i), i); + } + } + + private void addTab(CharSequence tabTitle, final int position) { + final TextView textView = new TextView(getContext()); + textView.setText(tabTitle); + textView.setBackgroundResource(R.drawable.contact_picker_tab_background_selector); + textView.setGravity(Gravity.CENTER); + textView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + mPager.setCurrentItem(getRtlPosition(position)); + } + }); + + // Assign various text appearance related attributes to child views. + if (mTextStyle > 0) { + textView.setTypeface(textView.getTypeface(), mTextStyle); + } + if (mTextSize > 0) { + textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize); + } + if (mTextColor != null) { + textView.setTextColor(mTextColor); + } + textView.setAllCaps(mTextAllCaps); + textView.setPadding(mSidePadding, 0, mSidePadding, 0); + mTabStrip.addView(textView, new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, + LayoutParams.MATCH_PARENT, 1)); + // Default to the first child being selected + if (position == 0) { + mPrevSelected = 0; + textView.setSelected(true); + } + } + + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + position = getRtlPosition(position); + int tabStripChildCount = mTabStrip.getChildCount(); + if ((tabStripChildCount == 0) || (position < 0) || (position >= tabStripChildCount)) { + return; + } + + mTabStrip.onPageScrolled(position, positionOffset, positionOffsetPixels); + } + + @Override + public void onPageSelected(int position) { + position = getRtlPosition(position); + int tabStripChildCount = mTabStrip.getChildCount(); + if ((tabStripChildCount == 0) || (position < 0) || (position >= tabStripChildCount)) { + return; + } + + if (mPrevSelected >= 0 && mPrevSelected < tabStripChildCount) { + mTabStrip.getChildAt(mPrevSelected).setSelected(false); + } + final View selectedChild = mTabStrip.getChildAt(position); + selectedChild.setSelected(true); + + // Update scroll position + final int scrollPos = selectedChild.getLeft() - (getWidth() - selectedChild.getWidth()) / 2; + smoothScrollTo(scrollPos, 0); + mPrevSelected = position; + } + + @Override + public void onPageScrollStateChanged(int state) { + } + + private int getRtlPosition(int position) { + if (OsUtil.isAtLeastJB_MR2() && Factory.get().getApplicationContext().getResources() + .getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) { + return mTabStrip.getChildCount() - 1 - position; + } + return position; + } + + public int getSelectedItemPosition() { + return mPrevSelected; + } +} diff --git a/src/com/android/messaging/ui/WidgetPickConversationActivity.java b/src/com/android/messaging/ui/WidgetPickConversationActivity.java new file mode 100644 index 0000000..60e1318 --- /dev/null +++ b/src/com/android/messaging/ui/WidgetPickConversationActivity.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.app.Fragment; +import android.appwidget.AppWidgetManager; +import android.content.Intent; +import android.os.Bundle; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.ui.conversationlist.ShareIntentFragment; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.widget.WidgetConversationProvider; + +public class WidgetPickConversationActivity extends BaseBugleActivity implements + ShareIntentFragment.HostInterface { + + private int mAppWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + + // Set the result to CANCELED. This will cause the widget host to cancel + // out of the widget placement if they press the back button. + setResult(RESULT_CANCELED); + + // Find the widget id from the intent. + final Intent intent = getIntent(); + final Bundle extras = intent.getExtras(); + if (extras != null) { + mAppWidgetId = extras.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); + } + + // If they gave us an intent without the widget id, just bail. + if (mAppWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish(); + } + + final ShareIntentFragment convPicker = new ShareIntentFragment(); + final Bundle bundle = new Bundle(); + bundle.putBoolean(ShareIntentFragment.HIDE_NEW_CONVERSATION_BUTTON_KEY, true); + convPicker.setArguments(bundle); + convPicker.show(getFragmentManager(), "ShareIntentFragment"); + } + + @Override + public void onAttachFragment(final Fragment fragment) { + final Intent intent = getIntent(); + final String action = intent.getAction(); + if (!AppWidgetManager.ACTION_APPWIDGET_CONFIGURE.equals(action)) { + // Unsupported action. + Assert.fail("Unsupported action type: " + action); + } + } + + @Override + public void onConversationClick(final ConversationListItemData conversationListItemData) { + saveConversationidPref(mAppWidgetId, conversationListItemData.getConversationId()); + + // Push widget update to surface with newly set prefix + WidgetConversationProvider.rebuildWidget(this, mAppWidgetId); + + // Make sure we pass back the original appWidgetId + Intent resultValue = new Intent(); + resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId); + setResult(RESULT_OK, resultValue); + finish(); + } + + @Override + public void onCreateConversationClick() { + // We should never get here because we're hiding the new conversation button in the + // ShareIntentFragment by setting HIDE_NEW_CONVERSATION_BUTTON_KEY in the arguments. + finish(); + } + + // Write the ConversationId to the SharedPreferences object for this widget + static void saveConversationidPref(int appWidgetId, String conversationId) { + final BuglePrefs prefs = Factory.get().getWidgetPrefs(); + prefs.putString(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID + appWidgetId, conversationId); + } + + // Read the ConversationId from the SharedPreferences object for this widget. + public static String getConversationIdPref(int appWidgetId) { + final BuglePrefs prefs = Factory.get().getWidgetPrefs(); + return prefs.getString(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID + appWidgetId, null); + } + + // Delete the ConversationId preference from the SharedPreferences object for this widget. + public static void deleteConversationIdPref(int appWidgetId) { + final BuglePrefs prefs = Factory.get().getWidgetPrefs(); + prefs.remove(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID + appWidgetId); + } + +} diff --git a/src/com/android/messaging/ui/animation/PopupTransitionAnimation.java b/src/com/android/messaging/ui/animation/PopupTransitionAnimation.java new file mode 100644 index 0000000..21529c6 --- /dev/null +++ b/src/com/android/messaging/ui/animation/PopupTransitionAnimation.java @@ -0,0 +1,302 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.animation; + +import android.animation.TypeEvaluator; +import android.app.Activity; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Rect; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.Transformation; +import android.widget.PopupWindow; + +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.ThreadUtil; +import com.android.messaging.util.UiUtils; + +/** + * Animates viewToAnimate from startRect to the place where it is in the layout, viewToAnimate + * should be in its final destination location before startAfterLayoutComplete is called. + * viewToAnimate will be drawn scaled and offset in a popupWindow. + * This class handles the case where the viewToAnimate moves during the animation + */ +public class PopupTransitionAnimation extends Animation { + /** The view we're animating */ + private final View mViewToAnimate; + + /** The rect to start the slide in animation from */ + private final Rect mStartRect; + + /** The rect of the currently animated view */ + private Rect mCurrentRect; + + /** The rect that we're animating to. This can change during the animation */ + private final Rect mDestRect; + + /** The bounds of the popup in window coordinates. Does not include notification bar */ + private final Rect mPopupRect; + + /** The bounds of the action bar in window coordinates. We clip the popup to below this */ + private final Rect mActionBarRect; + + /** Interpolates between the start and end rect for every animation tick */ + private final TypeEvaluator mRectEvaluator; + + /** The popup window that holds contains the animating view */ + private PopupWindow mPopupWindow; + + /** The layout root for the popup which is where the animated view is rendered */ + private View mPopupRoot; + + /** The action bar's view */ + private final View mActionBarView; + + private Runnable mOnStartCallback; + private Runnable mOnStopCallback; + + public PopupTransitionAnimation(final Rect startRect, final View viewToAnimate) { + mViewToAnimate = viewToAnimate; + mStartRect = startRect; + mCurrentRect = new Rect(mStartRect); + mDestRect = new Rect(); + mPopupRect = new Rect(); + mActionBarRect = new Rect(); + final Activity activity = (Activity) viewToAnimate.getRootView().getContext(); + mActionBarView = activity.getWindow().getDecorView().findViewById( + android.support.v7.appcompat.R.id.action_bar); + mRectEvaluator = RectEvaluatorCompat.create(); + setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION); + setInterpolator(UiUtils.DEFAULT_INTERPOLATOR); + setAnimationListener(new AnimationListener() { + @Override + public void onAnimationStart(final Animation animation) { + if (mOnStartCallback != null) { + mOnStartCallback.run(); + } + mEvents.append("oAS,"); + } + + @Override + public void onAnimationEnd(final Animation animation) { + if (mOnStopCallback != null) { + mOnStopCallback.run(); + } + dismiss(); + mEvents.append("oAE,"); + } + + @Override + public void onAnimationRepeat(final Animation animation) { + } + }); + } + + private final StringBuilder mEvents = new StringBuilder(); + private final Runnable mCleanupRunnable = new Runnable() { + @Override + public void run() { + LogUtil.w(LogUtil.BUGLE_TAG, "PopupTransitionAnimation: " + mEvents); + } + }; + + /** + * Ensures the animation is ready before starting the animation. + * viewToAnimate must first be layed out so we know where we will animate to + */ + public void startAfterLayoutComplete() { + // We want layout to occur, and then we immediately animate it in, so hide it initially to + // reduce jank on the first frame + mViewToAnimate.setVisibility(View.INVISIBLE); + mViewToAnimate.setAlpha(0); + + final Runnable startAnimation = new Runnable() { + boolean mRunComplete = false; + boolean mFirstTry = true; + + @Override + public void run() { + if (mRunComplete) { + return; + } + + mViewToAnimate.getGlobalVisibleRect(mDestRect); + // In Android views which are visible but haven't computed their size yet have a + // size of 1x1 because anything with a size of 0x0 is considered hidden. We can't + // start the animation until after the size is greater than 1x1 + if (mDestRect.width() <= 1 || mDestRect.height() <= 1) { + // Layout hasn't occurred yet + if (!mFirstTry) { + // Give up if this is not the first try, since layout change still doesn't + // yield a size for the view. This is likely because the media picker is + // full screen so there's no space left for the animated view. We give up + // on animation, but need to make sure the view that was initially + // hidden is re-shown. + mViewToAnimate.setAlpha(1); + mViewToAnimate.setVisibility(View.VISIBLE); + } else { + mFirstTry = false; + UiUtils.doOnceAfterLayoutChange(mViewToAnimate, this); + } + return; + } + + mRunComplete = true; + mViewToAnimate.startAnimation(PopupTransitionAnimation.this); + mViewToAnimate.invalidate(); + // http://b/20856505: The PopupWindow sometimes does not get dismissed. + ThreadUtil.getMainThreadHandler().postDelayed(mCleanupRunnable, getDuration() * 2); + } + }; + + startAnimation.run(); + } + + public PopupTransitionAnimation setOnStartCallback(final Runnable onStart) { + mOnStartCallback = onStart; + return this; + } + + public PopupTransitionAnimation setOnStopCallback(final Runnable onStop) { + mOnStopCallback = onStop; + return this; + } + + @Override + protected void applyTransformation(final float interpolatedTime, final Transformation t) { + if (mPopupWindow == null) { + initPopupWindow(); + } + // Update mDestRect as it may have moved during the animation + mPopupRect.set(UiUtils.getMeasuredBoundsOnScreen(mPopupRoot)); + mActionBarRect.set(UiUtils.getMeasuredBoundsOnScreen(mActionBarView)); + computeDestRect(); + + // Update currentRect to the new animated coordinates, and request mPopupRoot to redraw + // itself at the new coordinates + mCurrentRect = mRectEvaluator.evaluate(interpolatedTime, mStartRect, mDestRect); + mPopupRoot.invalidate(); + + if (interpolatedTime >= 0.98) { + mEvents.append("aT").append(interpolatedTime).append(','); + } + if (interpolatedTime == 1) { + dismiss(); + } + } + + private void dismiss() { + mEvents.append("d,"); + mViewToAnimate.setAlpha(1); + mViewToAnimate.setVisibility(View.VISIBLE); + // Delay dismissing the popup window to let mViewToAnimate draw under it and reduce the + // flash + ThreadUtil.getMainThreadHandler().post(new Runnable() { + @Override + public void run() { + try { + mPopupWindow.dismiss(); + } catch (IllegalArgumentException e) { + // PopupWindow.dismiss() will fire an IllegalArgumentException if the activity + // has already ended while we were animating + } + ThreadUtil.getMainThreadHandler().removeCallbacks(mCleanupRunnable); + } + }); + } + + @Override + public boolean willChangeBounds() { + return false; + } + + /** + * Computes mDestRect (the position in window space of the placeholder view that we should + * animate to). Some frames during the animation fail to compute getGlobalVisibleRect, so use + * the last known values in that case + */ + private void computeDestRect() { + final int prevTop = mDestRect.top; + final int prevLeft = mDestRect.left; + final int prevRight = mDestRect.right; + final int prevBottom = mDestRect.bottom; + + if (!getViewScreenMeasureRect(mViewToAnimate, mDestRect)) { + mDestRect.top = prevTop; + mDestRect.left = prevLeft; + mDestRect.bottom = prevBottom; + mDestRect.right = prevRight; + } + } + + /** + * Sets up the PopupWindow that the view will animate in. Animating the size and position of a + * popup can be choppy, so instead we make the popup fill the entire space of the screen, and + * animate the position of viewToAnimate within the popup using a Transformation + */ + private void initPopupWindow() { + mPopupRoot = new View(mViewToAnimate.getContext()) { + @Override + protected void onDraw(final Canvas canvas) { + canvas.save(); + canvas.clipRect(getLeft(), mActionBarRect.bottom - mPopupRect.top, getRight(), + getBottom()); + canvas.drawColor(Color.TRANSPARENT); + final float previousAlpha = mViewToAnimate.getAlpha(); + mViewToAnimate.setAlpha(1); + // The view's global position includes the notification bar height, but + // the popup window may or may not cover the notification bar (depending on screen + // rotation, IME status etc.), so we need to compensate for this difference by + // offseting vertically. + canvas.translate(mCurrentRect.left, mCurrentRect.top - mPopupRect.top); + + final float viewWidth = mViewToAnimate.getWidth(); + final float viewHeight = mViewToAnimate.getHeight(); + if (viewWidth > 0 && viewHeight > 0) { + canvas.scale(mCurrentRect.width() / viewWidth, + mCurrentRect.height() / viewHeight); + } + canvas.clipRect(0, 0, mCurrentRect.width(), mCurrentRect.height()); + if (!mPopupRect.isEmpty()) { + // HACK: Layout is unstable until mPopupRect is non-empty. + mViewToAnimate.draw(canvas); + } + mViewToAnimate.setAlpha(previousAlpha); + canvas.restore(); + } + }; + mPopupWindow = new PopupWindow(mViewToAnimate.getContext()); + mPopupWindow.setBackgroundDrawable(null); + mPopupWindow.setContentView(mPopupRoot); + mPopupWindow.setWidth(ViewGroup.LayoutParams.MATCH_PARENT); + mPopupWindow.setHeight(ViewGroup.LayoutParams.MATCH_PARENT); + mPopupWindow.setTouchable(false); + // We must pass a non-zero value for the y offset, or else the system resets the status bar + // color to black (M only) during the animation. The actual position of the window (and + // the animated view inside it) are still correct, regardless of what we pass for the y + // parameter (e.g. 1 and 100 both work). Not entirely sure why this works. + mPopupWindow.showAtLocation(mViewToAnimate, Gravity.TOP, 0, 1); + } + + private static boolean getViewScreenMeasureRect(final View view, final Rect outRect) { + outRect.set(UiUtils.getMeasuredBoundsOnScreen(view)); + return !outRect.isEmpty(); + } +} diff --git a/src/com/android/messaging/ui/animation/RectEvaluatorCompat.java b/src/com/android/messaging/ui/animation/RectEvaluatorCompat.java new file mode 100644 index 0000000..e3c60fc --- /dev/null +++ b/src/com/android/messaging/ui/animation/RectEvaluatorCompat.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.animation; + +import android.animation.RectEvaluator; +import android.animation.TypeEvaluator; +import android.graphics.Rect; + +import com.android.messaging.util.OsUtil; + +/** + * This evaluator can be used to perform type interpolation between Rect values. + * It's backward compatible to Api Level 11. + */ +public class RectEvaluatorCompat implements TypeEvaluator { + public static TypeEvaluator create() { + if (OsUtil.isAtLeastJB_MR2()) { + return new RectEvaluator(); + } else { + return new RectEvaluatorCompat(); + } + } + + @Override + public Rect evaluate(float fraction, Rect startValue, Rect endValue) { + int left = startValue.left + (int) ((endValue.left - startValue.left) * fraction); + int top = startValue.top + (int) ((endValue.top - startValue.top) * fraction); + int right = startValue.right + (int) ((endValue.right - startValue.right) * fraction); + int bottom = startValue.bottom + (int) ((endValue.bottom - startValue.bottom) * fraction); + return new Rect(left, top, right, bottom); + } +} diff --git a/src/com/android/messaging/ui/animation/ViewGroupItemVerticalExplodeAnimation.java b/src/com/android/messaging/ui/animation/ViewGroupItemVerticalExplodeAnimation.java new file mode 100644 index 0000000..6abfdf9 --- /dev/null +++ b/src/com/android/messaging/ui/animation/ViewGroupItemVerticalExplodeAnimation.java @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.animation; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.support.v4.view.ViewCompat; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewGroupOverlay; +import android.view.ViewOverlay; +import android.widget.FrameLayout; + +import com.android.messaging.R; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.UiUtils; + +/** + *

+ * Shows a vertical "explode" animation for any view inside a view group (e.g. views inside a + * ListView). During the animation, a snapshot is taken for the view to the animated and + * presented in a popup window or view overlay on top of the original view group. The background + * of the view (a highlight) vertically expands (explodes) during the animation. + *

+ *

+ * The exact implementation of the animation depends on platform API level. For JB_MR2 and later, + * the implementation utilizes ViewOverlay to perform highly performant overlay animations; for + * older API levels, the implementation falls back to using a full screen popup window to stage + * the animation. + *

+ *

+ * To start this animation, call {@link #startAnimationForView(ViewGroup, View, View, boolean, int)} + *

+ */ +public class ViewGroupItemVerticalExplodeAnimation { + /** + * Starts a vertical explode animation for a given view situated in a given container. + * + * @param container the container of the view which determines the explode animation's final + * size + * @param viewToAnimate the view to be animated. The view will be highlighted by the explode + * highlight, which expands from the size of the view to the size of the container. + * @param animationStagingView the view that stages the animation. Since viewToAnimate may be + * removed from the view tree during the animation, we need a view that'll be alive + * for the duration of the animation so that the animation won't get cancelled. + * @param snapshotView whether a snapshot of the view to animate is needed. + */ + public static void startAnimationForView(final ViewGroup container, final View viewToAnimate, + final View animationStagingView, final boolean snapshotView, final int duration) { + if (OsUtil.isAtLeastJB_MR2() && (viewToAnimate.getContext() instanceof Activity)) { + new ViewExplodeAnimationJellyBeanMR2(viewToAnimate, container, snapshotView, duration) + .startAnimation(); + } else { + // Pre JB_MR2, this animation can cause rendering failures which causes the framework + // to fall back to software rendering where camera preview isn't supported (b/18264647) + // just skip the animation to avoid this case. + } + } + + /** + * Implementation class for API level >= 18. + */ + @TargetApi(18) + private static class ViewExplodeAnimationJellyBeanMR2 { + private final View mViewToAnimate; + private final ViewGroup mContainer; + private final View mSnapshot; + private final Bitmap mViewBitmap; + private final int mDuration; + + public ViewExplodeAnimationJellyBeanMR2(final View viewToAnimate, final ViewGroup container, + final boolean snapshotView, final int duration) { + mViewToAnimate = viewToAnimate; + mContainer = container; + mDuration = duration; + if (snapshotView) { + mViewBitmap = snapshotView(viewToAnimate); + mSnapshot = new View(viewToAnimate.getContext()); + } else { + mSnapshot = null; + mViewBitmap = null; + } + } + + public void startAnimation() { + final Context context = mViewToAnimate.getContext(); + final Resources resources = context.getResources(); + final View decorView = ((Activity) context).getWindow().getDecorView(); + final ViewOverlay viewOverlay = decorView.getOverlay(); + if (viewOverlay instanceof ViewGroupOverlay) { + final ViewGroupOverlay overlay = (ViewGroupOverlay) viewOverlay; + + // Add a shadow layer to the overlay. + final FrameLayout shadowContainerLayer = new FrameLayout(context); + final Drawable oldBackground = mViewToAnimate.getBackground(); + final Rect containerRect = UiUtils.getMeasuredBoundsOnScreen(mContainer); + final Rect decorRect = UiUtils.getMeasuredBoundsOnScreen(decorView); + // Position the container rect relative to the decor rect since the decor rect + // defines whether the view overlay will be positioned. + containerRect.offset(-decorRect.left, -decorRect.top); + shadowContainerLayer.setLeft(containerRect.left); + shadowContainerLayer.setTop(containerRect.top); + shadowContainerLayer.setBottom(containerRect.bottom); + shadowContainerLayer.setRight(containerRect.right); + shadowContainerLayer.setBackgroundColor(resources.getColor( + R.color.open_conversation_animation_background_shadow)); + // Per design request, temporarily clear out the background of the item content + // to not show any ripple effects during animation. + if (!(oldBackground instanceof ColorDrawable)) { + mViewToAnimate.setBackground(null); + } + overlay.add(shadowContainerLayer); + + // Add a expand layer and position it with in the shadow background, so it can + // be properly clipped to the container bounds during the animation. + final View expandLayer = new View(context); + final int elevation = resources.getDimensionPixelSize( + R.dimen.explode_animation_highlight_elevation); + final Rect viewRect = UiUtils.getMeasuredBoundsOnScreen(mViewToAnimate); + // Frame viewRect from screen space to containerRect space. + viewRect.offset(-containerRect.left - decorRect.left, + -containerRect.top - decorRect.top); + // Since the expand layer expands at the same rate above and below, we need to + // compute the expand scale using the bigger of the top/bottom distances. + final int expandLayerHalfHeight = viewRect.height() / 2; + final int topDist = viewRect.top; + final int bottomDist = containerRect.height() - viewRect.bottom; + final float scale = expandLayerHalfHeight == 0 ? 1 : + ((float) Math.max(topDist, bottomDist) + expandLayerHalfHeight) / + expandLayerHalfHeight; + // Position the expand layer initially to exactly match the animated item. + shadowContainerLayer.addView(expandLayer); + expandLayer.setLeft(viewRect.left); + expandLayer.setTop(viewRect.top); + expandLayer.setBottom(viewRect.bottom); + expandLayer.setRight(viewRect.right); + expandLayer.setBackgroundColor(resources.getColor( + R.color.conversation_background)); + ViewCompat.setElevation(expandLayer, elevation); + + // Conditionally stage the snapshot in the overlay. + if (mSnapshot != null) { + shadowContainerLayer.addView(mSnapshot); + mSnapshot.setLeft(viewRect.left); + mSnapshot.setTop(viewRect.top); + mSnapshot.setBottom(viewRect.bottom); + mSnapshot.setRight(viewRect.right); + mSnapshot.setBackground(new BitmapDrawable(resources, mViewBitmap)); + ViewCompat.setElevation(mSnapshot, elevation); + } + + // Apply a scale animation to scale to full screen. + expandLayer.animate().scaleY(scale) + .setDuration(mDuration) + .setInterpolator(UiUtils.EASE_IN_INTERPOLATOR) + .withEndAction(new Runnable() { + @Override + public void run() { + // Clean up the views added to overlay on animation finish. + overlay.remove(shadowContainerLayer); + mViewToAnimate.setBackground(oldBackground); + if (mViewBitmap != null) { + mViewBitmap.recycle(); + } + } + }); + } + } + } + + /** + * Take a snapshot of the given review, return a Bitmap object that's owned by the caller. + */ + static Bitmap snapshotView(final View view) { + // Save the content of the view into a bitmap. + final Bitmap viewBitmap = Bitmap.createBitmap(view.getWidth(), + view.getHeight(), Bitmap.Config.ARGB_8888); + // Strip the view of its background when taking a snapshot so that things like touch + // feedback don't get accidentally snapshotted. + final Drawable viewBackground = view.getBackground(); + ImageUtils.setBackgroundDrawableOnView(view, null); + view.draw(new Canvas(viewBitmap)); + ImageUtils.setBackgroundDrawableOnView(view, viewBackground); + return viewBitmap; + } +} diff --git a/src/com/android/messaging/ui/appsettings/ApnEditorActivity.java b/src/com/android/messaging/ui/appsettings/ApnEditorActivity.java new file mode 100644 index 0000000..b7cb7ae --- /dev/null +++ b/src/com/android/messaging/ui/appsettings/ApnEditorActivity.java @@ -0,0 +1,463 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.appsettings; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.ContentValues; +import android.content.Intent; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.AsyncTask; +import android.os.Bundle; +import android.preference.EditTextPreference; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.provider.Telephony; +import android.support.v4.app.NavUtils; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.ApnDatabase; +import com.android.messaging.sms.BugleApnSettingsLoader; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.PhoneUtils; + +public class ApnEditorActivity extends BugleActionBarActivity { + private static final int ERROR_DIALOG_ID = 0; + private static final String ERROR_MESSAGE_KEY = "error_msg"; + private ApnEditorFragment mApnEditorFragment; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + // Display the fragment as the main content. + mApnEditorFragment = new ApnEditorFragment(); + mApnEditorFragment.setSubId(getIntent().getIntExtra(UIIntents.UI_INTENT_EXTRA_SUB_ID, + ParticipantData.DEFAULT_SELF_SUB_ID)); + getFragmentManager().beginTransaction() + .replace(android.R.id.content, mApnEditorFragment) + .commit(); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + NavUtils.navigateUpFromSameTask(this); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + protected Dialog onCreateDialog(int id, Bundle args) { + + if (id == ERROR_DIALOG_ID) { + String msg = args.getString(ERROR_MESSAGE_KEY); + + return new AlertDialog.Builder(this) + .setPositiveButton(android.R.string.ok, null) + .setMessage(msg) + .create(); + } + + return super.onCreateDialog(id); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + switch (keyCode) { + case KeyEvent.KEYCODE_BACK: { + if (mApnEditorFragment.validateAndSave(false)) { + finish(); + } + return true; + } + } + return super.onKeyDown(keyCode, event); + } + + @Override + protected void onPrepareDialog(int id, Dialog dialog, Bundle args) { + super.onPrepareDialog(id, dialog); + + if (id == ERROR_DIALOG_ID) { + final String msg = args.getString(ERROR_MESSAGE_KEY); + + if (msg != null) { + ((AlertDialog) dialog).setMessage(msg); + } + } + } + + public static class ApnEditorFragment extends PreferenceFragment implements + SharedPreferences.OnSharedPreferenceChangeListener { + + private static final String SAVED_POS = "pos"; + + private static final int MENU_DELETE = Menu.FIRST; + private static final int MENU_SAVE = Menu.FIRST + 1; + private static final int MENU_CANCEL = Menu.FIRST + 2; + + private EditTextPreference mMmsProxy; + private EditTextPreference mMmsPort; + private EditTextPreference mName; + private EditTextPreference mMmsc; + private EditTextPreference mMcc; + private EditTextPreference mMnc; + private static String sNotSet; + + private String mCurMnc; + private String mCurMcc; + + private Cursor mCursor; + private boolean mNewApn; + private boolean mFirstTime; + private String mCurrentId; + + private int mSubId; + + /** + * Standard projection for the interesting columns of a normal note. + */ + private static final String[] sProjection = new String[] { + Telephony.Carriers._ID, // 0 + Telephony.Carriers.NAME, // 1 + Telephony.Carriers.MMSC, // 2 + Telephony.Carriers.MCC, // 3 + Telephony.Carriers.MNC, // 4 + Telephony.Carriers.NUMERIC, // 5 + Telephony.Carriers.MMSPROXY, // 6 + Telephony.Carriers.MMSPORT, // 7 + Telephony.Carriers.TYPE, // 8 + }; + + private static final int ID_INDEX = 0; + private static final int NAME_INDEX = 1; + private static final int MMSC_INDEX = 2; + private static final int MCC_INDEX = 3; + private static final int MNC_INDEX = 4; + private static final int NUMERIC_INDEX = 5; + private static final int MMSPROXY_INDEX = 6; + private static final int MMSPORT_INDEX = 7; + private static final int TYPE_INDEX = 8; + + private SQLiteDatabase mDatabase; + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + addPreferencesFromResource(R.xml.apn_editor); + + setHasOptionsMenu(true); + + sNotSet = getResources().getString(R.string.apn_not_set); + mName = (EditTextPreference) findPreference("apn_name"); + mMmsProxy = (EditTextPreference) findPreference("apn_mms_proxy"); + mMmsPort = (EditTextPreference) findPreference("apn_mms_port"); + mMmsc = (EditTextPreference) findPreference("apn_mmsc"); + mMcc = (EditTextPreference) findPreference("apn_mcc"); + mMnc = (EditTextPreference) findPreference("apn_mnc"); + + final Intent intent = getActivity().getIntent(); + + mFirstTime = savedInstanceState == null; + mCurrentId = intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_APN_ROW_ID); + mNewApn = mCurrentId == null; + + mDatabase = ApnDatabase.getApnDatabase().getWritableDatabase(); + + if (mNewApn) { + fillUi(); + } else { + // Do initial query not on the UI thread + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + if (mCurrentId != null) { + String selection = Telephony.Carriers._ID + " =?"; + String[] selectionArgs = new String[]{ mCurrentId }; + mCursor = mDatabase.query(ApnDatabase.APN_TABLE, sProjection, selection, + selectionArgs, null, null, null, null); + } + return null; + } + + @Override + protected void onPostExecute(Void result) { + if (mCursor == null) { + getActivity().finish(); + return; + } + mCursor.moveToFirst(); + + fillUi(); + } + }.execute((Void) null); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (mCursor != null) { + mCursor.close(); + mCursor = null; + } + } + + @Override + public void onResume() { + super.onResume(); + getPreferenceScreen().getSharedPreferences() + .registerOnSharedPreferenceChangeListener(this); + } + + @Override + public void onPause() { + getPreferenceScreen().getSharedPreferences() + .unregisterOnSharedPreferenceChangeListener(this); + super.onPause(); + } + + public void setSubId(final int subId) { + mSubId = subId; + } + + private void fillUi() { + if (mNewApn) { + mMcc.setText(null); + mMnc.setText(null); + String numeric = PhoneUtils.get(mSubId).getSimOperatorNumeric(); + // MCC is first 3 chars and then in 2 - 3 chars of MNC + if (numeric != null && numeric.length() > 4) { + // Country code + String mcc = numeric.substring(0, 3); + // Network code + String mnc = numeric.substring(3); + // Auto populate MNC and MCC for new entries, based on what SIM reports + mMcc.setText(mcc); + mMnc.setText(mnc); + mCurMnc = mnc; + mCurMcc = mcc; + } + mName.setText(null); + mMmsProxy.setText(null); + mMmsPort.setText(null); + mMmsc.setText(null); + } else if (mFirstTime) { + mFirstTime = false; + // Fill in all the values from the db in both text editor and summary + mName.setText(mCursor.getString(NAME_INDEX)); + mMmsProxy.setText(mCursor.getString(MMSPROXY_INDEX)); + mMmsPort.setText(mCursor.getString(MMSPORT_INDEX)); + mMmsc.setText(mCursor.getString(MMSC_INDEX)); + mMcc.setText(mCursor.getString(MCC_INDEX)); + mMnc.setText(mCursor.getString(MNC_INDEX)); + } + + mName.setSummary(checkNull(mName.getText())); + mMmsProxy.setSummary(checkNull(mMmsProxy.getText())); + mMmsPort.setSummary(checkNull(mMmsPort.getText())); + mMmsc.setSummary(checkNull(mMmsc.getText())); + mMcc.setSummary(checkNull(mMcc.getText())); + mMnc.setSummary(checkNull(mMnc.getText())); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + // If it's a new APN, then cancel will delete the new entry in onPause + if (!mNewApn) { + menu.add(0, MENU_DELETE, 0, R.string.menu_delete_apn) + .setIcon(R.drawable.ic_delete_small_dark); + } + menu.add(0, MENU_SAVE, 0, R.string.menu_save_apn) + .setIcon(android.R.drawable.ic_menu_save); + menu.add(0, MENU_CANCEL, 0, R.string.menu_discard_apn_change) + .setIcon(android.R.drawable.ic_menu_close_clear_cancel); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case MENU_DELETE: + deleteApn(); + return true; + + case MENU_SAVE: + if (validateAndSave(false)) { + getActivity().finish(); + } + return true; + + case MENU_CANCEL: + getActivity().finish(); + return true; + + case android.R.id.home: + getActivity().onBackPressed(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onSaveInstanceState(Bundle icicle) { + super.onSaveInstanceState(icicle); + if (validateAndSave(true) && mCursor != null) { + icicle.putInt(SAVED_POS, mCursor.getInt(ID_INDEX)); + } + } + + /** + * Check the key fields' validity and save if valid. + * @param force save even if the fields are not valid, if the app is + * being suspended + * @return true if the data was saved + */ + private boolean validateAndSave(boolean force) { + final String name = checkNotSet(mName.getText()); + final String mcc = checkNotSet(mMcc.getText()); + final String mnc = checkNotSet(mMnc.getText()); + + if (getErrorMsg() != null && !force) { + final Bundle bundle = new Bundle(); + bundle.putString(ERROR_MESSAGE_KEY, getErrorMsg()); + getActivity().showDialog(ERROR_DIALOG_ID, bundle); + return false; + } + + // Make database changes not on the UI thread + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + ContentValues values = new ContentValues(); + + // Add a dummy name "Untitled", if the user exits the screen without adding a + // name but entered other information worth keeping. + values.put(Telephony.Carriers.NAME, name.length() < 1 ? + getResources().getString(R.string.untitled_apn) : name); + values.put(Telephony.Carriers.MMSPROXY, checkNotSet(mMmsProxy.getText())); + values.put(Telephony.Carriers.MMSPORT, checkNotSet(mMmsPort.getText())); + values.put(Telephony.Carriers.MMSC, checkNotSet(mMmsc.getText())); + + values.put(Telephony.Carriers.TYPE, BugleApnSettingsLoader.APN_TYPE_MMS); + + values.put(Telephony.Carriers.MCC, mcc); + values.put(Telephony.Carriers.MNC, mnc); + + values.put(Telephony.Carriers.NUMERIC, mcc + mnc); + + if (mCurMnc != null && mCurMcc != null) { + if (mCurMnc.equals(mnc) && mCurMcc.equals(mcc)) { + values.put(Telephony.Carriers.CURRENT, 1); + } + } + + if (mNewApn) { + mDatabase.insert(ApnDatabase.APN_TABLE, null, values); + } else { + // update the APN + String selection = Telephony.Carriers._ID + " =?"; + String[] selectionArgs = new String[]{ mCurrentId }; + int updated = mDatabase.update(ApnDatabase.APN_TABLE, values, + selection, selectionArgs); + } + return null; + } + }.execute((Void) null); + + return true; + } + + private void deleteApn() { + // Make database changes not on the UI thread + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + // delete the APN + String where = Telephony.Carriers._ID + " =?"; + String[] whereArgs = new String[]{ mCurrentId }; + + mDatabase.delete(ApnDatabase.APN_TABLE, where, whereArgs); + return null; + } + }.execute((Void) null); + + getActivity().finish(); + } + + private String checkNull(String value) { + if (value == null || value.length() == 0) { + return sNotSet; + } else { + return value; + } + } + + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + Preference pref = findPreference(key); + if (pref != null) { + pref.setSummary(checkNull(sharedPreferences.getString(key, ""))); + } + } + + private String getErrorMsg() { + String errorMsg = null; + + String name = checkNotSet(mName.getText()); + String mcc = checkNotSet(mMcc.getText()); + String mnc = checkNotSet(mMnc.getText()); + + if (name.length() < 1) { + errorMsg = getString(R.string.error_apn_name_empty); + } else if (mcc.length() != 3) { + errorMsg = getString(R.string.error_mcc_not3); + } else if ((mnc.length() & 0xFFFE) != 2) { + errorMsg = getString(R.string.error_mnc_not23); + } + + return errorMsg; + } + + private String checkNotSet(String value) { + if (value == null || value.equals(sNotSet)) { + return ""; + } else { + return value; + } + } + } +} diff --git a/src/com/android/messaging/ui/appsettings/ApnPreference.java b/src/com/android/messaging/ui/appsettings/ApnPreference.java new file mode 100644 index 0000000..74c6a08 --- /dev/null +++ b/src/com/android/messaging/ui/appsettings/ApnPreference.java @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.appsettings; + +import android.content.Context; +import android.preference.Preference; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.CompoundButton; +import android.widget.RadioButton; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.ui.UIIntents; + +/** + * ApnPreference implements a pref, typically used as a list item, that has a title/summary on + * the left and a radio button on the right. + * + */ +public class ApnPreference extends Preference implements + CompoundButton.OnCheckedChangeListener, OnClickListener { + static final String TAG = "ApnPreference"; + + public ApnPreference(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + public ApnPreference(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.apnPreferenceStyle); + } + + public ApnPreference(Context context) { + this(context, null); + } + + private static String mSelectedKey = null; + private static CompoundButton mCurrentChecked = null; + private boolean mProtectFromCheckedChange = false; + private boolean mSelectable = true; + private int mSubId = ParticipantData.DEFAULT_SELF_SUB_ID; + + @Override + public View getView(View convertView, ViewGroup parent) { + View view = super.getView(convertView, parent); + + View widget = view.findViewById(R.id.apn_radiobutton); + if ((widget != null) && widget instanceof RadioButton) { + RadioButton rb = (RadioButton) widget; + if (mSelectable) { + rb.setOnCheckedChangeListener(this); + + boolean isChecked = getKey().equals(mSelectedKey); + if (isChecked) { + mCurrentChecked = rb; + mSelectedKey = getKey(); + } + + mProtectFromCheckedChange = true; + rb.setChecked(isChecked); + mProtectFromCheckedChange = false; + } else { + rb.setVisibility(View.GONE); + } + setApnRadioButtonContentDescription(rb); + } + + View textLayout = view.findViewById(R.id.text_layout); + if ((textLayout != null) && textLayout instanceof RelativeLayout) { + textLayout.setOnClickListener(this); + } + + return view; + } + + public void setApnRadioButtonContentDescription(final CompoundButton buttonView) { + final View widget = (View) buttonView.getParent(); + final TextView tv = (TextView) widget.findViewById(android.R.id.title); + final String apnTitle = tv.getText().toString(); + buttonView.setContentDescription(apnTitle); + } + + public boolean isChecked() { + return getKey().equals(mSelectedKey); + } + + public void setChecked() { + mSelectedKey = getKey(); + } + + public void setSubId(final int subId) { + mSubId = subId; + } + + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + Log.i(TAG, "ID: " + getKey() + " :" + isChecked); + if (mProtectFromCheckedChange) { + return; + } + + if (isChecked) { + if (mCurrentChecked != null) { + mCurrentChecked.setChecked(false); + } + mCurrentChecked = buttonView; + mSelectedKey = getKey(); + callChangeListener(mSelectedKey); + } else { + mCurrentChecked = null; + mSelectedKey = null; + } + setApnRadioButtonContentDescription(buttonView); + } + + public void onClick(android.view.View v) { + if ((v != null) && (R.id.text_layout == v.getId())) { + Context context = getContext(); + if (context != null) { + context.startActivity( + UIIntents.get().getApnEditorIntent(context, getKey(), mSubId)); + } + } + } + + public void setSelectable(boolean selectable) { + mSelectable = selectable; + } + + public boolean getSelectable() { + return mSelectable; + } +} diff --git a/src/com/android/messaging/ui/appsettings/ApnSettingsActivity.java b/src/com/android/messaging/ui/appsettings/ApnSettingsActivity.java new file mode 100644 index 0000000..28dfc2a --- /dev/null +++ b/src/com/android/messaging/ui/appsettings/ApnSettingsActivity.java @@ -0,0 +1,406 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.appsettings; + +import android.app.Activity; +import android.app.Dialog; +import android.app.ProgressDialog; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; +import android.os.UserManager; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.preference.PreferenceGroup; +import android.preference.PreferenceScreen; +import android.provider.Telephony; +import android.support.v4.app.NavUtils; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.ApnDatabase; +import com.android.messaging.sms.BugleApnSettingsLoader; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +public class ApnSettingsActivity extends BugleActionBarActivity { + private static final int DIALOG_RESTORE_DEFAULTAPN = 1001; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + // Display the fragment as the main content. + final ApnSettingsFragment fragment = new ApnSettingsFragment(); + fragment.setSubId(getIntent().getIntExtra(UIIntents.UI_INTENT_EXTRA_SUB_ID, + ParticipantData.DEFAULT_SELF_SUB_ID)); + getFragmentManager().beginTransaction() + .replace(android.R.id.content, fragment) + .commit(); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + NavUtils.navigateUpFromSameTask(this); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + protected Dialog onCreateDialog(int id) { + if (id == DIALOG_RESTORE_DEFAULTAPN) { + ProgressDialog dialog = new ProgressDialog(this); + dialog.setMessage(getResources().getString(R.string.restore_default_apn)); + dialog.setCancelable(false); + return dialog; + } + return null; + } + + public static class ApnSettingsFragment extends PreferenceFragment implements + Preference.OnPreferenceChangeListener { + public static final String EXTRA_POSITION = "position"; + + public static final String APN_ID = "apn_id"; + + private static final String[] APN_PROJECTION = { + Telephony.Carriers._ID, // 0 + Telephony.Carriers.NAME, // 1 + Telephony.Carriers.APN, // 2 + Telephony.Carriers.TYPE // 3 + }; + private static final int ID_INDEX = 0; + private static final int NAME_INDEX = 1; + private static final int APN_INDEX = 2; + private static final int TYPES_INDEX = 3; + + private static final int MENU_NEW = Menu.FIRST; + private static final int MENU_RESTORE = Menu.FIRST + 1; + + private static final int EVENT_RESTORE_DEFAULTAPN_START = 1; + private static final int EVENT_RESTORE_DEFAULTAPN_COMPLETE = 2; + + private static boolean mRestoreDefaultApnMode; + + private RestoreApnUiHandler mRestoreApnUiHandler; + private RestoreApnProcessHandler mRestoreApnProcessHandler; + private HandlerThread mRestoreDefaultApnThread; + + private String mSelectedKey; + + private static final ContentValues sCurrentNullMap; + private static final ContentValues sCurrentSetMap; + + private UserManager mUm; + + private boolean mUnavailable; + private int mSubId; + + static { + sCurrentNullMap = new ContentValues(1); + sCurrentNullMap.putNull(Telephony.Carriers.CURRENT); + + sCurrentSetMap = new ContentValues(1); + sCurrentSetMap.put(Telephony.Carriers.CURRENT, "2"); // 2 for user-selected APN, + // 1 for Bugle-selected APN + } + + private SQLiteDatabase mDatabase; + + public void setSubId(final int subId) { + mSubId = subId; + } + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + + mDatabase = ApnDatabase.getApnDatabase().getWritableDatabase(); + + if (OsUtil.isAtLeastL()) { + mUm = (UserManager) getActivity().getSystemService(Context.USER_SERVICE); + if (!mUm.hasUserRestriction(UserManager.DISALLOW_CONFIG_MOBILE_NETWORKS)) { + setHasOptionsMenu(true); + } + } else { + setHasOptionsMenu(true); + } + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + final ListView lv = (ListView) getView().findViewById(android.R.id.list); + TextView empty = (TextView) getView().findViewById(android.R.id.empty); + if (empty != null) { + empty.setText(R.string.apn_settings_not_available); + lv.setEmptyView(empty); + } + + if (OsUtil.isAtLeastL() && + mUm.hasUserRestriction(UserManager.DISALLOW_CONFIG_MOBILE_NETWORKS)) { + mUnavailable = true; + setPreferenceScreen(getPreferenceManager().createPreferenceScreen(getActivity())); + return; + } + + addPreferencesFromResource(R.xml.apn_settings); + + lv.setItemsCanFocus(true); + } + + @Override + public void onResume() { + super.onResume(); + + if (mUnavailable) { + return; + } + + if (!mRestoreDefaultApnMode) { + fillList(); + } + } + + @Override + public void onPause() { + super.onPause(); + + if (mUnavailable) { + return; + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + + if (mRestoreDefaultApnThread != null) { + mRestoreDefaultApnThread.quit(); + } + } + + private void fillList() { + final String mccMnc = PhoneUtils.getMccMncString(PhoneUtils.get(mSubId).getMccMnc()); + + new AsyncTask() { + @Override + protected Cursor doInBackground(Void... params) { + String selection = Telephony.Carriers.NUMERIC + " =?"; + String[] selectionArgs = new String[]{ mccMnc }; + final Cursor cursor = mDatabase.query(ApnDatabase.APN_TABLE, APN_PROJECTION, + selection, selectionArgs, null, null, null, null); + return cursor; + } + + @Override + protected void onPostExecute(Cursor cursor) { + if (cursor != null) { + try { + PreferenceGroup apnList = (PreferenceGroup) + findPreference(getString(R.string.apn_list_pref_key)); + apnList.removeAll(); + + mSelectedKey = BugleApnSettingsLoader.getFirstTryApn(mDatabase, mccMnc); + while (cursor.moveToNext()) { + String name = cursor.getString(NAME_INDEX); + String apn = cursor.getString(APN_INDEX); + String key = cursor.getString(ID_INDEX); + String type = cursor.getString(TYPES_INDEX); + + if (BugleApnSettingsLoader.isValidApnType(type, + BugleApnSettingsLoader.APN_TYPE_MMS)) { + ApnPreference pref = new ApnPreference(getActivity()); + pref.setKey(key); + pref.setTitle(name); + pref.setSummary(apn); + pref.setPersistent(false); + pref.setOnPreferenceChangeListener(ApnSettingsFragment.this); + pref.setSelectable(true); + + // Turn on the radio button for the currently selected APN. If + // there is no selected APN, don't select an APN. + if ((mSelectedKey != null && mSelectedKey.equals(key))) { + pref.setChecked(); + } + apnList.addPreference(pref); + } + } + } finally { + cursor.close(); + } + } + } + }.execute((Void) null); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + if (!mUnavailable) { + menu.add(0, MENU_NEW, 0, + getResources().getString(R.string.menu_new_apn)) + .setIcon(R.drawable.ic_add_gray) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM); + menu.add(0, MENU_RESTORE, 0, + getResources().getString(R.string.menu_restore_default_apn)) + .setIcon(android.R.drawable.ic_menu_upload); + } + + super.onCreateOptionsMenu(menu, inflater); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case MENU_NEW: + addNewApn(); + return true; + + case MENU_RESTORE: + restoreDefaultApn(); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void addNewApn() { + startActivity(UIIntents.get().getApnEditorIntent(getActivity(), null, mSubId)); + } + + @Override + public boolean onPreferenceTreeClick(PreferenceScreen preferenceScreen, + Preference preference) { + startActivity( + UIIntents.get().getApnEditorIntent(getActivity(), preference.getKey(), mSubId)); + return true; + } + + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + if (newValue instanceof String) { + setSelectedApnKey((String) newValue); + } + + return true; + } + + // current=2 means user selected APN + private static final String UPDATE_SELECTION = Telephony.Carriers.CURRENT + " =?"; + private static final String[] UPDATE_SELECTION_ARGS = new String[] { "2" }; + private void setSelectedApnKey(final String key) { + mSelectedKey = key; + + // Make database changes not on the UI thread + new AsyncTask() { + @Override + protected Void doInBackground(Void... params) { + // null out the previous "current=2" APN + mDatabase.update(ApnDatabase.APN_TABLE, sCurrentNullMap, + UPDATE_SELECTION, UPDATE_SELECTION_ARGS); + + // set the new "current" APN (2) + String selection = Telephony.Carriers._ID + " =?"; + String[] selectionArgs = new String[]{ key }; + + mDatabase.update(ApnDatabase.APN_TABLE, sCurrentSetMap, + selection, selectionArgs); + return null; + } + }.execute((Void) null); + } + + private boolean restoreDefaultApn() { + getActivity().showDialog(DIALOG_RESTORE_DEFAULTAPN); + mRestoreDefaultApnMode = true; + + if (mRestoreApnUiHandler == null) { + mRestoreApnUiHandler = new RestoreApnUiHandler(); + } + + if (mRestoreApnProcessHandler == null || + mRestoreDefaultApnThread == null) { + mRestoreDefaultApnThread = new HandlerThread( + "Restore default APN Handler: Process Thread"); + mRestoreDefaultApnThread.start(); + mRestoreApnProcessHandler = new RestoreApnProcessHandler( + mRestoreDefaultApnThread.getLooper(), mRestoreApnUiHandler); + } + + mRestoreApnProcessHandler.sendEmptyMessage(EVENT_RESTORE_DEFAULTAPN_START); + return true; + } + + private class RestoreApnUiHandler extends Handler { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case EVENT_RESTORE_DEFAULTAPN_COMPLETE: + fillList(); + getPreferenceScreen().setEnabled(true); + mRestoreDefaultApnMode = false; + final Activity activity = getActivity(); + activity.dismissDialog(DIALOG_RESTORE_DEFAULTAPN); + Toast.makeText(activity, getResources().getString( + R.string.restore_default_apn_completed), Toast.LENGTH_LONG) + .show(); + break; + } + } + } + + private class RestoreApnProcessHandler extends Handler { + private Handler mCachedRestoreApnUiHandler; + + public RestoreApnProcessHandler(Looper looper, Handler restoreApnUiHandler) { + super(looper); + this.mCachedRestoreApnUiHandler = restoreApnUiHandler; + } + + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case EVENT_RESTORE_DEFAULTAPN_START: + ApnDatabase.forceBuildAndLoadApnTables(); + mCachedRestoreApnUiHandler.sendEmptyMessage( + EVENT_RESTORE_DEFAULTAPN_COMPLETE); + break; + } + } + } + } +} diff --git a/src/com/android/messaging/ui/appsettings/ApplicationSettingsActivity.java b/src/com/android/messaging/ui/appsettings/ApplicationSettingsActivity.java new file mode 100644 index 0000000..906009f --- /dev/null +++ b/src/com/android/messaging/ui/appsettings/ApplicationSettingsActivity.java @@ -0,0 +1,262 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.appsettings; + +import android.app.FragmentTransaction; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.SharedPreferences.OnSharedPreferenceChangeListener; +import android.media.Ringtone; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.preference.PreferenceScreen; +import android.preference.RingtonePreference; +import android.preference.TwoStatePreference; +import android.provider.Settings; +import android.support.v4.app.NavUtils; +import android.text.TextUtils; +import android.view.Menu; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.LicenseActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +public class ApplicationSettingsActivity extends BugleActionBarActivity { + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + final boolean topLevel = getIntent().getBooleanExtra( + UIIntents.UI_INTENT_EXTRA_TOP_LEVEL_SETTINGS, false); + if (topLevel) { + getSupportActionBar().setTitle(getString(R.string.settings_activity_title)); + } + + FragmentTransaction ft = getFragmentManager().beginTransaction(); + ft.replace(android.R.id.content, new ApplicationSettingsFragment()); + ft.commit(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + if (super.onCreateOptionsMenu(menu)) { + return true; + } + getMenuInflater().inflate(R.menu.settings_menu, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + NavUtils.navigateUpFromSameTask(this); + return true; + case R.id.action_license: + final Intent intent = new Intent(this, LicenseActivity.class); + startActivity(intent); + return true; + } + return super.onOptionsItemSelected(item); + } + + public static class ApplicationSettingsFragment extends PreferenceFragment implements + OnSharedPreferenceChangeListener { + + private String mNotificationsEnabledPreferenceKey; + private TwoStatePreference mNotificationsEnabledPreference; + private String mRingtonePreferenceKey; + private RingtonePreference mRingtonePreference; + private Preference mVibratePreference; + private String mSmsDisabledPrefKey; + private Preference mSmsDisabledPreference; + private String mSmsEnabledPrefKey; + private Preference mSmsEnabledPreference; + private boolean mIsSmsPreferenceClicked; + + public ApplicationSettingsFragment() { + // Required empty constructor + } + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + getPreferenceManager().setSharedPreferencesName(BuglePrefs.SHARED_PREFERENCES_NAME); + addPreferencesFromResource(R.xml.preferences_application); + + mNotificationsEnabledPreferenceKey = + getString(R.string.notifications_enabled_pref_key); + mNotificationsEnabledPreference = (TwoStatePreference) findPreference( + mNotificationsEnabledPreferenceKey); + mRingtonePreferenceKey = getString(R.string.notification_sound_pref_key); + mRingtonePreference = (RingtonePreference) findPreference(mRingtonePreferenceKey); + mVibratePreference = findPreference( + getString(R.string.notification_vibration_pref_key)); + mSmsDisabledPrefKey = getString(R.string.sms_disabled_pref_key); + mSmsDisabledPreference = findPreference(mSmsDisabledPrefKey); + mSmsEnabledPrefKey = getString(R.string.sms_enabled_pref_key); + mSmsEnabledPreference = findPreference(mSmsEnabledPrefKey); + mIsSmsPreferenceClicked = false; + + final SharedPreferences prefs = getPreferenceScreen().getSharedPreferences(); + updateSoundSummary(prefs); + + if (!DebugUtils.isDebugEnabled()) { + final Preference debugCategory = findPreference(getString( + R.string.debug_pref_key)); + getPreferenceScreen().removePreference(debugCategory); + } + + final PreferenceScreen advancedScreen = (PreferenceScreen) findPreference( + getString(R.string.advanced_pref_key)); + final boolean topLevel = getActivity().getIntent().getBooleanExtra( + UIIntents.UI_INTENT_EXTRA_TOP_LEVEL_SETTINGS, false); + if (topLevel) { + advancedScreen.setIntent(UIIntents.get() + .getAdvancedSettingsIntent(getPreferenceScreen().getContext())); + } else { + // Hide the Advanced settings screen if this is not top-level; these are shown at + // the parent SettingsActivity. + getPreferenceScreen().removePreference(advancedScreen); + } + } + + @Override + public boolean onPreferenceTreeClick (PreferenceScreen preferenceScreen, + Preference preference) { + if (preference.getKey() == mSmsDisabledPrefKey || + preference.getKey() == mSmsEnabledPrefKey) { + mIsSmsPreferenceClicked = true; + } + return super.onPreferenceTreeClick(preferenceScreen, preference); + } + + private void updateSoundSummary(final SharedPreferences sharedPreferences) { + // The silent ringtone just returns an empty string + String ringtoneName = mRingtonePreference.getContext().getString( + R.string.silent_ringtone); + + String ringtoneString = sharedPreferences.getString(mRingtonePreferenceKey, null); + + // Bootstrap the default setting in the preferences so that we have a valid selection + // in the dialog the first time that the user opens it. + if (ringtoneString == null) { + ringtoneString = Settings.System.DEFAULT_NOTIFICATION_URI.toString(); + final SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(mRingtonePreferenceKey, ringtoneString); + editor.apply(); + } + + if (!TextUtils.isEmpty(ringtoneString)) { + final Uri ringtoneUri = Uri.parse(ringtoneString); + final Ringtone tone = RingtoneManager.getRingtone(mRingtonePreference.getContext(), + ringtoneUri); + + if (tone != null) { + ringtoneName = tone.getTitle(mRingtonePreference.getContext()); + } + } + + mRingtonePreference.setSummary(ringtoneName); + } + + private void updateSmsEnabledPreferences() { + if (!OsUtil.isAtLeastKLP()) { + getPreferenceScreen().removePreference(mSmsDisabledPreference); + getPreferenceScreen().removePreference(mSmsEnabledPreference); + } else { + final String defaultSmsAppLabel = getString(R.string.default_sms_app, + PhoneUtils.getDefault().getDefaultSmsAppLabel()); + boolean isSmsEnabledBeforeState; + boolean isSmsEnabledCurrentState; + if (PhoneUtils.getDefault().isDefaultSmsApp()) { + if (getPreferenceScreen().findPreference(mSmsEnabledPrefKey) == null) { + getPreferenceScreen().addPreference(mSmsEnabledPreference); + isSmsEnabledBeforeState = false; + } else { + isSmsEnabledBeforeState = true; + } + isSmsEnabledCurrentState = true; + getPreferenceScreen().removePreference(mSmsDisabledPreference); + mSmsEnabledPreference.setSummary(defaultSmsAppLabel); + } else { + if (getPreferenceScreen().findPreference(mSmsDisabledPrefKey) == null) { + getPreferenceScreen().addPreference(mSmsDisabledPreference); + isSmsEnabledBeforeState = true; + } else { + isSmsEnabledBeforeState = false; + } + isSmsEnabledCurrentState = false; + getPreferenceScreen().removePreference(mSmsEnabledPreference); + mSmsDisabledPreference.setSummary(defaultSmsAppLabel); + } + updateNotificationsPreferences(); + } + mIsSmsPreferenceClicked = false; + } + + private void updateNotificationsPreferences() { + final boolean canNotify = !OsUtil.isAtLeastKLP() + || PhoneUtils.getDefault().isDefaultSmsApp(); + mNotificationsEnabledPreference.setEnabled(canNotify); + } + + @Override + public void onStart() { + super.onStart(); + // We do this on start rather than on resume because the sound picker is in a + // separate activity. + getPreferenceScreen().getSharedPreferences() + .registerOnSharedPreferenceChangeListener(this); + } + + @Override + public void onResume() { + super.onResume(); + updateSmsEnabledPreferences(); + updateNotificationsPreferences(); + } + + @Override + public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, + final String key) { + if (key.equals(mNotificationsEnabledPreferenceKey)) { + updateNotificationsPreferences(); + } else if (key.equals(mRingtonePreferenceKey)) { + updateSoundSummary(sharedPreferences); + } + } + + @Override + public void onStop() { + super.onStop(); + getPreferenceScreen().getSharedPreferences() + .unregisterOnSharedPreferenceChangeListener(this); + } + } +} diff --git a/src/com/android/messaging/ui/appsettings/GroupMmsSettingDialog.java b/src/com/android/messaging/ui/appsettings/GroupMmsSettingDialog.java new file mode 100644 index 0000000..739d2dc --- /dev/null +++ b/src/com/android/messaging/ui/appsettings/GroupMmsSettingDialog.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.appsettings; + +import android.app.AlertDialog; +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.RadioButton; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BuglePrefs; + +/** + * Displays an on/off switch for group MMS setting for a given subscription. + */ +public class GroupMmsSettingDialog { + private final Context mContext; + private final int mSubId; + private AlertDialog mDialog; + + /** + * Shows a new group MMS setting dialog. + */ + public static void showDialog(final Context context, final int subId) { + new GroupMmsSettingDialog(context, subId).show(); + } + + private GroupMmsSettingDialog(final Context context, final int subId) { + mContext = context; + mSubId = subId; + } + + private void show() { + Assert.isNull(mDialog); + mDialog = new AlertDialog.Builder(mContext) + .setView(createView()) + .setTitle(R.string.group_mms_pref_title) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private void changeGroupMmsSettings(final boolean enable) { + Assert.notNull(mDialog); + BuglePrefs.getSubscriptionPrefs(mSubId).putBoolean( + mContext.getString(R.string.group_mms_pref_key), enable); + mDialog.dismiss(); + } + + private View createView() { + final LayoutInflater inflater = (LayoutInflater) mContext + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + final View rootView = inflater.inflate(R.layout.group_mms_setting_dialog, null, false); + final RadioButton disableButton = (RadioButton) + rootView.findViewById(R.id.disable_group_mms_button); + final RadioButton enableButton = (RadioButton) + rootView.findViewById(R.id.enable_group_mms_button); + disableButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + changeGroupMmsSettings(false); + } + }); + enableButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + changeGroupMmsSettings(true); + } + }); + final boolean mmsEnabled = BuglePrefs.getSubscriptionPrefs(mSubId).getBoolean( + mContext.getString(R.string.group_mms_pref_key), + mContext.getResources().getBoolean(R.bool.group_mms_pref_default)); + enableButton.setChecked(mmsEnabled); + disableButton.setChecked(!mmsEnabled); + return rootView; + } +} diff --git a/src/com/android/messaging/ui/appsettings/PerSubscriptionSettingsActivity.java b/src/com/android/messaging/ui/appsettings/PerSubscriptionSettingsActivity.java new file mode 100644 index 0000000..e02823f --- /dev/null +++ b/src/com/android/messaging/ui/appsettings/PerSubscriptionSettingsActivity.java @@ -0,0 +1,246 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.appsettings; + +import android.app.FragmentTransaction; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.SharedPreferences.OnSharedPreferenceChangeListener; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.Preference.OnPreferenceClickListener; +import android.preference.PreferenceCategory; +import android.preference.PreferenceFragment; +import android.preference.PreferenceScreen; +import android.support.v4.app.NavUtils; +import android.text.TextUtils; +import android.view.MenuItem; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.ParticipantRefresh; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.ApnDatabase; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.PhoneUtils; + +public class PerSubscriptionSettingsActivity extends BugleActionBarActivity { + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + final String title = getIntent().getStringExtra( + UIIntents.UI_INTENT_EXTRA_PER_SUBSCRIPTION_SETTING_TITLE); + if (!TextUtils.isEmpty(title)) { + getSupportActionBar().setTitle(title); + } else { + // This will fall back to the default title, i.e. "Messaging settings," so No-op. + } + + final FragmentTransaction ft = getFragmentManager().beginTransaction(); + final PerSubscriptionSettingsFragment fragment = new PerSubscriptionSettingsFragment(); + ft.replace(android.R.id.content, fragment); + ft.commit(); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + NavUtils.navigateUpFromSameTask(this); + return true; + } + return super.onOptionsItemSelected(item); + } + + public static class PerSubscriptionSettingsFragment extends PreferenceFragment + implements OnSharedPreferenceChangeListener { + private PhoneNumberPreference mPhoneNumberPreference; + private Preference mGroupMmsPreference; + private String mGroupMmsPrefKey; + private String mPhoneNumberKey; + private int mSubId; + + public PerSubscriptionSettingsFragment() { + // Required empty constructor + } + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Get sub id from launch intent + final Intent intent = getActivity().getIntent(); + Assert.notNull(intent); + mSubId = (intent != null) ? intent.getIntExtra(UIIntents.UI_INTENT_EXTRA_SUB_ID, + ParticipantData.DEFAULT_SELF_SUB_ID) : ParticipantData.DEFAULT_SELF_SUB_ID; + + final BuglePrefs subPrefs = Factory.get().getSubscriptionPrefs(mSubId); + getPreferenceManager().setSharedPreferencesName(subPrefs.getSharedPreferencesName()); + addPreferencesFromResource(R.xml.preferences_per_subscription); + + mPhoneNumberKey = getString(R.string.mms_phone_number_pref_key); + mPhoneNumberPreference = (PhoneNumberPreference) findPreference(mPhoneNumberKey); + final PreferenceCategory advancedCategory = (PreferenceCategory) + findPreference(getString(R.string.advanced_category_pref_key)); + final PreferenceCategory mmsCategory = (PreferenceCategory) + findPreference(getString(R.string.mms_messaging_category_pref_key)); + + mPhoneNumberPreference.setDefaultPhoneNumber( + PhoneUtils.get(mSubId).getCanonicalForSelf(false/*allowOverride*/), mSubId); + + mGroupMmsPrefKey = getString(R.string.group_mms_pref_key); + mGroupMmsPreference = findPreference(mGroupMmsPrefKey); + if (!MmsConfig.get(mSubId).getGroupMmsEnabled()) { + // Always show group messaging setting even if the SIM has no number + // If broadcast sms is selected, the SIM number is not needed + // If group mms is selected, the phone number dialog will popup when message + // is being sent, making sure we will have a self number for group mms. + mmsCategory.removePreference(mGroupMmsPreference); + } else { + mGroupMmsPreference.setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference pref) { + GroupMmsSettingDialog.showDialog(getActivity(), mSubId); + return true; + } + }); + updateGroupMmsPrefSummary(); + } + + if (!MmsConfig.get(mSubId).getSMSDeliveryReportsEnabled()) { + final Preference deliveryReportsPref = findPreference( + getString(R.string.delivery_reports_pref_key)); + mmsCategory.removePreference(deliveryReportsPref); + } + final Preference wirelessAlertPref = findPreference(getString( + R.string.wireless_alerts_key)); + if (!isCellBroadcastAppLinkEnabled()) { + advancedCategory.removePreference(wirelessAlertPref); + } else { + wirelessAlertPref.setOnPreferenceClickListener( + new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(final Preference preference) { + try { + startActivity(UIIntents.get().getWirelessAlertsIntent()); + } catch (final ActivityNotFoundException e) { + // Handle so we shouldn't crash if the wireless alerts + // implementation is broken. + LogUtil.e(LogUtil.BUGLE_TAG, + "Failed to launch wireless alerts activity", e); + } + return true; + } + }); + } + + // Access Point Names (APNs) + final Preference apnsPref = findPreference(getString(R.string.sms_apns_key)); + + if (MmsUtils.useSystemApnTable() && !ApnDatabase.doesDatabaseExist()) { + // Don't remove the ability to edit the local APN prefs if this device lets us + // access the system APN, but we can't find the MCC/MNC in the APN table and we + // created the local APN table in case the MCC/MNC was in there. In other words, + // if the local APN table exists, let the user edit it. + advancedCategory.removePreference(apnsPref); + } else { + final PreferenceScreen apnsScreen = (PreferenceScreen) findPreference( + getString(R.string.sms_apns_key)); + apnsScreen.setIntent(UIIntents.get() + .getApnSettingsIntent(getPreferenceScreen().getContext(), mSubId)); + } + + // We want to disable preferences if we are not the default app, but we do all of the + // above first so that the user sees the correct information on the screen + if (!PhoneUtils.getDefault().isDefaultSmsApp()) { + mGroupMmsPreference.setEnabled(false); + final Preference autoRetrieveMmsPreference = + findPreference(getString(R.string.auto_retrieve_mms_pref_key)); + autoRetrieveMmsPreference.setEnabled(false); + final Preference deliveryReportsPreference = + findPreference(getString(R.string.delivery_reports_pref_key)); + deliveryReportsPreference.setEnabled(false); + } + } + + private boolean isCellBroadcastAppLinkEnabled() { + if (!MmsConfig.get(mSubId).getShowCellBroadcast()) { + return false; + } + try { + final PackageManager pm = getActivity().getPackageManager(); + return pm.getApplicationEnabledSetting(UIIntents.CMAS_COMPONENT) + != PackageManager.COMPONENT_ENABLED_STATE_DISABLED; + } catch (final IllegalArgumentException ignored) { + // CMAS app not installed. + } + return false; + } + + private void updateGroupMmsPrefSummary() { + final boolean groupMmsEnabled = getPreferenceScreen().getSharedPreferences().getBoolean( + mGroupMmsPrefKey, getResources().getBoolean(R.bool.group_mms_pref_default)); + mGroupMmsPreference.setSummary(groupMmsEnabled ? + R.string.enable_group_mms : R.string.disable_group_mms); + } + + @Override + public void onResume() { + super.onResume(); + getPreferenceScreen().getSharedPreferences() + .registerOnSharedPreferenceChangeListener(this); + } + + @Override + public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, + final String key) { + if (key.equals(mGroupMmsPrefKey)) { + updateGroupMmsPrefSummary(); + } else if (key.equals(mPhoneNumberKey)) { + // Save the changed phone number in preferences specific to the sub id + final String newPhoneNumber = mPhoneNumberPreference.getText(); + final BuglePrefs subPrefs = BuglePrefs.getSubscriptionPrefs(mSubId); + if (TextUtils.isEmpty(newPhoneNumber)) { + subPrefs.remove(mPhoneNumberKey); + } else { + subPrefs.putString(getString(R.string.mms_phone_number_pref_key), + newPhoneNumber); + } + // Update the self participants so the new phone number will be reflected + // everywhere in the UI. + ParticipantRefresh.refreshSelfParticipants(); + } + } + + @Override + public void onPause() { + super.onPause(); + getPreferenceScreen().getSharedPreferences() + .unregisterOnSharedPreferenceChangeListener(this); + } + } +} diff --git a/src/com/android/messaging/ui/appsettings/PhoneNumberPreference.java b/src/com/android/messaging/ui/appsettings/PhoneNumberPreference.java new file mode 100644 index 0000000..0c9c018 --- /dev/null +++ b/src/com/android/messaging/ui/appsettings/PhoneNumberPreference.java @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.appsettings; + +import android.content.Context; +import android.preference.EditTextPreference; +import android.support.v4.text.BidiFormatter; +import android.support.v4.text.TextDirectionHeuristicsCompat; +import android.text.InputType; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; + +import com.android.messaging.R; +import com.android.messaging.util.PhoneUtils; + +/** + * Preference that displays a phone number and allows editing via a dialog. + *

+ * A default number can be assigned, which is shown in the preference view and + * used to populate the dialog editor when the preference value is not set. If + * the user sets the preference to a number equivalent to the default, the + * underlying preference is cleared. + */ +public class PhoneNumberPreference extends EditTextPreference { + + private String mDefaultPhoneNumber; + private int mSubId; + + public PhoneNumberPreference(final Context context, final AttributeSet attrs) { + super(context, attrs); + mDefaultPhoneNumber = ""; + } + + public void setDefaultPhoneNumber(final String phoneNumber, final int subscriptionId) { + mDefaultPhoneNumber = phoneNumber; + mSubId = subscriptionId; + } + + @Override + protected void onBindView(final View view) { + // Show the preference value if it's set, or the default number if not. + // If we don't have a default, fall back to a static string (e.g. Unknown). + String value = getText(); + if (TextUtils.isEmpty(value)) { + value = mDefaultPhoneNumber; + } + final String displayValue = (!TextUtils.isEmpty(value)) + ? PhoneUtils.get(mSubId).formatForDisplay(value) + : getContext().getString(R.string.unknown_phone_number_pref_display_value); + final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); + final String phoneNumber = bidiFormatter.unicodeWrap + (displayValue, TextDirectionHeuristicsCompat.LTR); + // Set the value as the summary and let the superclass populate the views + setSummary(phoneNumber); + super.onBindView(view); + } + + @Override + protected void onBindDialogView(final View view) { + super.onBindDialogView(view); + + final String value = getText(); + + // If the preference is empty, populate the EditText with the default number instead. + if (TextUtils.isEmpty(value) && !TextUtils.isEmpty(mDefaultPhoneNumber)) { + final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); + final String phoneNumber = bidiFormatter.unicodeWrap + (PhoneUtils.get(mSubId).getCanonicalBySystemLocale(mDefaultPhoneNumber), + TextDirectionHeuristicsCompat.LTR); + getEditText().setText(phoneNumber); + } + getEditText().setInputType(InputType.TYPE_CLASS_PHONE); + } + + @Override + protected void onDialogClosed(final boolean positiveResult) { + if (positiveResult && mDefaultPhoneNumber != null) { + final String value = getEditText().getText().toString(); + final PhoneUtils phoneUtils = PhoneUtils.get(mSubId); + final String phoneNumber = phoneUtils.getCanonicalBySystemLocale(value); + final String defaultPhoneNumber = phoneUtils.getCanonicalBySystemLocale( + mDefaultPhoneNumber); + + // If the new value is the default, clear the preference. + if (phoneNumber.equals(defaultPhoneNumber)) { + setText(""); + return; + } + } + super.onDialogClosed(positiveResult); + } + + @Override + public void setText(final String text) { + super.setText(text); + + // EditTextPreference doesn't show the value on the preference view, but we do. + // We thus need to force a rebind of the view when a new value is set. + notifyChanged(); + } +} diff --git a/src/com/android/messaging/ui/appsettings/SettingsActivity.java b/src/com/android/messaging/ui/appsettings/SettingsActivity.java new file mode 100644 index 0000000..75266d8 --- /dev/null +++ b/src/com/android/messaging/ui/appsettings/SettingsActivity.java @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.appsettings; + +import android.app.Fragment; +import android.content.Context; +import android.os.Bundle; +import android.support.v4.app.NavUtils; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.SettingsData; +import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener; +import com.android.messaging.datamodel.data.SettingsData.SettingsItem; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; +import com.android.messaging.util.PhoneUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * Shows the "master" settings activity that contains two parts, one for application-wide settings + * (dubbed "General settings"), and one or more for per-subscription settings (dubbed "Messaging + * settings" for single-SIM, and the actual SIM name for multi-SIM). Clicking on either item + * (e.g. "General settings") will open the detail settings activity (ApplicationSettingsActivity + * in this case). + */ +public class SettingsActivity extends BugleActionBarActivity { + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + // Directly open the detailed settings page as the top-level settings activity if this is + // not a multi-SIM device. + if (PhoneUtils.getDefault().getActiveSubscriptionCount() <= 1) { + UIIntents.get().launchApplicationSettingsActivity(this, true /* topLevel */); + finish(); + } else { + getFragmentManager().beginTransaction() + .replace(android.R.id.content, new SettingsFragment()) + .commit(); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + NavUtils.navigateUpFromSameTask(this); + return true; + } + return super.onOptionsItemSelected(item); + } + + public static class SettingsFragment extends Fragment implements SettingsDataListener { + private ListView mListView; + private SettingsListAdapter mAdapter; + private final Binding mBinding = BindingBase.createBinding(this); + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mBinding.bind(DataModel.get().createSettingsData(getActivity(), this)); + mBinding.getData().init(getLoaderManager(), mBinding); + } + + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final View view = inflater.inflate(R.layout.settings_fragment, container, false); + mListView = (ListView) view.findViewById(android.R.id.list); + mAdapter = new SettingsListAdapter(getActivity()); + mListView.setAdapter(mAdapter); + return view; + } + + @Override + public void onDestroy() { + super.onDestroy(); + mBinding.unbind(); + } + + @Override + public void onSelfParticipantDataLoaded(SettingsData data) { + mBinding.ensureBound(data); + mAdapter.setSettingsItems(data.getSettingsItems()); + } + + /** + * An adapter that displays a list of SettingsItem. + */ + private class SettingsListAdapter extends ArrayAdapter { + public SettingsListAdapter(final Context context) { + super(context, R.layout.settings_item_view, new ArrayList()); + } + + public void setSettingsItems(final List newList) { + clear(); + addAll(newList); + notifyDataSetChanged(); + } + + @Override + public View getView(final int position, final View convertView, + final ViewGroup parent) { + View itemView; + if (convertView != null) { + itemView = convertView; + } else { + final LayoutInflater inflater = (LayoutInflater) getContext() + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + itemView = inflater.inflate( + R.layout.settings_item_view, parent, false); + } + final SettingsItem item = getItem(position); + final TextView titleTextView = (TextView) itemView.findViewById(R.id.title); + final TextView subtitleTextView = (TextView) itemView.findViewById(R.id.subtitle); + final String summaryText = item.getDisplayDetail(); + titleTextView.setText(item.getDisplayName()); + if (!TextUtils.isEmpty(summaryText)) { + subtitleTextView.setText(summaryText); + subtitleTextView.setVisibility(View.VISIBLE); + } else { + subtitleTextView.setVisibility(View.GONE); + } + itemView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + switch (item.getType()) { + case SettingsItem.TYPE_GENERAL_SETTINGS: + UIIntents.get().launchApplicationSettingsActivity(getActivity(), + false /* topLevel */); + break; + + case SettingsItem.TYPE_PER_SUBSCRIPTION_SETTINGS: + UIIntents.get().launchPerSubscriptionSettingsActivity(getActivity(), + item.getSubId(), item.getActivityTitle()); + break; + + default: + Assert.fail("unrecognized setting type!"); + break; + } + } + }); + return itemView; + } + } + } +} diff --git a/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserActivity.java b/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserActivity.java new file mode 100644 index 0000000..a540597 --- /dev/null +++ b/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserActivity.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.attachmentchooser; + +import android.app.Fragment; +import android.os.Bundle; + +import com.android.messaging.R; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.attachmentchooser.AttachmentChooserFragment.AttachmentChooserFragmentHost; +import com.android.messaging.util.Assert; + +public class AttachmentChooserActivity extends BugleActionBarActivity implements + AttachmentChooserFragmentHost { + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.attachment_chooser_activity); + getSupportActionBar().setDisplayHomeAsUpEnabled(false); + } + + @Override + public void onAttachFragment(final Fragment fragment) { + if (fragment instanceof AttachmentChooserFragment) { + final String conversationId = + getIntent().getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); + Assert.notNull(conversationId); + final AttachmentChooserFragment chooserFragment = + (AttachmentChooserFragment) fragment; + chooserFragment.setConversationId(conversationId); + chooserFragment.setHost(this); + } + } + + @Override + public void onConfirmSelection() { + setResult(RESULT_OK); + finish(); + } +} diff --git a/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserFragment.java b/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserFragment.java new file mode 100644 index 0000000..b39dc3e --- /dev/null +++ b/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserFragment.java @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.attachmentchooser; + +import android.app.Fragment; +import android.content.Context; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.attachmentchooser.AttachmentGridView.AttachmentGridHost; +import com.google.common.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.List; + +public class AttachmentChooserFragment extends Fragment implements DraftMessageDataListener, + AttachmentGridHost { + public interface AttachmentChooserFragmentHost { + void onConfirmSelection(); + } + + private AttachmentGridView mAttachmentGridView; + private AttachmentGridAdapter mAdapter; + private AttachmentChooserFragmentHost mHost; + + @VisibleForTesting + Binding mBinding = BindingBase.createBinding(this); + + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final View view = inflater.inflate(R.layout.attachment_chooser_fragment, container, false); + mAttachmentGridView = (AttachmentGridView) view.findViewById(R.id.grid); + mAdapter = new AttachmentGridAdapter(getActivity()); + mAttachmentGridView.setAdapter(mAdapter); + mAttachmentGridView.setHost(this); + setHasOptionsMenu(true); + return view; + } + + @Override + public void onDestroy() { + super.onDestroy(); + mBinding.unbind(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + inflater.inflate(R.menu.attachment_chooser_menu, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.action_confirm_selection: + confirmSelection(); + return true; + + default: + return super.onOptionsItemSelected(item); + } + } + + @VisibleForTesting + void confirmSelection() { + if (mBinding.isBound()) { + mBinding.getData().removeExistingAttachments( + mAttachmentGridView.getUnselectedAttachments()); + mBinding.getData().saveToStorage(mBinding); + mHost.onConfirmSelection(); + } + } + + public void setConversationId(final String conversationId) { + mBinding.bind(DataModel.get().createDraftMessageData(conversationId)); + mBinding.getData().addListener(this); + mBinding.getData().loadFromStorage(mBinding, null, false); + } + + public void setHost(final AttachmentChooserFragmentHost host) { + mHost = host; + } + + @Override + public void onDraftChanged(final DraftMessageData data, final int changeFlags) { + mBinding.ensureBound(data); + if ((changeFlags & DraftMessageData.ATTACHMENTS_CHANGED) == + DraftMessageData.ATTACHMENTS_CHANGED) { + mAdapter.onAttachmentsLoaded(data.getReadOnlyAttachments()); + } + } + + @Override + public void onDraftAttachmentLimitReached(final DraftMessageData data) { + // Do nothing since the user is in the process of unselecting attachments. + } + + @Override + public void onDraftAttachmentLoadFailed() { + // Do nothing since the user is in the process of unselecting attachments. + } + + @Override + public void displayPhoto(final Rect viewRect, final Uri photoUri) { + final Uri imagesUri = MessagingContentProvider.buildDraftImagesUri( + mBinding.getData().getConversationId()); + UIIntents.get().launchFullScreenPhotoViewer( + getActivity(), photoUri, viewRect, imagesUri); + } + + @Override + public void updateSelectionCount(int count) { + if (getActivity() instanceof BugleActionBarActivity) { + final ActionBar actionBar = ((BugleActionBarActivity) getActivity()) + .getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle(getResources().getString( + R.string.attachment_chooser_selection, count)); + } + } + } + + class AttachmentGridAdapter extends ArrayAdapter { + public AttachmentGridAdapter(final Context context) { + super(context, R.layout.attachment_grid_item_view, new ArrayList()); + } + + public void onAttachmentsLoaded(final List attachments) { + clear(); + addAll(attachments); + notifyDataSetChanged(); + } + + @Override + public View getView(final int position, final View convertView, final ViewGroup parent) { + AttachmentGridItemView itemView; + final MessagePartData item = getItem(position); + if (convertView != null && convertView instanceof AttachmentGridItemView) { + itemView = (AttachmentGridItemView) convertView; + } else { + final LayoutInflater inflater = (LayoutInflater) getContext() + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + itemView = (AttachmentGridItemView) inflater.inflate( + R.layout.attachment_grid_item_view, parent, false); + } + itemView.bind(item, mAttachmentGridView); + return itemView; + } + } +} diff --git a/src/com/android/messaging/ui/attachmentchooser/AttachmentGridItemView.java b/src/com/android/messaging/ui/attachmentchooser/AttachmentGridItemView.java new file mode 100644 index 0000000..8bb7356 --- /dev/null +++ b/src/com/android/messaging/ui/attachmentchooser/AttachmentGridItemView.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.attachmentchooser; + +import android.content.Context; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.TouchDelegate; +import android.view.View; +import android.widget.CheckBox; +import android.widget.FrameLayout; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.ui.AttachmentPreviewFactory; +import com.android.messaging.util.Assert; +import com.google.common.annotations.VisibleForTesting; + +/** + * Shows an item in the attachment picker grid. + */ +public class AttachmentGridItemView extends FrameLayout { + public interface HostInterface { + boolean isItemSelected(MessagePartData attachment); + void onItemCheckedChanged(AttachmentGridItemView view, MessagePartData attachment); + void onItemClicked(AttachmentGridItemView view, MessagePartData attachment); + } + + @VisibleForTesting + MessagePartData mAttachmentData; + private FrameLayout mAttachmentViewContainer; + private CheckBox mCheckBox; + private HostInterface mHostInterface; + + public AttachmentGridItemView(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mAttachmentViewContainer = (FrameLayout) findViewById(R.id.attachment_container); + mCheckBox = (CheckBox) findViewById(R.id.checkbox); + mCheckBox.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View v) { + mHostInterface.onItemCheckedChanged(AttachmentGridItemView.this, mAttachmentData); + } + }); + setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View v) { + mHostInterface.onItemClicked(AttachmentGridItemView.this, mAttachmentData); + } + }); + addOnLayoutChangeListener(new OnLayoutChangeListener() { + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, + int oldLeft, int oldTop, int oldRight, int oldBottom) { + // Enlarge the clickable region for the checkbox. + final int touchAreaIncrease = getResources().getDimensionPixelOffset( + R.dimen.attachment_grid_checkbox_area_increase); + final Rect region = new Rect(); + mCheckBox.getHitRect(region); + region.inset(-touchAreaIncrease, -touchAreaIncrease); + setTouchDelegate(new TouchDelegate(region, mCheckBox)); + } + }); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + // The grid view auto-fits the columns, so we want to let the height match the width + // to make the attachment preview square. + super.onMeasure(widthMeasureSpec, widthMeasureSpec); + } + + public void bind(final MessagePartData attachment, final HostInterface hostInterface) { + Assert.isTrue(attachment.isAttachment()); + mHostInterface = hostInterface; + updateSelectedState(); + if (mAttachmentData == null || !mAttachmentData.equals(attachment)) { + mAttachmentData = attachment; + updateAttachmentView(); + } + } + + @VisibleForTesting + HostInterface testGetHostInterface() { + return mHostInterface; + } + + public void updateSelectedState() { + mCheckBox.setChecked(mHostInterface.isItemSelected(mAttachmentData)); + } + + private void updateAttachmentView() { + mAttachmentViewContainer.removeAllViews(); + final LayoutInflater inflater = LayoutInflater.from(getContext()); + final View attachmentView = AttachmentPreviewFactory.createAttachmentPreview(inflater, + mAttachmentData, mAttachmentViewContainer, + AttachmentPreviewFactory.TYPE_CHOOSER_GRID, true /* startImageRequest */, null); + mAttachmentViewContainer.addView(attachmentView); + } +} diff --git a/src/com/android/messaging/ui/attachmentchooser/AttachmentGridView.java b/src/com/android/messaging/ui/attachmentchooser/AttachmentGridView.java new file mode 100644 index 0000000..abf61dc --- /dev/null +++ b/src/com/android/messaging/ui/attachmentchooser/AttachmentGridView.java @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.attachmentchooser; + +import android.content.Context; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.widget.GridView; + +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.ui.attachmentchooser.AttachmentChooserFragment.AttachmentGridAdapter; +import com.android.messaging.util.Assert; +import com.android.messaging.util.UiUtils; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Displays a grid of attachment previews for the user to choose which to select/unselect + */ +public class AttachmentGridView extends GridView implements + AttachmentGridItemView.HostInterface { + public interface AttachmentGridHost { + void displayPhoto(final Rect viewRect, final Uri photoUri); + void updateSelectionCount(final int count); + } + + // By default everything is selected so only need to keep track of the unselected set. + private final Set mUnselectedSet; + private AttachmentGridHost mHost; + + public AttachmentGridView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mUnselectedSet = new HashSet<>(); + } + + public void setHost(final AttachmentGridHost host) { + mHost = host; + } + + @Override + public boolean isItemSelected(final MessagePartData attachment) { + return !mUnselectedSet.contains(attachment); + } + + @Override + public void onItemClicked(final AttachmentGridItemView view, final MessagePartData attachment) { + // If the item is an image, show the photo viewer. All the other types (video, audio, + // vcard) have internal click handling for showing previews so we don't need to handle them + if (attachment.isImage()) { + mHost.displayPhoto(UiUtils.getMeasuredBoundsOnScreen(view), attachment.getContentUri()); + } + } + + @Override + public void onItemCheckedChanged(AttachmentGridItemView view, MessagePartData attachment) { + // Toggle selection. + if (isItemSelected(attachment)) { + mUnselectedSet.add(attachment); + } else { + mUnselectedSet.remove(attachment); + } + view.updateSelectedState(); + updateSelectionCount(); + } + + public Set getUnselectedAttachments() { + return Collections.unmodifiableSet(mUnselectedSet); + } + + private void updateSelectionCount() { + final int count = getAdapter().getCount() - mUnselectedSet.size(); + Assert.isTrue(count >= 0); + mHost.updateSelectionCount(count); + } + + private void refreshViews() { + if (getAdapter() instanceof AttachmentGridAdapter) { + ((AttachmentGridAdapter) getAdapter()).notifyDataSetChanged(); + } + } + + @Override + public Parcelable onSaveInstanceState() { + final Parcelable superState = super.onSaveInstanceState(); + final SavedState savedState = new SavedState(superState); + savedState.unselectedParts = mUnselectedSet + .toArray(new MessagePartData[mUnselectedSet.size()]); + return savedState; + } + + @Override + public void onRestoreInstanceState(final Parcelable state) { + if (!(state instanceof SavedState)) { + super.onRestoreInstanceState(state); + return; + } + + final SavedState savedState = (SavedState) state; + super.onRestoreInstanceState(savedState.getSuperState()); + mUnselectedSet.clear(); + for (int i = 0; i < savedState.unselectedParts.length; i++) { + final MessagePartData unselectedPart = savedState.unselectedParts[i]; + mUnselectedSet.add(unselectedPart); + } + refreshViews(); + } + + /** + * Persists the item selection state to saved instance state so we can restore on activity + * recreation + */ + public static class SavedState extends BaseSavedState { + MessagePartData[] unselectedParts; + + SavedState(final Parcelable superState) { + super(superState); + } + + private SavedState(final Parcel in) { + super(in); + + // Read parts + final int partCount = in.readInt(); + unselectedParts = new MessagePartData[partCount]; + for (int i = 0; i < partCount; i++) { + unselectedParts[i] = ((MessagePartData) in.readParcelable( + MessagePartData.class.getClassLoader())); + } + } + + @Override + public void writeToParcel(final Parcel out, final int flags) { + super.writeToParcel(out, flags); + + // Write parts + out.writeInt(unselectedParts.length); + for (final MessagePartData image : unselectedParts) { + out.writeParcelable(image, flags); + } + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + @Override + public SavedState createFromParcel(final Parcel in) { + return new SavedState(in); + } + @Override + public SavedState[] newArray(final int size) { + return new SavedState[size]; + } + }; + } +} diff --git a/src/com/android/messaging/ui/contact/AddContactsConfirmationDialog.java b/src/com/android/messaging/ui/contact/AddContactsConfirmationDialog.java new file mode 100644 index 0000000..9c1393d --- /dev/null +++ b/src/com/android/messaging/ui/contact/AddContactsConfirmationDialog.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.contact; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.res.Resources; +import android.net.Uri; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.AccessibilityUtil; + +public class AddContactsConfirmationDialog implements DialogInterface.OnClickListener { + private final Context mContext; + private final Uri mAvatarUri; + private final String mNormalizedDestination; + + public AddContactsConfirmationDialog(final Context context, final Uri avatarUri, + final String normalizedDestination) { + mContext = context; + mAvatarUri = avatarUri; + mNormalizedDestination = normalizedDestination; + } + + public void show() { + final int confirmAddContactStringId = R.string.add_contact_confirmation; + final int cancelStringId = android.R.string.cancel; + final AlertDialog alertDialog = new AlertDialog.Builder(mContext) + .setTitle(R.string.add_contact_confirmation_dialog_title) + .setView(createBodyView()) + .setPositiveButton(confirmAddContactStringId, this) + .setNegativeButton(cancelStringId, null) + .create(); + alertDialog.show(); + final Resources resources = mContext.getResources(); + final Button cancelButton = alertDialog.getButton(DialogInterface.BUTTON_NEGATIVE); + if (cancelButton != null) { + cancelButton.setTextColor(resources.getColor(R.color.contact_picker_button_text_color)); + } + final Button addButton = alertDialog.getButton(DialogInterface.BUTTON_POSITIVE); + if (addButton != null) { + addButton.setTextColor(resources.getColor(R.color.contact_picker_button_text_color)); + } + } + + @Override + public void onClick(final DialogInterface dialog, final int which) { + UIIntents.get().launchAddContactActivity(mContext, mNormalizedDestination); + } + + private View createBodyView() { + final View view = LayoutInflater.from(mContext).inflate( + R.layout.add_contacts_confirmation_dialog_body, null); + final ContactIconView iconView = (ContactIconView) view.findViewById(R.id.contact_icon); + iconView.setImageResourceUri(mAvatarUri); + final TextView textView = (TextView) view.findViewById(R.id.participant_name); + textView.setText(mNormalizedDestination); + // Accessibility reason : in case phone numbers are mixed in the display name, + // we need to vocalize it for talkback. + final String vocalizedDisplayName = AccessibilityUtil.getVocalizedPhoneNumber( + mContext.getResources(), mNormalizedDestination); + textView.setContentDescription(vocalizedDisplayName); + return view; + } +} diff --git a/src/com/android/messaging/ui/contact/AllContactsListViewHolder.java b/src/com/android/messaging/ui/contact/AllContactsListViewHolder.java new file mode 100644 index 0000000..7263c54 --- /dev/null +++ b/src/com/android/messaging/ui/contact/AllContactsListViewHolder.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.contact; + +import android.content.Context; + +import com.android.messaging.R; +import com.android.messaging.ui.CustomHeaderPagerListViewHolder; +import com.android.messaging.ui.contact.ContactListItemView.HostInterface; + +/** + * Holds the all contacts view for the contact picker's view pager. + */ +public class AllContactsListViewHolder extends CustomHeaderPagerListViewHolder { + public AllContactsListViewHolder(final Context context, final HostInterface clivHostInterface) { + super(context, new ContactListAdapter(context, null, clivHostInterface, + true /* needAlphabetHeader */)); + } + + @Override + protected int getLayoutResId() { + return R.layout.all_contacts_list_view; + } + + @Override + protected int getPageTitleResId() { + return R.string.contact_picker_all_contacts_tab_title; + } + + @Override + protected int getEmptyViewResId() { + return R.id.empty_view; + } + + @Override + protected int getListViewResId() { + return R.id.all_contacts_list; + } + + @Override + protected int getEmptyViewTitleResId() { + return R.string.contact_list_empty_text; + } + + @Override + protected int getEmptyViewImageResId() { + return R.drawable.ic_oobe_freq_list; + } +} diff --git a/src/com/android/messaging/ui/contact/ContactDropdownLayouter.java b/src/com/android/messaging/ui/contact/ContactDropdownLayouter.java new file mode 100644 index 0000000..7df62de --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactDropdownLayouter.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.contact; + +import android.content.Context; +import android.graphics.drawable.StateListDrawable; +import android.net.Uri; +import android.support.v4.text.BidiFormatter; +import android.support.v4.text.TextDirectionHeuristicsCompat; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import com.android.ex.chips.DropdownChipLayouter; +import com.android.ex.chips.RecipientEntry; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ContactListItemData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ContactRecipientEntryUtils; + +/** + * An implementation for {@link DropdownChipLayouter}. Layouts the dropdown + * list in the ContactRecipientAutoCompleteView in Material style. + */ +public class ContactDropdownLayouter extends DropdownChipLayouter { + private final ContactListItemView.HostInterface mClivHostInterface; + + public ContactDropdownLayouter(final LayoutInflater inflater, final Context context, + final ContactListItemView.HostInterface clivHostInterface) { + super(inflater, context); + mClivHostInterface = new ContactListItemView.HostInterface() { + + @Override + public void onContactListItemClicked(final ContactListItemData item, + final ContactListItemView view) { + // The chips UI will handle auto-complete item click events, so No-op here. + } + + @Override + public boolean isContactSelected(final ContactListItemData item) { + // In chips drop down we don't show any selected checkmark per design. + return false; + } + }; + } + + /** + * Bind a drop down view to a RecipientEntry. We'd like regular dropdown items (BASE_RECIPIENT) + * to behave the same as regular ContactListItemViews, while using the chips library's + * item styling for alternates dropdown items (happens when you click on a chip). + */ + @Override + public View bindView(final View convertView, final ViewGroup parent, final RecipientEntry entry, + final int position, AdapterType type, final String substring, + final StateListDrawable deleteDrawable) { + if (type != AdapterType.BASE_RECIPIENT) { + if (type == AdapterType.SINGLE_RECIPIENT) { + // Treat single recipients the same way as alternates. The base implementation of + // single recipients would try to simplify the destination by tokenizing. We'd + // like to always show the full destination address per design request. + type = AdapterType.RECIPIENT_ALTERNATES; + } + return super.bindView(convertView, parent, entry, position, type, substring, + deleteDrawable); + } + + // Default to show all the information + // RTL : To format contact name and detail if they happen to be phone numbers. + final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); + final String displayName = bidiFormatter.unicodeWrap( + ContactRecipientEntryUtils.getDisplayNameForContactList(entry), + TextDirectionHeuristicsCompat.LTR); + final String destination = bidiFormatter.unicodeWrap( + ContactRecipientEntryUtils.formatDestination(entry), + TextDirectionHeuristicsCompat.LTR); + final View itemView = reuseOrInflateView(convertView, parent, type); + + // Bold the string that is matched. + final CharSequence[] styledResults = + getStyledResults(substring, displayName, destination); + + Assert.isTrue(itemView instanceof ContactListItemView); + final ContactListItemView contactListItemView = (ContactListItemView) itemView; + contactListItemView.setImageClickHandlerDisabled(true); + contactListItemView.bind(entry, styledResults[0], styledResults[1], + mClivHostInterface, (type == AdapterType.SINGLE_RECIPIENT)); + return itemView; + } + + @Override + protected void bindIconToView(boolean showImage, RecipientEntry entry, ImageView view, + AdapterType type) { + if (showImage && view instanceof ContactIconView) { + final ContactIconView contactView = (ContactIconView) view; + // These show contact cards by default, but that isn't what we want here + contactView.setImageClickHandlerDisabled(true); + final Uri avatarUri = AvatarUriUtil.createAvatarUri( + ParticipantData.getFromRecipientEntry(entry)); + contactView.setImageResourceUri(avatarUri); + } else { + super.bindIconToView(showImage, entry, view, type); + } + } + + @Override + protected int getItemLayoutResId(AdapterType type) { + switch (type) { + case BASE_RECIPIENT: + return R.layout.contact_list_item_view; + case RECIPIENT_ALTERNATES: + return R.layout.chips_alternates_dropdown_item; + default: + return R.layout.chips_alternates_dropdown_item; + } + } + + @Override + protected int getAlternateItemLayoutResId(AdapterType type) { + return getItemLayoutResId(type); + } +} diff --git a/src/com/android/messaging/ui/contact/ContactListAdapter.java b/src/com/android/messaging/ui/contact/ContactListAdapter.java new file mode 100644 index 0000000..d466b61 --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactListAdapter.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.contact; + +import android.content.Context; +import android.database.Cursor; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CursorAdapter; +import android.widget.SectionIndexer; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; + +public class ContactListAdapter extends CursorAdapter implements SectionIndexer { + private final ContactListItemView.HostInterface mClivHostInterface; + private final boolean mNeedAlphabetHeader; + private ContactSectionIndexer mSectionIndexer; + + public ContactListAdapter(final Context context, final Cursor cursor, + final ContactListItemView.HostInterface clivHostInterface, + final boolean needAlphabetHeader) { + super(context, cursor, 0); + mClivHostInterface = clivHostInterface; + mNeedAlphabetHeader = needAlphabetHeader; + mSectionIndexer = new ContactSectionIndexer(cursor); + } + + @Override + public void bindView(final View view, final Context context, final Cursor cursor) { + Assert.isTrue(view instanceof ContactListItemView); + final ContactListItemView contactListItemView = (ContactListItemView) view; + String alphabetHeader = null; + if (mNeedAlphabetHeader) { + final int position = cursor.getPosition(); + final int section = mSectionIndexer.getSectionForPosition(position); + // Check if the position is the first in the section. + if (mSectionIndexer.getPositionForSection(section) == position) { + alphabetHeader = (String) mSectionIndexer.getSections()[section]; + } + } + contactListItemView.bind(cursor, mClivHostInterface, mNeedAlphabetHeader, alphabetHeader); + } + + @Override + public View newView(final Context context, final Cursor cursor, final ViewGroup parent) { + final LayoutInflater layoutInflater = LayoutInflater.from(context); + return layoutInflater.inflate(R.layout.contact_list_item_view, parent, false); + } + + @Override + public Cursor swapCursor(final Cursor newCursor) { + mSectionIndexer = new ContactSectionIndexer(newCursor); + return super.swapCursor(newCursor); + } + + @Override + public Object[] getSections() { + return mSectionIndexer.getSections(); + } + + @Override + public int getPositionForSection(final int sectionIndex) { + return mSectionIndexer.getPositionForSection(sectionIndex); + } + + @Override + public int getSectionForPosition(final int position) { + return mSectionIndexer.getSectionForPosition(position); + } +} diff --git a/src/com/android/messaging/ui/contact/ContactListItemView.java b/src/com/android/messaging/ui/contact/ContactListItemView.java new file mode 100644 index 0000000..6904da6 --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactListItemView.java @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.contact; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.util.AttributeSet; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.android.ex.chips.RecipientEntry; +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.data.ContactListItemData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.google.common.annotations.VisibleForTesting; + +/** + * The view for a single entry in a contact list. + */ +public class ContactListItemView extends LinearLayout implements OnClickListener { + public interface HostInterface { + void onContactListItemClicked(ContactListItemData item, ContactListItemView view); + boolean isContactSelected(ContactListItemData item); + } + + @VisibleForTesting + final ContactListItemData mData; + private TextView mContactNameTextView; + private TextView mContactDetailsTextView; + private TextView mContactDetailTypeTextView; + private TextView mAlphabetHeaderTextView; + private ContactIconView mContactIconView; + private ImageView mContactCheckmarkView; + private HostInterface mHostInterface; + private boolean mShouldShowAlphabetHeader; + + public ContactListItemView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mData = DataModel.get().createContactListItemData(); + } + + @Override + protected void onFinishInflate () { + mContactNameTextView = (TextView) findViewById(R.id.contact_name); + mContactDetailsTextView = (TextView) findViewById(R.id.contact_details); + mContactDetailTypeTextView = (TextView) findViewById(R.id.contact_detail_type); + mAlphabetHeaderTextView = (TextView) findViewById(R.id.alphabet_header); + mContactIconView = (ContactIconView) findViewById(R.id.contact_icon); + mContactCheckmarkView = (ImageView) findViewById(R.id.contact_checkmark); + } + + /** + * Fills in the data associated with this view by binding to a contact cursor provided by + * ContactUtil. + * @param cursor the contact cursor. + * @param hostInterface host interface to this view. + * @param shouldShowAlphabetHeader whether an alphabetical header should shown on the side + * of this view. If {@code headerLabel} is empty, we will still leave space for it. + * @param headerLabel the alphabetical header on the side of this view, if it should be shown. + */ + public void bind(final Cursor cursor, final HostInterface hostInterface, + final boolean shouldShowAlphabetHeader, final String headerLabel) { + mData.bind(cursor, headerLabel); + mHostInterface = hostInterface; + mShouldShowAlphabetHeader = shouldShowAlphabetHeader; + setOnClickListener(this); + updateViewAppearance(); + } + + /** + * Binds a RecipientEntry. This is used by the chips text view's dropdown layout. + * @param recipientEntry the source RecipientEntry provided by ContactDropdownLayouter, which + * was in turn directly from one of the existing chips, or from filtered results + * generated by ContactRecipientAdapter. + * @param styledName display name where the portion that matches the search text is bold. + * @param styledDestination number where the portion that matches the search text is bold. + * @param hostInterface host interface to this view. + * @param isSingleRecipient whether this item is shown as the only line item in the single + * recipient drop down from the chips view. If this is the case, we always show the + * contact avatar even if it's not a first-level entry. + */ + public void bind(final RecipientEntry recipientEntry, final CharSequence styledName, + final CharSequence styledDestination, final HostInterface hostInterface, + final boolean isSingleRecipient) { + mData.bind(recipientEntry, styledName, styledDestination, isSingleRecipient); + mHostInterface = hostInterface; + mShouldShowAlphabetHeader = false; + updateViewAppearance(); + } + + private void updateViewAppearance() { + mContactNameTextView.setText(mData.getDisplayName()); + mContactDetailsTextView.setText(mData.getDestination()); + mContactDetailTypeTextView.setText(Phone.getTypeLabel(getResources(), + mData.getDestinationType(), mData.getDestinationLabel())); + final RecipientEntry recipientEntry = mData.getRecipientEntry(); + final String destinationString = String.valueOf(mData.getDestination()); + if (mData.getIsSimpleContactItem()) { + // This is a special number-with-avatar type of contact (for unknown contact chips + // and for direct "send to destination" item). In this case, make sure we only show + // the display name (phone number) and the avatar and hide everything else. + final Uri avatarUri = AvatarUriUtil.createAvatarUri( + ParticipantData.getFromRecipientEntry(recipientEntry)); + mContactIconView.setImageResourceUri(avatarUri, mData.getContactId(), + mData.getLookupKey(), destinationString); + mContactIconView.setVisibility(VISIBLE); + mContactCheckmarkView.setVisibility(GONE); + mContactDetailTypeTextView.setVisibility(GONE); + mContactDetailsTextView.setVisibility(GONE); + mContactNameTextView.setVisibility(VISIBLE); + } else if (mData.getIsFirstLevel()) { + final Uri avatarUri = AvatarUriUtil.createAvatarUri( + ParticipantData.getFromRecipientEntry(recipientEntry)); + mContactIconView.setImageResourceUri(avatarUri, mData.getContactId(), + mData.getLookupKey(), destinationString); + mContactIconView.setVisibility(VISIBLE); + mContactNameTextView.setVisibility(VISIBLE); + final boolean isSelected = mHostInterface.isContactSelected(mData); + setSelected(isSelected); + mContactCheckmarkView.setVisibility(isSelected ? VISIBLE : GONE); + mContactDetailsTextView.setVisibility(VISIBLE); + mContactDetailTypeTextView.setVisibility(VISIBLE); + } else { + mContactIconView.setImageResourceUri(null); + mContactIconView.setVisibility(INVISIBLE); + mContactNameTextView.setVisibility(GONE); + final boolean isSelected = mHostInterface.isContactSelected(mData); + setSelected(isSelected); + mContactCheckmarkView.setVisibility(isSelected ? VISIBLE : GONE); + mContactDetailsTextView.setVisibility(VISIBLE); + mContactDetailTypeTextView.setVisibility(VISIBLE); + } + + if (mShouldShowAlphabetHeader) { + mAlphabetHeaderTextView.setVisibility(VISIBLE); + mAlphabetHeaderTextView.setText(mData.getAlphabetHeader()); + } else { + mAlphabetHeaderTextView.setVisibility(GONE); + } + } + + /** + * {@inheritDoc} from OnClickListener + */ + @Override + public void onClick(final View v) { + Assert.isTrue(v == this); + Assert.isTrue(mHostInterface != null); + mHostInterface.onContactListItemClicked(mData, this); + } + + public void setImageClickHandlerDisabled(final boolean isHandlerDisabled) { + mContactIconView.setImageClickHandlerDisabled(isHandlerDisabled); + } +} diff --git a/src/com/android/messaging/ui/contact/ContactPickerFragment.java b/src/com/android/messaging/ui/contact/ContactPickerFragment.java new file mode 100644 index 0000000..d803087 --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactPickerFragment.java @@ -0,0 +1,607 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.contact; + +import android.app.Activity; +import android.app.Fragment; +import android.database.Cursor; +import android.graphics.Rect; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.support.v7.widget.Toolbar; +import android.support.v7.widget.Toolbar.OnMenuItemClickListener; +import android.text.Editable; +import android.text.InputType; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.transition.Explode; +import android.transition.Transition; +import android.transition.Transition.EpicenterCallback; +import android.transition.TransitionManager; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.action.ActionMonitor; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionListener; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionMonitor; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.ContactListItemData; +import com.android.messaging.datamodel.data.ContactPickerData; +import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataListener; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.ui.CustomHeaderPagerViewHolder; +import com.android.messaging.ui.CustomHeaderViewPager; +import com.android.messaging.ui.animation.ViewGroupItemVerticalExplodeAnimation; +import com.android.messaging.ui.contact.ContactRecipientAutoCompleteView.ContactChipsChangeListener; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.RunsOnMainThread; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.ImeUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.UiUtils; +import com.google.common.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.Set; + + +/** + * Shows lists of contacts to start conversations with. + */ +public class ContactPickerFragment extends Fragment implements ContactPickerDataListener, + ContactListItemView.HostInterface, ContactChipsChangeListener, OnMenuItemClickListener, + GetOrCreateConversationActionListener { + public static final String FRAGMENT_TAG = "contactpicker"; + + // Undefined contact picker mode. We should never be in this state after the host activity has + // been created. + public static final int MODE_UNDEFINED = 0; + + // The initial contact picker mode for starting a new conversation with one contact. + public static final int MODE_PICK_INITIAL_CONTACT = 1; + + // The contact picker mode where one initial contact has been picked and we are showing + // only the chips edit box. + public static final int MODE_CHIPS_ONLY = 2; + + // The contact picker mode for picking more contacts after starting the initial 1-1. + public static final int MODE_PICK_MORE_CONTACTS = 3; + + // The contact picker mode when max number of participants is reached. + public static final int MODE_PICK_MAX_PARTICIPANTS = 4; + + public interface ContactPickerFragmentHost { + void onGetOrCreateNewConversation(String conversationId); + void onBackButtonPressed(); + void onInitiateAddMoreParticipants(); + void onParticipantCountChanged(boolean canAddMoreParticipants); + void invalidateActionBar(); + } + + @VisibleForTesting + final Binding mBinding = BindingBase.createBinding(this); + + private ContactPickerFragmentHost mHost; + private ContactRecipientAutoCompleteView mRecipientTextView; + private CustomHeaderViewPager mCustomHeaderViewPager; + private AllContactsListViewHolder mAllContactsListViewHolder; + private FrequentContactsListViewHolder mFrequentContactsListViewHolder; + private View mRootView; + private View mPendingExplodeView; + private View mComposeDivider; + private Toolbar mToolbar; + private int mContactPickingMode = MODE_UNDEFINED; + + // Keeps track of the currently selected phone numbers in the chips view to enable fast lookup. + private Set mSelectedPhoneNumbers = null; + + /** + * {@inheritDoc} from Fragment + */ + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mAllContactsListViewHolder = new AllContactsListViewHolder(getActivity(), this); + mFrequentContactsListViewHolder = new FrequentContactsListViewHolder(getActivity(), this); + + if (ContactUtil.hasReadContactsPermission()) { + mBinding.bind(DataModel.get().createContactPickerData(getActivity(), this)); + mBinding.getData().init(getLoaderManager(), mBinding); + } + } + + /** + * {@inheritDoc} from Fragment + */ + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final View view = inflater.inflate(R.layout.contact_picker_fragment, container, false); + mRecipientTextView = (ContactRecipientAutoCompleteView) + view.findViewById(R.id.recipient_text_view); + mRecipientTextView.setThreshold(0); + mRecipientTextView.setDropDownAnchor(R.id.compose_contact_divider); + + mRecipientTextView.setContactChipsListener(this); + mRecipientTextView.setDropdownChipLayouter(new ContactDropdownLayouter(inflater, + getActivity(), this)); + mRecipientTextView.setAdapter(new ContactRecipientAdapter(getActivity(), this)); + mRecipientTextView.addTextChangedListener(new TextWatcher() { + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, + final int count) { + } + + @Override + public void beforeTextChanged(final CharSequence s, final int start, final int count, + final int after) { + } + + @Override + public void afterTextChanged(final Editable s) { + updateTextInputButtonsVisibility(); + } + }); + + final CustomHeaderPagerViewHolder[] viewHolders = { + mFrequentContactsListViewHolder, + mAllContactsListViewHolder }; + + mCustomHeaderViewPager = (CustomHeaderViewPager) view.findViewById(R.id.contact_pager); + mCustomHeaderViewPager.setViewHolders(viewHolders); + mCustomHeaderViewPager.setViewPagerTabHeight(CustomHeaderViewPager.DEFAULT_TAB_STRIP_SIZE); + mCustomHeaderViewPager.setBackgroundColor(getResources() + .getColor(R.color.contact_picker_background)); + + // The view pager defaults to the frequent contacts page. + mCustomHeaderViewPager.setCurrentItem(0); + + mToolbar = (Toolbar) view.findViewById(R.id.toolbar); + mToolbar.setNavigationIcon(R.drawable.ic_arrow_back_light); + mToolbar.setNavigationContentDescription(R.string.back); + mToolbar.setNavigationOnClickListener(new OnClickListener() { + @Override + public void onClick(final View v) { + mHost.onBackButtonPressed(); + } + }); + + mToolbar.inflateMenu(R.menu.compose_menu); + mToolbar.setOnMenuItemClickListener(this); + + mComposeDivider = view.findViewById(R.id.compose_contact_divider); + mRootView = view; + return view; + } + + /** + * {@inheritDoc} + * + * Called when the host activity has been created. At this point, the host activity should + * have set the contact picking mode for us so that we may update our visuals. + */ + @Override + public void onActivityCreated(final Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + Assert.isTrue(mContactPickingMode != MODE_UNDEFINED); + updateVisualsForContactPickingMode(false /* animate */); + mHost.invalidateActionBar(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + // We could not have bound to the data if the permission was denied. + if (mBinding.isBound()) { + mBinding.unbind(); + } + + if (mMonitor != null) { + mMonitor.unregister(); + } + mMonitor = null; + } + + @Override + public boolean onMenuItemClick(final MenuItem menuItem) { + switch (menuItem.getItemId()) { + case R.id.action_ime_dialpad_toggle: + final int baseInputType = InputType.TYPE_TEXT_FLAG_MULTI_LINE; + if ((mRecipientTextView.getInputType() & InputType.TYPE_CLASS_PHONE) != + InputType.TYPE_CLASS_PHONE) { + mRecipientTextView.setInputType(baseInputType | InputType.TYPE_CLASS_PHONE); + menuItem.setIcon(R.drawable.ic_ime_light); + } else { + mRecipientTextView.setInputType(baseInputType | InputType.TYPE_CLASS_TEXT); + menuItem.setIcon(R.drawable.ic_numeric_dialpad); + } + ImeUtil.get().showImeKeyboard(getActivity(), mRecipientTextView); + return true; + + case R.id.action_add_more_participants: + mHost.onInitiateAddMoreParticipants(); + return true; + + case R.id.action_confirm_participants: + maybeGetOrCreateConversation(); + return true; + + case R.id.action_delete_text: + Assert.equals(MODE_PICK_INITIAL_CONTACT, mContactPickingMode); + mRecipientTextView.setText(""); + return true; + } + return false; + } + + @Override // From ContactPickerDataListener + public void onAllContactsCursorUpdated(final Cursor data) { + mBinding.ensureBound(); + mAllContactsListViewHolder.onContactsCursorUpdated(data); + } + + @Override // From ContactPickerDataListener + public void onFrequentContactsCursorUpdated(final Cursor data) { + mBinding.ensureBound(); + mFrequentContactsListViewHolder.onContactsCursorUpdated(data); + if (data != null && data.getCount() == 0) { + // Show the all contacts list when there's no frequents. + mCustomHeaderViewPager.setCurrentItem(1); + } + } + + @Override // From ContactListItemView.HostInterface + public void onContactListItemClicked(final ContactListItemData item, + final ContactListItemView view) { + if (!isContactSelected(item)) { + if (mContactPickingMode == MODE_PICK_INITIAL_CONTACT) { + mPendingExplodeView = view; + } + mRecipientTextView.appendRecipientEntry(item.getRecipientEntry()); + } else if (mContactPickingMode != MODE_PICK_INITIAL_CONTACT) { + mRecipientTextView.removeRecipientEntry(item.getRecipientEntry()); + } + } + + @Override // From ContactListItemView.HostInterface + public boolean isContactSelected(final ContactListItemData item) { + return mSelectedPhoneNumbers != null && + mSelectedPhoneNumbers.contains(PhoneUtils.getDefault().getCanonicalBySystemLocale( + item.getRecipientEntry().getDestination())); + } + + /** + * Call this immediately after attaching the fragment, or when there's a ui state change that + * changes our host (i.e. restore from saved instance state). + */ + public void setHost(final ContactPickerFragmentHost host) { + mHost = host; + } + + public void setContactPickingMode(final int mode, final boolean animate) { + if (mContactPickingMode != mode) { + // Guard against impossible transitions. + Assert.isTrue( + // We may start from undefined mode to any mode when we are restoring state. + (mContactPickingMode == MODE_UNDEFINED) || + (mContactPickingMode == MODE_PICK_INITIAL_CONTACT && mode == MODE_CHIPS_ONLY) || + (mContactPickingMode == MODE_CHIPS_ONLY && mode == MODE_PICK_MORE_CONTACTS) || + (mContactPickingMode == MODE_PICK_MORE_CONTACTS + && mode == MODE_PICK_MAX_PARTICIPANTS) || + (mContactPickingMode == MODE_PICK_MAX_PARTICIPANTS + && mode == MODE_PICK_MORE_CONTACTS)); + + mContactPickingMode = mode; + updateVisualsForContactPickingMode(animate); + } + } + + private void showImeKeyboard() { + Assert.notNull(mRecipientTextView); + mRecipientTextView.requestFocus(); + + // showImeKeyboard() won't work until the layout is ready, so wait until layout is complete + // before showing the soft keyboard. + UiUtils.doOnceAfterLayoutChange(mRootView, new Runnable() { + @Override + public void run() { + final Activity activity = getActivity(); + if (activity != null) { + ImeUtil.get().showImeKeyboard(activity, mRecipientTextView); + } + } + }); + mRecipientTextView.invalidate(); + } + + private void updateVisualsForContactPickingMode(final boolean animate) { + // Don't update visuals if the visuals haven't been inflated yet. + if (mRootView != null) { + final Menu menu = mToolbar.getMenu(); + final MenuItem addMoreParticipantsItem = menu.findItem( + R.id.action_add_more_participants); + final MenuItem confirmParticipantsItem = menu.findItem( + R.id.action_confirm_participants); + switch (mContactPickingMode) { + case MODE_PICK_INITIAL_CONTACT: + addMoreParticipantsItem.setVisible(false); + confirmParticipantsItem.setVisible(false); + mCustomHeaderViewPager.setVisibility(View.VISIBLE); + mComposeDivider.setVisibility(View.INVISIBLE); + mRecipientTextView.setEnabled(true); + showImeKeyboard(); + break; + + case MODE_CHIPS_ONLY: + if (animate) { + if (mPendingExplodeView == null) { + // The user didn't click on any contact item, so use the toolbar as + // the view to "explode." + mPendingExplodeView = mToolbar; + } + startExplodeTransitionForContactLists(false /* show */); + + ViewGroupItemVerticalExplodeAnimation.startAnimationForView( + mCustomHeaderViewPager, mPendingExplodeView, mRootView, + true /* snapshotView */, UiUtils.COMPOSE_TRANSITION_DURATION); + showHideContactPagerWithAnimation(false /* show */); + } else { + mCustomHeaderViewPager.setVisibility(View.GONE); + } + + addMoreParticipantsItem.setVisible(true); + confirmParticipantsItem.setVisible(false); + mComposeDivider.setVisibility(View.VISIBLE); + mRecipientTextView.setEnabled(true); + break; + + case MODE_PICK_MORE_CONTACTS: + if (animate) { + // Correctly set the start visibility state for the view pager and + // individual list items (hidden initially), so that the transition + // manager can properly track the visibility change for the explode. + mCustomHeaderViewPager.setVisibility(View.VISIBLE); + toggleContactListItemsVisibilityForPendingTransition(false /* show */); + startExplodeTransitionForContactLists(true /* show */); + } + addMoreParticipantsItem.setVisible(false); + confirmParticipantsItem.setVisible(true); + mCustomHeaderViewPager.setVisibility(View.VISIBLE); + mComposeDivider.setVisibility(View.INVISIBLE); + mRecipientTextView.setEnabled(true); + showImeKeyboard(); + break; + + case MODE_PICK_MAX_PARTICIPANTS: + addMoreParticipantsItem.setVisible(false); + confirmParticipantsItem.setVisible(true); + mCustomHeaderViewPager.setVisibility(View.VISIBLE); + mComposeDivider.setVisibility(View.INVISIBLE); + // TODO: Verify that this is okay for accessibility + mRecipientTextView.setEnabled(false); + break; + + default: + Assert.fail("Unsupported contact picker mode!"); + break; + } + updateTextInputButtonsVisibility(); + } + } + + private void updateTextInputButtonsVisibility() { + final Menu menu = mToolbar.getMenu(); + final MenuItem keypadToggleItem = menu.findItem(R.id.action_ime_dialpad_toggle); + final MenuItem deleteTextItem = menu.findItem(R.id.action_delete_text); + if (mContactPickingMode == MODE_PICK_INITIAL_CONTACT) { + if (TextUtils.isEmpty(mRecipientTextView.getText())) { + deleteTextItem.setVisible(false); + keypadToggleItem.setVisible(true); + } else { + deleteTextItem.setVisible(true); + keypadToggleItem.setVisible(false); + } + } else { + deleteTextItem.setVisible(false); + keypadToggleItem.setVisible(false); + } + } + + private void maybeGetOrCreateConversation() { + final ArrayList participants = + mRecipientTextView.getRecipientParticipantDataForConversationCreation(); + if (ContactPickerData.isTooManyParticipants(participants.size())) { + UiUtils.showToast(R.string.too_many_participants); + } else if (participants.size() > 0 && mMonitor == null) { + mMonitor = GetOrCreateConversationAction.getOrCreateConversation(participants, + null, this); + } + } + + /** + * Watches changes in contact chips to determine possible state transitions (e.g. creating + * the initial conversation, adding more participants or finish the current conversation) + */ + @Override + public void onContactChipsChanged(final int oldCount, final int newCount) { + Assert.isTrue(oldCount != newCount); + if (mContactPickingMode == MODE_PICK_INITIAL_CONTACT) { + // Initial picking mode. Start a conversation once a recipient has been picked. + maybeGetOrCreateConversation(); + } else if (mContactPickingMode == MODE_CHIPS_ONLY) { + // oldCount == 0 means we are restoring from savedInstanceState to add the existing + // chips, don't switch to "add more participants" mode in this case. + if (oldCount > 0 && mRecipientTextView.isFocused()) { + // Chips only mode. The user may have picked an additional contact or deleted the + // only existing contact. Either way, switch to picking more participants mode. + mHost.onInitiateAddMoreParticipants(); + } + } + mHost.onParticipantCountChanged(ContactPickerData.getCanAddMoreParticipants(newCount)); + + // Refresh our local copy of the selected chips set to keep it up-to-date. + mSelectedPhoneNumbers = mRecipientTextView.getSelectedDestinations(); + invalidateContactLists(); + } + + /** + * Listens for notification that invalid contacts have been removed during resolving them. + * These contacts were not local contacts, valid email, or valid phone numbers + */ + @Override + public void onInvalidContactChipsPruned(final int prunedCount) { + Assert.isTrue(prunedCount > 0); + UiUtils.showToast(R.plurals.add_invalid_contact_error, prunedCount); + } + + /** + * Listens for notification that the user has pressed enter/done on the keyboard with all + * contacts in place and we should create or go to the existing conversation now + */ + @Override + public void onEntryComplete() { + if (mContactPickingMode == MODE_PICK_INITIAL_CONTACT || + mContactPickingMode == MODE_PICK_MORE_CONTACTS || + mContactPickingMode == MODE_PICK_MAX_PARTICIPANTS) { + // Avoid multiple calls to create in race cases (hit done right after selecting contact) + maybeGetOrCreateConversation(); + } + } + + private void invalidateContactLists() { + mAllContactsListViewHolder.invalidateList(); + mFrequentContactsListViewHolder.invalidateList(); + } + + /** + * Kicks off a scene transition that animates visibility changes of individual contact list + * items via explode animation. + * @param show whether the contact lists are to be shown or hidden. + */ + private void startExplodeTransitionForContactLists(final boolean show) { + if (!OsUtil.isAtLeastL()) { + // Explode animation is not supported pre-L. + return; + } + final Explode transition = new Explode(); + final Rect epicenter = mPendingExplodeView == null ? null : + UiUtils.getMeasuredBoundsOnScreen(mPendingExplodeView); + transition.setDuration(UiUtils.COMPOSE_TRANSITION_DURATION); + transition.setInterpolator(UiUtils.EASE_IN_INTERPOLATOR); + transition.setEpicenterCallback(new EpicenterCallback() { + @Override + public Rect onGetEpicenter(final Transition transition) { + return epicenter; + } + }); + + // Kick off the delayed scene explode transition. Anything happens after this line in this + // method before the next frame will be tracked by the transition manager for visibility + // changes and animated accordingly. + TransitionManager.beginDelayedTransition(mCustomHeaderViewPager, + transition); + + toggleContactListItemsVisibilityForPendingTransition(show); + } + + /** + * Toggle the visibility of contact list items in the contact lists for them to be tracked by + * the transition manager for pending explode transition. + */ + private void toggleContactListItemsVisibilityForPendingTransition(final boolean show) { + if (!OsUtil.isAtLeastL()) { + // Explode animation is not supported pre-L. + return; + } + mAllContactsListViewHolder.toggleVisibilityForPendingTransition(show, mPendingExplodeView); + mFrequentContactsListViewHolder.toggleVisibilityForPendingTransition(show, + mPendingExplodeView); + } + + private void showHideContactPagerWithAnimation(final boolean show) { + final boolean isPagerVisible = (mCustomHeaderViewPager.getVisibility() == View.VISIBLE); + if (show == isPagerVisible) { + return; + } + + mCustomHeaderViewPager.animate().alpha(show ? 1F : 0F) + .setStartDelay(!show ? UiUtils.COMPOSE_TRANSITION_DURATION : 0) + .withStartAction(new Runnable() { + @Override + public void run() { + mCustomHeaderViewPager.setVisibility(View.VISIBLE); + mCustomHeaderViewPager.setAlpha(show ? 0F : 1F); + } + }) + .withEndAction(new Runnable() { + @Override + public void run() { + mCustomHeaderViewPager.setVisibility(show ? View.VISIBLE : View.GONE); + mCustomHeaderViewPager.setAlpha(1F); + } + }); + } + + @Override + public void onContactCustomColorLoaded(final ContactPickerData data) { + mBinding.ensureBound(data); + invalidateContactLists(); + } + + public void updateActionBar(final ActionBar actionBar) { + // Hide the action bar for contact picker mode. The custom ToolBar containing chips UI + // etc. will take the spot of the action bar. + actionBar.hide(); + UiUtils.setStatusBarColor(getActivity(), + getResources().getColor(R.color.compose_notification_bar_background)); + } + + private GetOrCreateConversationActionMonitor mMonitor; + + @Override + @RunsOnMainThread + public void onGetOrCreateConversationSucceeded(final ActionMonitor monitor, + final Object data, final String conversationId) { + Assert.isTrue(monitor == mMonitor); + Assert.isTrue(conversationId != null); + + mRecipientTextView.setInputType(InputType.TYPE_TEXT_FLAG_MULTI_LINE | + InputType.TYPE_CLASS_TEXT); + mHost.onGetOrCreateNewConversation(conversationId); + + mMonitor = null; + } + + @Override + @RunsOnMainThread + public void onGetOrCreateConversationFailed(final ActionMonitor monitor, + final Object data) { + Assert.isTrue(monitor == mMonitor); + LogUtil.e(LogUtil.BUGLE_TAG, "onGetOrCreateConversationFailed"); + mMonitor = null; + } +} diff --git a/src/com/android/messaging/ui/contact/ContactRecipientAdapter.java b/src/com/android/messaging/ui/contact/ContactRecipientAdapter.java new file mode 100644 index 0000000..25f422e --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactRecipientAdapter.java @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.contact; + +import android.content.Context; +import android.database.Cursor; +import android.database.MergeCursor; +import android.support.v4.util.Pair; +import android.text.TextUtils; +import android.text.util.Rfc822Token; +import android.text.util.Rfc822Tokenizer; +import android.widget.Filter; + +import com.android.ex.chips.BaseRecipientAdapter; +import com.android.ex.chips.RecipientAlternatesAdapter; +import com.android.ex.chips.RecipientAlternatesAdapter.RecipientMatchCallback; +import com.android.ex.chips.RecipientEntry; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.ContactRecipientEntryUtils; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.PhoneUtils; + +import java.text.Collator; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * An extension on the base {@link BaseRecipientAdapter} that uses data layer from Bugle, + * such as the ContactRecipientPhotoManager that uses our own MediaResourceManager, and + * contact lookup that relies on ContactUtil. It provides data source and filtering ability + * for {@link ContactRecipientAutoCompleteView} + */ +public final class ContactRecipientAdapter extends BaseRecipientAdapter { + public ContactRecipientAdapter(final Context context, + final ContactListItemView.HostInterface clivHost) { + this(context, Integer.MAX_VALUE, QUERY_TYPE_PHONE, clivHost); + } + + public ContactRecipientAdapter(final Context context, final int preferredMaxResultCount, + final int queryMode, final ContactListItemView.HostInterface clivHost) { + super(context, preferredMaxResultCount, queryMode); + setPhotoManager(new ContactRecipientPhotoManager(context, clivHost)); + } + + @Override + public boolean forceShowAddress() { + // We should always use the SingleRecipientAddressAdapter + // And never use the RecipientAlternatesAdapter + return true; + } + + @Override + public Filter getFilter() { + return new ContactFilter(); + } + + /** + * A Filter for a RecipientEditTextView that queries Bugle's ContactUtil for auto-complete + * results. + */ + public class ContactFilter extends Filter { + // Used to sort filtered contacts when it has combined results from email and phone. + private final RecipientEntryComparator mComparator = new RecipientEntryComparator(); + + /** + * Returns a cursor containing the filtered results in contacts given the search text, + * and a boolean indicating whether the results are sorted. + * + * The queries are synchronously performed since this is not run on the main thread. + * + * Some locales (e.g. JPN) expect email addresses to be auto-completed for MMS. + * If this is the case, perform two queries on phone number followed by email and + * return the merged results. + */ + @DoesNotRunOnMainThread + private Pair getFilteredResultsCursor(final Context context, + final String searchText) { + Assert.isNotMainThread(); + if (BugleGservices.get().getBoolean( + BugleGservicesKeys.ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS, + BugleGservicesKeys.ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS_DEFAULT)) { + return Pair.create((Cursor) new MergeCursor(new Cursor[] { + ContactUtil.filterPhones(getContext(), searchText) + .performSynchronousQuery(), + ContactUtil.filterEmails(getContext(), searchText) + .performSynchronousQuery() + }), false /* the merged cursor is not sorted */); + } else { + return Pair.create(ContactUtil.filterDestination(getContext(), searchText) + .performSynchronousQuery(), true); + } + } + + @Override + protected FilterResults performFiltering(final CharSequence constraint) { + Assert.isNotMainThread(); + final FilterResults results = new FilterResults(); + + // No query, return empty results. + if (TextUtils.isEmpty(constraint)) { + clearTempEntries(); + return results; + } + + final String searchText = constraint.toString(); + + // Query for auto-complete results, since performFiltering() is not done on the + // main thread, perform the cursor loader queries directly. + final Pair filteredResults = getFilteredResultsCursor(getContext(), + searchText); + final Cursor cursor = filteredResults.first; + final boolean sorted = filteredResults.second; + if (cursor != null) { + try { + final List entries = new ArrayList(); + + // First check if the constraint is a valid SMS destination. If so, add the + // destination as a suggestion item to the drop down. + if (PhoneUtils.isValidSmsMmsDestination(searchText)) { + entries.add(ContactRecipientEntryUtils + .constructSendToDestinationEntry(searchText)); + } + + HashSet existingContactIds = new HashSet(); + while (cursor.moveToNext()) { + // Make sure there's only one first-level contact (i.e. contact for which + // we show the avatar picture and name) for every contact id. + final long contactId = cursor.getLong(ContactUtil.INDEX_CONTACT_ID); + final boolean isFirstLevel = !existingContactIds.contains(contactId); + if (isFirstLevel) { + existingContactIds.add(contactId); + } + entries.add(ContactUtil.createRecipientEntryForPhoneQuery(cursor, + isFirstLevel)); + } + + if (!sorted) { + Collections.sort(entries, mComparator); + } + results.values = entries; + results.count = 1; + + } finally { + cursor.close(); + } + } + return results; + } + + @Override + protected void publishResults(final CharSequence constraint, final FilterResults results) { + mCurrentConstraint = constraint; + clearTempEntries(); + + if (results.values != null) { + @SuppressWarnings("unchecked") + final List entries = (List) results.values; + updateEntries(entries); + } else { + updateEntries(Collections.emptyList()); + } + } + + private class RecipientEntryComparator implements Comparator { + private final Collator mCollator; + + public RecipientEntryComparator() { + mCollator = Collator.getInstance(Locale.getDefault()); + mCollator.setStrength(Collator.PRIMARY); + } + + /** + * Compare two RecipientEntry's, first by locale-aware display name comparison, then by + * contact id comparison, finally by first-level-ness comparison. + */ + @Override + public int compare(RecipientEntry lhs, RecipientEntry rhs) { + // Send-to-destinations always appear before everything else. + final boolean sendToLhs = ContactRecipientEntryUtils + .isSendToDestinationContact(lhs); + final boolean sendToRhs = ContactRecipientEntryUtils + .isSendToDestinationContact(lhs); + if (sendToLhs != sendToRhs) { + if (sendToLhs) { + return -1; + } else if (sendToRhs) { + return 1; + } + } + + final int displayNameCompare = mCollator.compare(lhs.getDisplayName(), + rhs.getDisplayName()); + if (displayNameCompare != 0) { + return displayNameCompare; + } + + // Long.compare could accomplish the following three lines, but this is only + // available in API 19+ + final long lhsContactId = lhs.getContactId(); + final long rhsContactId = rhs.getContactId(); + final int contactCompare = lhsContactId < rhsContactId ? -1 : + (lhsContactId == rhsContactId ? 0 : 1); + if (contactCompare != 0) { + return contactCompare; + } + + // These are the same contact. Make sure first-level contacts always + // appear at the front. + if (lhs.isFirstLevel()) { + return -1; + } else if (rhs.isFirstLevel()) { + return 1; + } else { + return 0; + } + } + } + } + + /** + * Called when we need to substitute temporary recipient chips with better alternatives. + * For example, if a list of comma-delimited phone numbers are pasted into the edit box, + * we want to be able to look up in the ContactUtil for exact matches and get contact + * details such as name and photo thumbnail for the contact to display a better chip. + */ + @Override + public void getMatchingRecipients(final ArrayList inAddresses, + final RecipientMatchCallback callback) { + final int addressesSize = Math.min( + RecipientAlternatesAdapter.MAX_LOOKUPS, inAddresses.size()); + final HashSet addresses = new HashSet(); + for (int i = 0; i < addressesSize; i++) { + final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(inAddresses.get(i).toLowerCase()); + addresses.add(tokens.length > 0 ? tokens[0].getAddress() : inAddresses.get(i)); + } + + final Map recipientEntries = + new HashMap(); + // query for each address + for (final String address : addresses) { + final Cursor cursor = ContactUtil.lookupDestination(getContext(), address) + .performSynchronousQuery(); + if (cursor != null) { + try { + if (cursor.moveToNext()) { + // There may be multiple matches to the same number, always take the + // first match. + // TODO: May need to consider if there's an existing conversation + // that matches this particular contact and prioritize that contact. + final RecipientEntry entry = + ContactUtil.createRecipientEntryForPhoneQuery(cursor, true); + recipientEntries.put(address, entry); + } + + } finally { + cursor.close(); + } + } + } + + // report matches + callback.matchesFound(recipientEntries); + } +} diff --git a/src/com/android/messaging/ui/contact/ContactRecipientAutoCompleteView.java b/src/com/android/messaging/ui/contact/ContactRecipientAutoCompleteView.java new file mode 100644 index 0000000..c7c2731 --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactRecipientAutoCompleteView.java @@ -0,0 +1,289 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.contact; + +import android.content.Context; +import android.database.Cursor; +import android.graphics.Rect; +import android.os.AsyncTask; +import android.support.v7.appcompat.R; +import android.text.Editable; +import android.text.TextPaint; +import android.text.TextWatcher; +import android.text.util.Rfc822Tokenizer; +import android.util.AttributeSet; +import android.view.ContextThemeWrapper; +import android.view.KeyEvent; +import android.view.inputmethod.EditorInfo; +import android.widget.TextView; + +import com.android.ex.chips.RecipientEditTextView; +import com.android.ex.chips.RecipientEntry; +import com.android.ex.chips.recipientchip.DrawableRecipientChip; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.util.ContactRecipientEntryUtils; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.PhoneUtils; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +/** + * An extension for {@link RecipientEditTextView} which shows a list of Materialized contact chips. + * It uses Bugle's ContactUtil to perform contact lookup, and is able to return the list of + * recipients in the form of a ParticipantData list. + */ +public class ContactRecipientAutoCompleteView extends RecipientEditTextView { + public interface ContactChipsChangeListener { + void onContactChipsChanged(int oldCount, int newCount); + void onInvalidContactChipsPruned(int prunedCount); + void onEntryComplete(); + } + + private final int mTextHeight; + private ContactChipsChangeListener mChipsChangeListener; + + /** + * Watches changes in contact chips to determine possible state transitions. + */ + private class ContactChipsWatcher implements TextWatcher { + /** + * Tracks the old chips count before text changes. Note that we currently don't compare + * the entire chip sets but just the cheaper-to-do before and after counts, because + * the chips view don't allow for replacing chips. + */ + private int mLastChipsCount = 0; + + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, + final int count) { + } + + @Override + public void beforeTextChanged(final CharSequence s, final int start, final int count, + final int after) { + // We don't take mLastChipsCount from here but from the last afterTextChanged() run. + // The reason is because at this point, any chip spans to be removed is already removed + // from s in the chips text view. + } + + @Override + public void afterTextChanged(final Editable s) { + final int currentChipsCount = s.getSpans(0, s.length(), + DrawableRecipientChip.class).length; + if (currentChipsCount != mLastChipsCount) { + // When a sanitizing task is running, we don't want to notify any chips count + // change, but we do want to track the last chip count. + if (mChipsChangeListener != null && mCurrentSanitizeTask == null) { + mChipsChangeListener.onContactChipsChanged(mLastChipsCount, currentChipsCount); + } + mLastChipsCount = currentChipsCount; + } + } + } + + private static final String TEXT_HEIGHT_SAMPLE = "a"; + + public ContactRecipientAutoCompleteView(final Context context, final AttributeSet attrs) { + super(new ContextThemeWrapper(context, R.style.ColorAccentGrayOverrideStyle), attrs); + + // Get the height of the text, given the currently set font face and size. + final Rect textBounds = new Rect(0, 0, 0, 0); + final TextPaint paint = getPaint(); + paint.getTextBounds(TEXT_HEIGHT_SAMPLE, 0, TEXT_HEIGHT_SAMPLE.length(), textBounds); + mTextHeight = textBounds.height(); + + setTokenizer(new Rfc822Tokenizer()); + addTextChangedListener(new ContactChipsWatcher()); + setOnFocusListShrinkRecipients(false); + + setBackground(context.getResources().getDrawable( + R.drawable.abc_textfield_search_default_mtrl_alpha)); + } + + public void setContactChipsListener(final ContactChipsChangeListener listener) { + mChipsChangeListener = listener; + } + + /** + * A tuple of chips which AsyncContactChipSanitizeTask reports as progress to have the + * chip actually replaced/removed on the UI thread. + */ + private class ChipReplacementTuple { + public final DrawableRecipientChip removedChip; + public final RecipientEntry replacedChipEntry; + + public ChipReplacementTuple(final DrawableRecipientChip removedChip, + final RecipientEntry replacedChipEntry) { + this.removedChip = removedChip; + this.replacedChipEntry = replacedChipEntry; + } + } + + /** + * An AsyncTask that cleans up contact chips on every chips commit (i.e. get or create a new + * conversation with the given chips). + */ + private class AsyncContactChipSanitizeTask extends + AsyncTask { + + @Override + protected Integer doInBackground(final Void... params) { + final DrawableRecipientChip[] recips = getText() + .getSpans(0, getText().length(), DrawableRecipientChip.class); + int invalidChipsRemoved = 0; + for (final DrawableRecipientChip recipient : recips) { + final RecipientEntry entry = recipient.getEntry(); + if (entry != null) { + if (entry.isValid()) { + if (RecipientEntry.isCreatedRecipient(entry.getContactId()) || + ContactRecipientEntryUtils.isSendToDestinationContact(entry)) { + // This is a generated/send-to contact chip, try to look it up and + // display a chip for the corresponding local contact. + final Cursor lookupResult = ContactUtil.lookupDestination(getContext(), + entry.getDestination()).performSynchronousQuery(); + if (lookupResult != null && lookupResult.moveToNext()) { + // Found a match, remove the generated entry and replace with + // a better local entry. + publishProgress(new ChipReplacementTuple(recipient, + ContactUtil.createRecipientEntryForPhoneQuery( + lookupResult, true))); + } else if (PhoneUtils.isValidSmsMmsDestination( + entry.getDestination())){ + // No match was found, but we have a valid destination so let's at + // least create an entry that shows an avatar. + publishProgress(new ChipReplacementTuple(recipient, + ContactRecipientEntryUtils.constructNumberWithAvatarEntry( + entry.getDestination()))); + } else { + // Not a valid contact. Remove and show an error. + publishProgress(new ChipReplacementTuple(recipient, null)); + invalidChipsRemoved++; + } + } + } else { + publishProgress(new ChipReplacementTuple(recipient, null)); + invalidChipsRemoved++; + } + } + } + return invalidChipsRemoved; + } + + @Override + protected void onProgressUpdate(final ChipReplacementTuple... values) { + for (final ChipReplacementTuple tuple : values) { + if (tuple.removedChip != null) { + final Editable text = getText(); + final int chipStart = text.getSpanStart(tuple.removedChip); + final int chipEnd = text.getSpanEnd(tuple.removedChip); + if (chipStart >= 0 && chipEnd >= 0) { + text.delete(chipStart, chipEnd); + } + + if (tuple.replacedChipEntry != null) { + appendRecipientEntry(tuple.replacedChipEntry); + } + } + } + } + + @Override + protected void onPostExecute(final Integer invalidChipsRemoved) { + mCurrentSanitizeTask = null; + if (invalidChipsRemoved > 0) { + mChipsChangeListener.onInvalidContactChipsPruned(invalidChipsRemoved); + } + } + } + + /** + * We don't use SafeAsyncTask but instead use a single threaded executor to ensure that + * all sanitization tasks are serially executed so as not to interfere with each other. + */ + private static final Executor SANITIZE_EXECUTOR = Executors.newSingleThreadExecutor(); + + private AsyncContactChipSanitizeTask mCurrentSanitizeTask; + + /** + * Whenever the caller wants to start a new conversation with the list of chips we have, + * make sure we asynchronously: + * 1. Remove invalid chips. + * 2. Attempt to resolve unknown contacts to known local contacts. + * 3. Convert still unknown chips to chips with generated avatar. + * + * Note that we don't need to perform this synchronously since we can + * resolve any unknown contacts to local contacts when needed. + */ + private void sanitizeContactChips() { + if (mCurrentSanitizeTask != null && !mCurrentSanitizeTask.isCancelled()) { + mCurrentSanitizeTask.cancel(false); + mCurrentSanitizeTask = null; + } + mCurrentSanitizeTask = new AsyncContactChipSanitizeTask(); + mCurrentSanitizeTask.executeOnExecutor(SANITIZE_EXECUTOR); + } + + /** + * Returns a list of ParticipantData from the entered chips in order to create + * new conversation. + */ + public ArrayList getRecipientParticipantDataForConversationCreation() { + final DrawableRecipientChip[] recips = getText() + .getSpans(0, getText().length(), DrawableRecipientChip.class); + final ArrayList contacts = + new ArrayList(recips.length); + for (final DrawableRecipientChip recipient : recips) { + final RecipientEntry entry = recipient.getEntry(); + if (entry != null && entry.isValid() && entry.getDestination() != null && + PhoneUtils.isValidSmsMmsDestination(entry.getDestination())) { + contacts.add(ParticipantData.getFromRecipientEntry(recipient.getEntry())); + } + } + sanitizeContactChips(); + return contacts; + } + + /**c + * Gets a set of currently selected chips' emails/phone numbers. This will facilitate the + * consumer with determining quickly whether a contact is currently selected. + */ + public Set getSelectedDestinations() { + Set set = new HashSet(); + final DrawableRecipientChip[] recips = getText() + .getSpans(0, getText().length(), DrawableRecipientChip.class); + + for (final DrawableRecipientChip recipient : recips) { + final RecipientEntry entry = recipient.getEntry(); + if (entry != null && entry.isValid() && entry.getDestination() != null) { + set.add(PhoneUtils.getDefault().getCanonicalBySystemLocale( + entry.getDestination())); + } + } + return set; + } + + @Override + public boolean onEditorAction(final TextView view, final int actionId, final KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_DONE) { + mChipsChangeListener.onEntryComplete(); + } + return super.onEditorAction(view, actionId, event); + } +} diff --git a/src/com/android/messaging/ui/contact/ContactRecipientPhotoManager.java b/src/com/android/messaging/ui/contact/ContactRecipientPhotoManager.java new file mode 100644 index 0000000..d69ba64 --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactRecipientPhotoManager.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.contact; + +import android.content.Context; +import android.net.Uri; + +import com.android.ex.chips.PhotoManager; +import com.android.ex.chips.RecipientEntry; +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.media.AvatarRequestDescriptor; +import com.android.messaging.datamodel.media.BindableMediaRequest; +import com.android.messaging.datamodel.media.ImageResource; +import com.android.messaging.datamodel.media.MediaRequest; +import com.android.messaging.datamodel.media.MediaResourceManager; +import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.ThreadUtil; + +/** + * An implementation of {@link PhotoManager} that hooks up the chips UI's photos with our own + * {@link MediaResourceManager} for retrieving and caching contact avatars. + */ +public class ContactRecipientPhotoManager implements PhotoManager { + private static final String IMAGE_BYTES_REQUEST_STATIC_BINDING_ID = "imagebytes"; + private final Context mContext; + private final int mIconSize; + private final ContactListItemView.HostInterface mClivHostInterface; + + public ContactRecipientPhotoManager(final Context context, + final ContactListItemView.HostInterface clivHostInterface) { + mContext = context; + mIconSize = context.getResources().getDimensionPixelSize( + R.dimen.compose_message_chip_height) - context.getResources().getDimensionPixelSize( + R.dimen.compose_message_chip_padding) * 2; + mClivHostInterface = clivHostInterface; + } + + /** + * {@inheritDoc} + */ + @Override + public void populatePhotoBytesAsync(final RecipientEntry entry, + final PhotoManagerCallback callback) { + // Post all media resource request to the main thread. + ThreadUtil.getMainThreadHandler().post(new Runnable() { + @Override + public void run() { + final Uri avatarUri = AvatarUriUtil.createAvatarUri( + ParticipantData.getFromRecipientEntry(entry)); + final AvatarRequestDescriptor descriptor = + new AvatarRequestDescriptor(avatarUri, mIconSize, mIconSize); + final BindableMediaRequest req = descriptor.buildAsyncMediaRequest( + mContext, + new MediaResourceLoadListener() { + @Override + public void onMediaResourceLoaded(final MediaRequest request, + final ImageResource resource, final boolean isCached) { + entry.setPhotoBytes(resource.getBytes()); + callback.onPhotoBytesAsynchronouslyPopulated(); + } + + @Override + public void onMediaResourceLoadError(final MediaRequest request, + final Exception exception) { + LogUtil.e(LogUtil.BUGLE_TAG, "Photo bytes loading failed due to " + + exception + " request key=" + request.getKey()); + + // Fall back to the default avatar image. + callback.onPhotoBytesAsyncLoadFailed(); + }}); + + // Statically bind the request since it's not bound to any specific piece of UI. + req.bind(IMAGE_BYTES_REQUEST_STATIC_BINDING_ID); + + Factory.get().getMediaResourceManager().requestMediaResourceAsync(req); + } + }); + } +} diff --git a/src/com/android/messaging/ui/contact/ContactSectionIndexer.java b/src/com/android/messaging/ui/contact/ContactSectionIndexer.java new file mode 100644 index 0000000..1d5abf3 --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactSectionIndexer.java @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.contact; + +import android.database.Cursor; +import android.os.Bundle; +import android.provider.ContactsContract.Contacts; +import android.text.TextUtils; +import android.widget.SectionIndexer; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.LogUtil; + +import java.util.ArrayList; + +/** + * Indexes contact alphabetical sections so we can report to the fast scrolling list view + * where we are in the list when the user scrolls through the contact list, allowing us to show + * alphabetical indicators for the fast scroller as well as list section headers. + */ +public class ContactSectionIndexer implements SectionIndexer { + private String[] mSections; + private ArrayList mSectionStartingPositions; + private static final String BLANK_HEADER_STRING = " "; + + public ContactSectionIndexer(final Cursor contactsCursor) { + buildIndexer(contactsCursor); + } + + @Override + public Object[] getSections() { + return mSections; + } + + @Override + public int getPositionForSection(final int sectionIndex) { + if (mSectionStartingPositions.isEmpty()) { + return 0; + } + // Clamp to the bounds of the section position array per Android API doc. + return mSectionStartingPositions.get( + Math.max(Math.min(sectionIndex, mSectionStartingPositions.size() - 1), 0)); + } + + @Override + public int getSectionForPosition(final int position) { + if (mSectionStartingPositions.isEmpty()) { + return 0; + } + + // Perform a binary search on the starting positions of the sections to the find the + // section for the position. + int left = 0; + int right = mSectionStartingPositions.size() - 1; + + // According to getSectionForPosition()'s doc, we should always clamp the value when the + // position is out of bound. + if (position <= mSectionStartingPositions.get(left)) { + return left; + } else if (position >= mSectionStartingPositions.get(right)) { + return right; + } + + while (left <= right) { + final int mid = (left + right) / 2; + final int startingPos = mSectionStartingPositions.get(mid); + final int nextStartingPos = mSectionStartingPositions.get(mid + 1); + if (position >= startingPos && position < nextStartingPos) { + return mid; + } else if (position < startingPos) { + right = mid - 1; + } else if (position >= nextStartingPos) { + left = mid + 1; + } + } + Assert.fail("Invalid section indexer state: couldn't find section for pos " + position); + return -1; + } + + private boolean buildIndexerFromCursorExtras(final Cursor cursor) { + if (cursor == null) { + return false; + } + final Bundle cursorExtras = cursor.getExtras(); + if (cursorExtras == null) { + return false; + } + final String[] sections = cursorExtras.getStringArray( + Contacts.EXTRA_ADDRESS_BOOK_INDEX_TITLES); + final int[] counts = cursorExtras.getIntArray(Contacts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS); + if (sections == null || counts == null) { + return false; + } + + if (sections.length != counts.length) { + return false; + } + + this.mSections = sections; + mSectionStartingPositions = new ArrayList(counts.length); + int position = 0; + for (int i = 0; i < counts.length; i++) { + if (TextUtils.isEmpty(mSections[i])) { + mSections[i] = BLANK_HEADER_STRING; + } else if (!mSections[i].equals(BLANK_HEADER_STRING)) { + mSections[i] = mSections[i].trim(); + } + + mSectionStartingPositions.add(position); + position += counts[i]; + } + return true; + } + + private void buildIndexerFromDisplayNames(final Cursor cursor) { + // Loop through the contact cursor and get the starting position for each first character. + // The result is stored into two arrays, one for the section header (i.e. the first + // character), and one for the starting position, which is guaranteed to be sorted in + // ascending order. + final ArrayList sections = new ArrayList(); + mSectionStartingPositions = new ArrayList(); + if (cursor != null) { + cursor.moveToPosition(-1); + int currentPosition = 0; + while (cursor.moveToNext()) { + // The sort key is typically the contact's display name, so for example, a contact + // named "Bob" will go into section "B". The Contacts provider generally uses a + // a slightly more sophisticated heuristic, but as a fallback this is good enough. + final String sortKey = cursor.getString(ContactUtil.INDEX_SORT_KEY); + final String section = TextUtils.isEmpty(sortKey) ? BLANK_HEADER_STRING : + sortKey.substring(0, 1).toUpperCase(); + + final int lastIndex = sections.size() - 1; + final String currentSection = lastIndex >= 0 ? sections.get(lastIndex) : null; + if (!TextUtils.equals(currentSection, section)) { + sections.add(section); + mSectionStartingPositions.add(currentPosition); + } + currentPosition++; + } + } + mSections = new String[sections.size()]; + sections.toArray(mSections); + } + + private void buildIndexer(final Cursor cursor) { + // First check if we get indexer label extras from the contact provider; if not, fall back + // to building from display names. + if (!buildIndexerFromCursorExtras(cursor)) { + LogUtil.w(LogUtil.BUGLE_TAG, "contact provider didn't provide contact label " + + "information, fall back to using display name!"); + buildIndexerFromDisplayNames(cursor); + } + } +} diff --git a/src/com/android/messaging/ui/contact/FrequentContactsListViewHolder.java b/src/com/android/messaging/ui/contact/FrequentContactsListViewHolder.java new file mode 100644 index 0000000..1f3c795 --- /dev/null +++ b/src/com/android/messaging/ui/contact/FrequentContactsListViewHolder.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.contact; + +import android.content.Context; + +import com.android.messaging.R; +import com.android.messaging.ui.CustomHeaderPagerListViewHolder; +import com.android.messaging.ui.contact.ContactListItemView.HostInterface; + +/** + * Holds the frequent contacts view for the contact picker's view pager. + */ +public class FrequentContactsListViewHolder extends CustomHeaderPagerListViewHolder { + public FrequentContactsListViewHolder(final Context context, + final HostInterface clivHostInterface) { + super(context, new ContactListAdapter(context, null, clivHostInterface, + false /* needAlphabetHeader */)); + } + + @Override + protected int getLayoutResId() { + return R.layout.frequent_contacts_list_view; + } + + @Override + protected int getPageTitleResId() { + return R.string.contact_picker_frequents_tab_title; + } + + @Override + protected int getEmptyViewResId() { + return R.id.empty_view; + } + + @Override + protected int getListViewResId() { + return R.id.frequent_contacts_list; + } + + @Override + protected int getEmptyViewTitleResId() { + return R.string.contact_list_empty_text; + } + + @Override + protected int getEmptyViewImageResId() { + return R.drawable.ic_oobe_freq_list; + } +} diff --git a/src/com/android/messaging/ui/conversation/ComposeMessageView.java b/src/com/android/messaging/ui/conversation/ComposeMessageView.java new file mode 100644 index 0000000..17f8f74 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ComposeMessageView.java @@ -0,0 +1,962 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversation; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.text.Editable; +import android.text.Html; +import android.text.InputFilter; +import android.text.InputFilter.LengthFilter; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.view.ContextThemeWrapper; +import android.view.KeyEvent; +import android.view.View; +import android.view.accessibility.AccessibilityEvent; +import android.view.inputmethod.EditorInfo; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.binding.ImmutableBindingRef; +import com.android.messaging.datamodel.data.ConversationData; +import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; +import com.android.messaging.datamodel.data.ConversationData.SimpleConversationDataListener; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.DraftMessageData.CheckDraftForSendTask; +import com.android.messaging.datamodel.data.DraftMessageData.CheckDraftTaskCallback; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.PendingAttachmentData; +import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.ui.AttachmentPreview; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.PlainTextEditText; +import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputSink; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.MediaUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.UiUtils; + +import java.util.Collection; +import java.util.List; + +/** + * This view contains the UI required to generate and send messages. + */ +public class ComposeMessageView extends LinearLayout + implements TextView.OnEditorActionListener, DraftMessageDataListener, TextWatcher, + ConversationInputSink { + + public interface IComposeMessageViewHost extends + DraftMessageData.DraftMessageSubscriptionDataProvider { + void sendMessage(MessageData message); + void onComposeEditTextFocused(); + void onAttachmentsCleared(); + void onAttachmentsChanged(final boolean haveAttachments); + void displayPhoto(Uri photoUri, Rect imageBounds, boolean isDraft); + void promptForSelfPhoneNumber(); + boolean isReadyForAction(); + void warnOfMissingActionConditions(final boolean sending, + final Runnable commandToRunAfterActionConditionResolved); + void warnOfExceedingMessageLimit(final boolean showAttachmentChooser, + boolean tooManyVideos); + void notifyOfAttachmentLoadFailed(); + void showAttachmentChooser(); + boolean shouldShowSubjectEditor(); + boolean shouldHideAttachmentsWhenSimSelectorShown(); + Uri getSelfSendButtonIconUri(); + int overrideCounterColor(); + int getAttachmentsClearedFlags(); + } + + public static final int CODEPOINTS_REMAINING_BEFORE_COUNTER_SHOWN = 10; + + // There is no draft and there is no need for the SIM selector + private static final int SEND_WIDGET_MODE_SELF_AVATAR = 1; + // There is no draft but we need to show the SIM selector + private static final int SEND_WIDGET_MODE_SIM_SELECTOR = 2; + // There is a draft + private static final int SEND_WIDGET_MODE_SEND_BUTTON = 3; + + private PlainTextEditText mComposeEditText; + private PlainTextEditText mComposeSubjectText; + private TextView mCharCounter; + private TextView mMmsIndicator; + private SimIconView mSelfSendIcon; + private ImageButton mSendButton; + private View mSubjectView; + private ImageButton mDeleteSubjectButton; + private AttachmentPreview mAttachmentPreview; + private ImageButton mAttachMediaButton; + + private final Binding mBinding; + private IComposeMessageViewHost mHost; + private final Context mOriginalContext; + private int mSendWidgetMode = SEND_WIDGET_MODE_SELF_AVATAR; + + // Shared data model object binding from the conversation. + private ImmutableBindingRef mConversationDataModel; + + // Centrally manages all the mutual exclusive UI components accepting user input, i.e. + // media picker, IME keyboard and SIM selector. + private ConversationInputManager mInputManager; + + private final ConversationDataListener mDataListener = new SimpleConversationDataListener() { + @Override + public void onConversationMetadataUpdated(ConversationData data) { + mConversationDataModel.ensureBound(data); + updateVisualsOnDraftChanged(); + } + + @Override + public void onConversationParticipantDataLoaded(ConversationData data) { + mConversationDataModel.ensureBound(data); + updateVisualsOnDraftChanged(); + } + + @Override + public void onSubscriptionListDataLoaded(ConversationData data) { + mConversationDataModel.ensureBound(data); + updateOnSelfSubscriptionChange(); + updateVisualsOnDraftChanged(); + } + }; + + public ComposeMessageView(final Context context, final AttributeSet attrs) { + super(new ContextThemeWrapper(context, R.style.ColorAccentBlueOverrideStyle), attrs); + mOriginalContext = context; + mBinding = BindingBase.createBinding(this); + } + + /** + * Host calls this to bind view to DraftMessageData object + */ + public void bind(final DraftMessageData data, final IComposeMessageViewHost host) { + mHost = host; + mBinding.bind(data); + data.addListener(this); + data.setSubscriptionDataProvider(host); + + final int counterColor = mHost.overrideCounterColor(); + if (counterColor != -1) { + mCharCounter.setTextColor(counterColor); + } + } + + /** + * Host calls this to unbind view + */ + public void unbind() { + mBinding.unbind(); + mHost = null; + mInputManager.onDetach(); + } + + @Override + protected void onFinishInflate() { + mComposeEditText = (PlainTextEditText) findViewById( + R.id.compose_message_text); + mComposeEditText.setOnEditorActionListener(this); + mComposeEditText.addTextChangedListener(this); + mComposeEditText.setOnFocusChangeListener(new OnFocusChangeListener() { + @Override + public void onFocusChange(final View v, final boolean hasFocus) { + if (v == mComposeEditText && hasFocus) { + mHost.onComposeEditTextFocused(); + } + } + }); + mComposeEditText.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View arg0) { + if (mHost.shouldHideAttachmentsWhenSimSelectorShown()) { + hideSimSelector(); + } + } + }); + + // onFinishInflate() is called before self is loaded from db. We set the default text + // limit here, and apply the real limit later in updateOnSelfSubscriptionChange(). + mComposeEditText.setFilters(new InputFilter[] { + new LengthFilter(MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID) + .getMaxTextLimit()) }); + + mSelfSendIcon = (SimIconView) findViewById(R.id.self_send_icon); + mSelfSendIcon.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + boolean shown = mInputManager.toggleSimSelector(true /* animate */, + getSelfSubscriptionListEntry()); + hideAttachmentsWhenShowingSims(shown); + } + }); + mSelfSendIcon.setOnLongClickListener(new OnLongClickListener() { + @Override + public boolean onLongClick(final View v) { + if (mHost.shouldShowSubjectEditor()) { + showSubjectEditor(); + } else { + boolean shown = mInputManager.toggleSimSelector(true /* animate */, + getSelfSubscriptionListEntry()); + hideAttachmentsWhenShowingSims(shown); + } + return true; + } + }); + + mComposeSubjectText = (PlainTextEditText) findViewById( + R.id.compose_subject_text); + // We need the listener to change the avatar to the send button when the user starts + // typing a subject without a message. + mComposeSubjectText.addTextChangedListener(this); + // onFinishInflate() is called before self is loaded from db. We set the default text + // limit here, and apply the real limit later in updateOnSelfSubscriptionChange(). + mComposeSubjectText.setFilters(new InputFilter[] { + new LengthFilter(MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID) + .getMaxSubjectLength())}); + + mDeleteSubjectButton = (ImageButton) findViewById(R.id.delete_subject_button); + mDeleteSubjectButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View clickView) { + hideSubjectEditor(); + mComposeSubjectText.setText(null); + mBinding.getData().setMessageSubject(null); + } + }); + + mSubjectView = findViewById(R.id.subject_view); + + mSendButton = (ImageButton) findViewById(R.id.send_message_button); + mSendButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View clickView) { + sendMessageInternal(true /* checkMessageSize */); + } + }); + mSendButton.setOnLongClickListener(new OnLongClickListener() { + @Override + public boolean onLongClick(final View arg0) { + boolean shown = mInputManager.toggleSimSelector(true /* animate */, + getSelfSubscriptionListEntry()); + hideAttachmentsWhenShowingSims(shown); + if (mHost.shouldShowSubjectEditor()) { + showSubjectEditor(); + } + return true; + } + }); + mSendButton.setAccessibilityDelegate(new AccessibilityDelegate() { + @Override + public void onPopulateAccessibilityEvent(View host, AccessibilityEvent event) { + super.onPopulateAccessibilityEvent(host, event); + // When the send button is long clicked, we want TalkBack to announce the real + // action (select SIM or edit subject), as opposed to "long press send button." + if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_LONG_CLICKED) { + event.getText().clear(); + event.getText().add(getResources() + .getText(shouldShowSimSelector(mConversationDataModel.getData()) ? + R.string.send_button_long_click_description_with_sim_selector : + R.string.send_button_long_click_description_no_sim_selector)); + // Make this an announcement so TalkBack will read our custom message. + event.setEventType(AccessibilityEvent.TYPE_ANNOUNCEMENT); + } + } + }); + + mAttachMediaButton = + (ImageButton) findViewById(R.id.attach_media_button); + mAttachMediaButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View clickView) { + // Showing the media picker is treated as starting to compose the message. + mInputManager.showHideMediaPicker(true /* show */, true /* animate */); + } + }); + + mAttachmentPreview = (AttachmentPreview) findViewById(R.id.attachment_draft_view); + mAttachmentPreview.setComposeMessageView(this); + + mCharCounter = (TextView) findViewById(R.id.char_counter); + mMmsIndicator = (TextView) findViewById(R.id.mms_indicator); + } + + private void hideAttachmentsWhenShowingSims(final boolean simPickerVisible) { + if (!mHost.shouldHideAttachmentsWhenSimSelectorShown()) { + return; + } + final boolean haveAttachments = mBinding.getData().hasAttachments(); + if (simPickerVisible && haveAttachments) { + mHost.onAttachmentsChanged(false); + mAttachmentPreview.hideAttachmentPreview(); + } else { + mHost.onAttachmentsChanged(haveAttachments); + mAttachmentPreview.onAttachmentsChanged(mBinding.getData()); + } + } + + public void setInputManager(final ConversationInputManager inputManager) { + mInputManager = inputManager; + } + + public void setConversationDataModel(final ImmutableBindingRef refDataModel) { + mConversationDataModel = refDataModel; + mConversationDataModel.getData().addConversationDataListener(mDataListener); + } + + ImmutableBindingRef getDraftDataModel() { + return BindingBase.createBindingReference(mBinding); + } + + // returns true if it actually shows the subject editor and false if already showing + private boolean showSubjectEditor() { + // show the subject editor + if (mSubjectView.getVisibility() == View.GONE) { + mSubjectView.setVisibility(View.VISIBLE); + mSubjectView.requestFocus(); + return true; + } + return false; + } + + private void hideSubjectEditor() { + mSubjectView.setVisibility(View.GONE); + mComposeEditText.requestFocus(); + } + + /** + * {@inheritDoc} from TextView.OnEditorActionListener + */ + @Override // TextView.OnEditorActionListener.onEditorAction + public boolean onEditorAction(final TextView view, final int actionId, final KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_SEND) { + sendMessageInternal(true /* checkMessageSize */); + return true; + } + return false; + } + + private void sendMessageInternal(final boolean checkMessageSize) { + LogUtil.i(LogUtil.BUGLE_TAG, "UI initiated message sending in conversation " + + mBinding.getData().getConversationId()); + if (mBinding.getData().isCheckingDraft()) { + // Don't send message if we are currently checking draft for sending. + LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: still checking draft"); + return; + } + // Check the host for pre-conditions about any action. + if (mHost.isReadyForAction()) { + mInputManager.showHideSimSelector(false /* show */, true /* animate */); + final String messageToSend = mComposeEditText.getText().toString(); + mBinding.getData().setMessageText(messageToSend); + final String subject = mComposeSubjectText.getText().toString(); + mBinding.getData().setMessageSubject(subject); + // Asynchronously check the draft against various requirements before sending. + mBinding.getData().checkDraftForAction(checkMessageSize, + mHost.getConversationSelfSubId(), new CheckDraftTaskCallback() { + @Override + public void onDraftChecked(DraftMessageData data, int result) { + mBinding.ensureBound(data); + switch (result) { + case CheckDraftForSendTask.RESULT_PASSED: + // Continue sending after check succeeded. + final MessageData message = mBinding.getData() + .prepareMessageForSending(mBinding); + if (message != null && message.hasContent()) { + playSentSound(); + mHost.sendMessage(message); + hideSubjectEditor(); + if (AccessibilityUtil.isTouchExplorationEnabled(getContext())) { + AccessibilityUtil.announceForAccessibilityCompat( + ComposeMessageView.this, null, + R.string.sending_message); + } + } + break; + + case CheckDraftForSendTask.RESULT_HAS_PENDING_ATTACHMENTS: + // Cannot send while there's still attachment(s) being loaded. + UiUtils.showToastAtBottom( + R.string.cant_send_message_while_loading_attachments); + break; + + case CheckDraftForSendTask.RESULT_NO_SELF_PHONE_NUMBER_IN_GROUP_MMS: + mHost.promptForSelfPhoneNumber(); + break; + + case CheckDraftForSendTask.RESULT_MESSAGE_OVER_LIMIT: + Assert.isTrue(checkMessageSize); + mHost.warnOfExceedingMessageLimit( + true /*sending*/, false /* tooManyVideos */); + break; + + case CheckDraftForSendTask.RESULT_VIDEO_ATTACHMENT_LIMIT_EXCEEDED: + Assert.isTrue(checkMessageSize); + mHost.warnOfExceedingMessageLimit( + true /*sending*/, true /* tooManyVideos */); + break; + + case CheckDraftForSendTask.RESULT_SIM_NOT_READY: + // Cannot send if there is no active subscription + UiUtils.showToastAtBottom( + R.string.cant_send_message_without_active_subscription); + break; + + default: + break; + } + } + }, mBinding); + } else { + mHost.warnOfMissingActionConditions(true /*sending*/, + new Runnable() { + @Override + public void run() { + sendMessageInternal(checkMessageSize); + } + + }); + } + } + + public static void playSentSound() { + // Check if this setting is enabled before playing + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + final Context context = Factory.get().getApplicationContext(); + final String prefKey = context.getString(R.string.send_sound_pref_key); + final boolean defaultValue = context.getResources().getBoolean( + R.bool.send_sound_pref_default); + if (!prefs.getBoolean(prefKey, defaultValue)) { + return; + } + MediaUtil.get().playSound(context, R.raw.message_sent, null /* completionListener */); + } + + /** + * {@inheritDoc} from DraftMessageDataListener + */ + @Override // From DraftMessageDataListener + public void onDraftChanged(final DraftMessageData data, final int changeFlags) { + // As this is called asynchronously when message read check bound before updating text + mBinding.ensureBound(data); + + // We have to cache the values of the DraftMessageData because when we set + // mComposeEditText, its onTextChanged calls updateVisualsOnDraftChanged, + // which immediately reloads the text from the subject and message fields and replaces + // what's in the DraftMessageData. + + final String subject = data.getMessageSubject(); + final String message = data.getMessageText(); + + if ((changeFlags & DraftMessageData.MESSAGE_SUBJECT_CHANGED) == + DraftMessageData.MESSAGE_SUBJECT_CHANGED) { + mComposeSubjectText.setText(subject); + + // Set the cursor selection to the end since setText resets it to the start + mComposeSubjectText.setSelection(mComposeSubjectText.getText().length()); + } + + if ((changeFlags & DraftMessageData.MESSAGE_TEXT_CHANGED) == + DraftMessageData.MESSAGE_TEXT_CHANGED) { + mComposeEditText.setText(message); + + // Set the cursor selection to the end since setText resets it to the start + mComposeEditText.setSelection(mComposeEditText.getText().length()); + } + + if ((changeFlags & DraftMessageData.ATTACHMENTS_CHANGED) == + DraftMessageData.ATTACHMENTS_CHANGED) { + final boolean haveAttachments = mAttachmentPreview.onAttachmentsChanged(data); + mHost.onAttachmentsChanged(haveAttachments); + } + + if ((changeFlags & DraftMessageData.SELF_CHANGED) == DraftMessageData.SELF_CHANGED) { + updateOnSelfSubscriptionChange(); + } + updateVisualsOnDraftChanged(); + } + + @Override // From DraftMessageDataListener + public void onDraftAttachmentLimitReached(final DraftMessageData data) { + mBinding.ensureBound(data); + mHost.warnOfExceedingMessageLimit(false /* sending */, false /* tooManyVideos */); + } + + private void updateOnSelfSubscriptionChange() { + // Refresh the length filters according to the selected self's MmsConfig. + mComposeEditText.setFilters(new InputFilter[] { + new LengthFilter(MmsConfig.get(mBinding.getData().getSelfSubId()) + .getMaxTextLimit()) }); + mComposeSubjectText.setFilters(new InputFilter[] { + new LengthFilter(MmsConfig.get(mBinding.getData().getSelfSubId()) + .getMaxSubjectLength())}); + } + + @Override + public void onMediaItemsSelected(final Collection items) { + mBinding.getData().addAttachments(items); + announceMediaItemState(true /*isSelected*/); + } + + @Override + public void onMediaItemsUnselected(final MessagePartData item) { + mBinding.getData().removeAttachment(item); + announceMediaItemState(false /*isSelected*/); + } + + @Override + public void onPendingAttachmentAdded(final PendingAttachmentData pendingItem) { + mBinding.getData().addPendingAttachment(pendingItem, mBinding); + resumeComposeMessage(); + } + + private void announceMediaItemState(final boolean isSelected) { + final Resources res = getContext().getResources(); + final String announcement = isSelected ? res.getString( + R.string.mediapicker_gallery_item_selected_content_description) : + res.getString(R.string.mediapicker_gallery_item_unselected_content_description); + AccessibilityUtil.announceForAccessibilityCompat( + this, null, announcement); + } + + private void announceAttachmentState() { + if (AccessibilityUtil.isTouchExplorationEnabled(getContext())) { + int attachmentCount = mBinding.getData().getReadOnlyAttachments().size() + + mBinding.getData().getReadOnlyPendingAttachments().size(); + final String announcement = getContext().getResources().getQuantityString( + R.plurals.attachment_changed_accessibility_announcement, + attachmentCount, attachmentCount); + AccessibilityUtil.announceForAccessibilityCompat( + this, null, announcement); + } + } + + @Override + public void resumeComposeMessage() { + mComposeEditText.requestFocus(); + mInputManager.showHideImeKeyboard(true, true); + announceAttachmentState(); + } + + public void clearAttachments() { + mBinding.getData().clearAttachments(mHost.getAttachmentsClearedFlags()); + mHost.onAttachmentsCleared(); + } + + public void requestDraftMessage(boolean clearLocalDraft) { + mBinding.getData().loadFromStorage(mBinding, null, clearLocalDraft); + } + + public void setDraftMessage(final MessageData message) { + mBinding.getData().loadFromStorage(mBinding, message, false); + } + + public void writeDraftMessage() { + final String messageText = mComposeEditText.getText().toString(); + mBinding.getData().setMessageText(messageText); + + final String subject = mComposeSubjectText.getText().toString(); + mBinding.getData().setMessageSubject(subject); + + mBinding.getData().saveToStorage(mBinding); + } + + private void updateConversationSelfId(final String selfId, final boolean notify) { + mBinding.getData().setSelfId(selfId, notify); + } + + private Uri getSelfSendButtonIconUri() { + final Uri overridenSelfUri = mHost.getSelfSendButtonIconUri(); + if (overridenSelfUri != null) { + return overridenSelfUri; + } + final SubscriptionListEntry subscriptionListEntry = getSelfSubscriptionListEntry(); + + if (subscriptionListEntry != null) { + return subscriptionListEntry.selectedIconUri; + } + + // Fall back to default self-avatar in the base case. + final ParticipantData self = mConversationDataModel.getData().getDefaultSelfParticipant(); + return self == null ? null : AvatarUriUtil.createAvatarUri(self); + } + + private SubscriptionListEntry getSelfSubscriptionListEntry() { + return mConversationDataModel.getData().getSubscriptionEntryForSelfParticipant( + mBinding.getData().getSelfId(), false /* excludeDefault */); + } + + private boolean isDataLoadedForMessageSend() { + // Check data loading prerequisites for sending a message. + return mConversationDataModel != null && mConversationDataModel.isBound() && + mConversationDataModel.getData().getParticipantsLoaded(); + } + + private void updateVisualsOnDraftChanged() { + final String messageText = mComposeEditText.getText().toString(); + final DraftMessageData draftMessageData = mBinding.getData(); + draftMessageData.setMessageText(messageText); + + final String subject = mComposeSubjectText.getText().toString(); + draftMessageData.setMessageSubject(subject); + if (!TextUtils.isEmpty(subject)) { + mSubjectView.setVisibility(View.VISIBLE); + } + + final boolean hasMessageText = (TextUtils.getTrimmedLength(messageText) > 0); + final boolean hasSubject = (TextUtils.getTrimmedLength(subject) > 0); + final boolean hasWorkingDraft = hasMessageText || hasSubject || + mBinding.getData().hasAttachments(); + + // Update the SMS text counter. + final int messageCount = draftMessageData.getNumMessagesToBeSent(); + final int codePointsRemaining = draftMessageData.getCodePointsRemainingInCurrentMessage(); + // Show the counter only if: + // - We are not in MMS mode + // - We are going to send more than one message OR we are getting close + boolean showCounter = false; + if (!draftMessageData.getIsMms() && (messageCount > 1 || + codePointsRemaining <= CODEPOINTS_REMAINING_BEFORE_COUNTER_SHOWN)) { + showCounter = true; + } + + if (showCounter) { + // Update the remaining characters and number of messages required. + final String counterText = messageCount > 1 ? codePointsRemaining + " / " + + messageCount : String.valueOf(codePointsRemaining); + mCharCounter.setText(counterText); + mCharCounter.setVisibility(View.VISIBLE); + } else { + mCharCounter.setVisibility(View.INVISIBLE); + } + + // Update the send message button. Self icon uri might be null if self participant data + // and/or conversation metadata hasn't been loaded by the host. + final Uri selfSendButtonUri = getSelfSendButtonIconUri(); + int sendWidgetMode = SEND_WIDGET_MODE_SELF_AVATAR; + if (selfSendButtonUri != null) { + if (hasWorkingDraft && isDataLoadedForMessageSend()) { + UiUtils.revealOrHideViewWithAnimation(mSendButton, VISIBLE, null); + if (isOverriddenAvatarAGroup()) { + // If the host has overriden the avatar to show a group avatar where the + // send button sits, we have to hide the group avatar because it can be larger + // than the send button and pieces of the avatar will stick out from behind + // the send button. + UiUtils.revealOrHideViewWithAnimation(mSelfSendIcon, GONE, null); + } + mMmsIndicator.setVisibility(draftMessageData.getIsMms() ? VISIBLE : INVISIBLE); + sendWidgetMode = SEND_WIDGET_MODE_SEND_BUTTON; + } else { + mSelfSendIcon.setImageResourceUri(selfSendButtonUri); + if (isOverriddenAvatarAGroup()) { + UiUtils.revealOrHideViewWithAnimation(mSelfSendIcon, VISIBLE, null); + } + UiUtils.revealOrHideViewWithAnimation(mSendButton, GONE, null); + mMmsIndicator.setVisibility(INVISIBLE); + if (shouldShowSimSelector(mConversationDataModel.getData())) { + sendWidgetMode = SEND_WIDGET_MODE_SIM_SELECTOR; + } + } + } else { + mSelfSendIcon.setImageResourceUri(null); + } + + if (mSendWidgetMode != sendWidgetMode || sendWidgetMode == SEND_WIDGET_MODE_SIM_SELECTOR) { + setSendButtonAccessibility(sendWidgetMode); + mSendWidgetMode = sendWidgetMode; + } + + // Update the text hint on the message box depending on the attachment type. + final List attachments = draftMessageData.getReadOnlyAttachments(); + final int attachmentCount = attachments.size(); + if (attachmentCount == 0) { + final SubscriptionListEntry subscriptionListEntry = + mConversationDataModel.getData().getSubscriptionEntryForSelfParticipant( + mBinding.getData().getSelfId(), false /* excludeDefault */); + if (subscriptionListEntry == null) { + mComposeEditText.setHint(R.string.compose_message_view_hint_text); + } else { + mComposeEditText.setHint(Html.fromHtml(getResources().getString( + R.string.compose_message_view_hint_text_multi_sim, + subscriptionListEntry.displayName))); + } + } else { + int type = -1; + for (final MessagePartData attachment : attachments) { + int newType; + if (attachment.isImage()) { + newType = ContentType.TYPE_IMAGE; + } else if (attachment.isAudio()) { + newType = ContentType.TYPE_AUDIO; + } else if (attachment.isVideo()) { + newType = ContentType.TYPE_VIDEO; + } else if (attachment.isVCard()) { + newType = ContentType.TYPE_VCARD; + } else { + newType = ContentType.TYPE_OTHER; + } + + if (type == -1) { + type = newType; + } else if (type != newType || type == ContentType.TYPE_OTHER) { + type = ContentType.TYPE_OTHER; + break; + } + } + + switch (type) { + case ContentType.TYPE_IMAGE: + mComposeEditText.setHint(getResources().getQuantityString( + R.plurals.compose_message_view_hint_text_photo, attachmentCount)); + break; + + case ContentType.TYPE_AUDIO: + mComposeEditText.setHint(getResources().getQuantityString( + R.plurals.compose_message_view_hint_text_audio, attachmentCount)); + break; + + case ContentType.TYPE_VIDEO: + mComposeEditText.setHint(getResources().getQuantityString( + R.plurals.compose_message_view_hint_text_video, attachmentCount)); + break; + + case ContentType.TYPE_VCARD: + mComposeEditText.setHint(getResources().getQuantityString( + R.plurals.compose_message_view_hint_text_vcard, attachmentCount)); + break; + + case ContentType.TYPE_OTHER: + mComposeEditText.setHint(getResources().getQuantityString( + R.plurals.compose_message_view_hint_text_attachments, attachmentCount)); + break; + + default: + Assert.fail("Unsupported attachment type!"); + break; + } + } + } + + private void setSendButtonAccessibility(final int sendWidgetMode) { + switch (sendWidgetMode) { + case SEND_WIDGET_MODE_SELF_AVATAR: + // No send button and no SIM selector; the self send button is no longer + // important for accessibility. + mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + mSelfSendIcon.setContentDescription(null); + mSendButton.setVisibility(View.GONE); + setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SELF_AVATAR); + break; + + case SEND_WIDGET_MODE_SIM_SELECTOR: + mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); + mSelfSendIcon.setContentDescription(getSimContentDescription()); + setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SIM_SELECTOR); + break; + + case SEND_WIDGET_MODE_SEND_BUTTON: + mMmsIndicator.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + mMmsIndicator.setContentDescription(null); + setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SEND_BUTTON); + break; + } + } + + private String getSimContentDescription() { + final SubscriptionListEntry sub = getSelfSubscriptionListEntry(); + if (sub != null) { + return getResources().getString( + R.string.sim_selector_button_content_description_with_selection, + sub.displayName); + } else { + return getResources().getString( + R.string.sim_selector_button_content_description); + } + } + + // Set accessibility traversal order of the components in the send widget. + private void setSendWidgetAccessibilityTraversalOrder(final int mode) { + if (OsUtil.isAtLeastL_MR1()) { + mAttachMediaButton.setAccessibilityTraversalBefore(R.id.compose_message_text); + switch (mode) { + case SEND_WIDGET_MODE_SIM_SELECTOR: + mComposeEditText.setAccessibilityTraversalBefore(R.id.self_send_icon); + break; + case SEND_WIDGET_MODE_SEND_BUTTON: + mComposeEditText.setAccessibilityTraversalBefore(R.id.send_message_button); + break; + default: + break; + } + } + } + + @Override + public void afterTextChanged(final Editable editable) { + } + + @Override + public void beforeTextChanged(final CharSequence s, final int start, final int count, + final int after) { + if (mHost.shouldHideAttachmentsWhenSimSelectorShown()) { + hideSimSelector(); + } + } + + private void hideSimSelector() { + if (mInputManager.showHideSimSelector(false /* show */, true /* animate */)) { + // Now that the sim selector has been hidden, reshow the attachments if they + // have been hidden. + hideAttachmentsWhenShowingSims(false /*simPickerVisible*/); + } + } + + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, + final int count) { + final BugleActionBarActivity activity = (mOriginalContext instanceof BugleActionBarActivity) + ? (BugleActionBarActivity) mOriginalContext : null; + if (activity != null && activity.getIsDestroyed()) { + LogUtil.v(LogUtil.BUGLE_TAG, "got onTextChanged after onDestroy"); + + // if we get onTextChanged after the activity is destroyed then, ah, wtf + // b/18176615 + // This appears to have occurred as the result of orientation change. + return; + } + + mBinding.ensureBound(); + updateVisualsOnDraftChanged(); + } + + @Override + public PlainTextEditText getComposeEditText() { + return mComposeEditText; + } + + public void displayPhoto(final Uri photoUri, final Rect imageBounds) { + mHost.displayPhoto(photoUri, imageBounds, true /* isDraft */); + } + + public void updateConversationSelfIdOnExternalChange(final String selfId) { + updateConversationSelfId(selfId, true /* notify */); + } + + /** + * The selfId of the conversation. As soon as the DraftMessageData successfully loads (i.e. + * getSelfId() is non-null), the selfId in DraftMessageData is treated as the sole source + * of truth for conversation self id since it reflects any pending self id change the user + * makes in the UI. + */ + public String getConversationSelfId() { + return mBinding.getData().getSelfId(); + } + + public void selectSim(SubscriptionListEntry subscriptionData) { + final String oldSelfId = getConversationSelfId(); + final String newSelfId = subscriptionData.selfParticipantId; + Assert.notNull(newSelfId); + // Don't attempt to change self if self hasn't been loaded, or if self hasn't changed. + if (oldSelfId == null || TextUtils.equals(oldSelfId, newSelfId)) { + return; + } + updateConversationSelfId(newSelfId, true /* notify */); + } + + public void hideAllComposeInputs(final boolean animate) { + mInputManager.hideAllInputs(animate); + } + + public void saveInputState(final Bundle outState) { + mInputManager.onSaveInputState(outState); + } + + public void resetMediaPickerState() { + mInputManager.resetMediaPickerState(); + } + + public boolean onBackPressed() { + return mInputManager.onBackPressed(); + } + + public boolean onNavigationUpPressed() { + return mInputManager.onNavigationUpPressed(); + } + + public boolean updateActionBar(final ActionBar actionBar) { + return mInputManager != null ? mInputManager.updateActionBar(actionBar) : false; + } + + public static boolean shouldShowSimSelector(final ConversationData convData) { + return OsUtil.isAtLeastL_MR1() && + convData.getSelfParticipantsCountExcludingDefault(true /* activeOnly */) > 1; + } + + public void sendMessageIgnoreMessageSizeLimit() { + sendMessageInternal(false /* checkMessageSize */); + } + + public void onAttachmentPreviewLongClicked() { + mHost.showAttachmentChooser(); + } + + @Override + public void onDraftAttachmentLoadFailed() { + mHost.notifyOfAttachmentLoadFailed(); + } + + private boolean isOverriddenAvatarAGroup() { + final Uri overridenSelfUri = mHost.getSelfSendButtonIconUri(); + if (overridenSelfUri == null) { + return false; + } + return AvatarUriUtil.TYPE_GROUP_URI.equals(AvatarUriUtil.getAvatarType(overridenSelfUri)); + } + + @Override + public void setAccessibility(boolean enabled) { + if (enabled) { + mAttachMediaButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); + mComposeEditText.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); + mSendButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); + setSendButtonAccessibility(mSendWidgetMode); + } else { + mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + mComposeEditText.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + mSendButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + mAttachMediaButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + } + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationActivity.java b/src/com/android/messaging/ui/conversation/ConversationActivity.java new file mode 100644 index 0000000..66310ea --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationActivity.java @@ -0,0 +1,379 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversation; + +import android.app.FragmentManager; +import android.app.FragmentTransaction; +import android.content.Intent; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.text.TextUtils; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.contact.ContactPickerFragment; +import com.android.messaging.ui.contact.ContactPickerFragment.ContactPickerFragmentHost; +import com.android.messaging.ui.conversation.ConversationActivityUiState.ConversationActivityUiStateHost; +import com.android.messaging.ui.conversation.ConversationFragment.ConversationFragmentHost; +import com.android.messaging.ui.conversationlist.ConversationListActivity; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.UiUtils; + +public class ConversationActivity extends BugleActionBarActivity + implements ContactPickerFragmentHost, ConversationFragmentHost, + ConversationActivityUiStateHost { + public static final int FINISH_RESULT_CODE = 1; + private static final String SAVED_INSTANCE_STATE_UI_STATE_KEY = "uistate"; + + private ConversationActivityUiState mUiState; + + // Fragment transactions cannot be performed after onSaveInstanceState() has been called since + // it will cause state loss. We don't want to call commitAllowingStateLoss() since it's + // dangerous. Therefore, we note when instance state is saved and avoid performing UI state + // updates concerning fragments past that point. + private boolean mInstanceStateSaved; + + // Tracks whether onPause is called. + private boolean mIsPaused; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.conversation_activity); + + final Intent intent = getIntent(); + + // Do our best to restore UI state from saved instance state. + if (savedInstanceState != null) { + mUiState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_UI_STATE_KEY); + } else { + if (intent. + getBooleanExtra(UIIntents.UI_INTENT_EXTRA_GOTO_CONVERSATION_LIST, false)) { + // See the comment in BugleWidgetService.getViewMoreConversationsView() why this + // is unfortunately necessary. The Bugle desktop widget can display a list of + // conversations. When there are more conversations that can be displayed in + // the widget, the last item is a "More conversations" item. The way widgets + // are built, the list items can only go to a single fill-in intent which points + // to this ConversationActivity. When the user taps on "More conversations", we + // really want to go to the ConversationList. This code makes that possible. + finish(); + final Intent convListIntent = new Intent(this, ConversationListActivity.class); + convListIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(convListIntent); + return; + } + } + + // If saved instance state doesn't offer a clue, get the info from the intent. + if (mUiState == null) { + final String conversationId = intent.getStringExtra( + UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); + mUiState = new ConversationActivityUiState(conversationId); + } + mUiState.setHost(this); + mInstanceStateSaved = false; + + // Don't animate UI state change for initial setup. + updateUiState(false /* animate */); + + // See if we're getting called from a widget to directly display an image or video + final String extraToDisplay = + intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_URI); + if (!TextUtils.isEmpty(extraToDisplay)) { + final String contentType = + intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_TYPE); + final Rect bounds = UiUtils.getMeasuredBoundsOnScreen( + findViewById(R.id.conversation_and_compose_container)); + if (ContentType.isImageType(contentType)) { + final Uri imagesUri = MessagingContentProvider.buildConversationImagesUri( + mUiState.getConversationId()); + UIIntents.get().launchFullScreenPhotoViewer( + this, Uri.parse(extraToDisplay), bounds, imagesUri); + } else if (ContentType.isVideoType(contentType)) { + UIIntents.get().launchFullScreenVideoViewer(this, Uri.parse(extraToDisplay)); + } + } + } + + @Override + protected void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + // After onSaveInstanceState() is called, future changes to mUiState won't update the UI + // anymore, because fragment transactions are not allowed past this point. + // For an activity recreation due to orientation change, the saved instance state keeps + // using the in-memory copy of the UI state instead of writing it to parcel as an + // optimization, so the UI state values may still change in response to, for example, + // focus change from the framework, making mUiState and actual UI inconsistent. + // Therefore, save an exact "snapshot" (clone) of the UI state object to make sure the + // restored UI state ALWAYS matches the actual restored UI components. + outState.putParcelable(SAVED_INSTANCE_STATE_UI_STATE_KEY, mUiState.clone()); + mInstanceStateSaved = true; + } + + @Override + protected void onResume() { + super.onResume(); + + // we need to reset the mInstanceStateSaved flag since we may have just been restored from + // a previous onStop() instead of an onDestroy(). + mInstanceStateSaved = false; + mIsPaused = false; + } + + @Override + protected void onPause() { + super.onPause(); + mIsPaused = true; + } + + @Override + public void onWindowFocusChanged(final boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + final ConversationFragment conversationFragment = getConversationFragment(); + // When the screen is turned on, the last used activity gets resumed, but it gets + // window focus only after the lock screen is unlocked. + if (hasFocus && conversationFragment != null) { + conversationFragment.setConversationFocus(); + } + } + + @Override + public void onDisplayHeightChanged(final int heightSpecification) { + super.onDisplayHeightChanged(heightSpecification); + invalidateActionBar(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (mUiState != null) { + mUiState.setHost(null); + } + } + + @Override + public void updateActionBar(final ActionBar actionBar) { + super.updateActionBar(actionBar); + final ConversationFragment conversation = getConversationFragment(); + final ContactPickerFragment contactPicker = getContactPicker(); + if (contactPicker != null && mUiState.shouldShowContactPickerFragment()) { + contactPicker.updateActionBar(actionBar); + } else if (conversation != null && mUiState.shouldShowConversationFragment()) { + conversation.updateActionBar(actionBar); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem menuItem) { + if (super.onOptionsItemSelected(menuItem)) { + return true; + } + if (menuItem.getItemId() == android.R.id.home) { + onNavigationUpPressed(); + return true; + } + return false; + } + + public void onNavigationUpPressed() { + // Let the conversation fragment handle the navigation up press. + final ConversationFragment conversationFragment = getConversationFragment(); + if (conversationFragment != null && conversationFragment.onNavigationUpPressed()) { + return; + } + onFinishCurrentConversation(); + } + + @Override + public void onBackPressed() { + // If action mode is active dismiss it + if (getActionMode() != null) { + dismissActionMode(); + return; + } + + // Let the conversation fragment handle the back press. + final ConversationFragment conversationFragment = getConversationFragment(); + if (conversationFragment != null && conversationFragment.onBackPressed()) { + return; + } + super.onBackPressed(); + } + + private ContactPickerFragment getContactPicker() { + return (ContactPickerFragment) getFragmentManager().findFragmentByTag( + ContactPickerFragment.FRAGMENT_TAG); + } + + private ConversationFragment getConversationFragment() { + return (ConversationFragment) getFragmentManager().findFragmentByTag( + ConversationFragment.FRAGMENT_TAG); + } + + @Override // From ContactPickerFragmentHost + public void onGetOrCreateNewConversation(final String conversationId) { + Assert.isTrue(conversationId != null); + mUiState.onGetOrCreateConversation(conversationId); + } + + @Override // From ContactPickerFragmentHost + public void onBackButtonPressed() { + onBackPressed(); + } + + @Override // From ContactPickerFragmentHost + public void onInitiateAddMoreParticipants() { + mUiState.onAddMoreParticipants(); + } + + + @Override + public void onParticipantCountChanged(final boolean canAddMoreParticipants) { + mUiState.onParticipantCountUpdated(canAddMoreParticipants); + } + + @Override // From ConversationFragmentHost + public void onStartComposeMessage() { + mUiState.onStartMessageCompose(); + } + + @Override // From ConversationFragmentHost + public void onConversationMetadataUpdated() { + invalidateActionBar(); + } + + @Override // From ConversationFragmentHost + public void onConversationMessagesUpdated(final int numberOfMessages) { + } + + @Override // From ConversationFragmentHost + public void onConversationParticipantDataLoaded(final int numberOfParticipants) { + } + + @Override // From ConversationFragmentHost + public boolean isActiveAndFocused() { + return !mIsPaused && hasWindowFocus(); + } + + @Override // From ConversationActivityUiStateListener + public void onConversationContactPickerUiStateChanged(final int oldState, final int newState, + final boolean animate) { + Assert.isTrue(oldState != newState); + updateUiState(animate); + } + + private void updateUiState(final boolean animate) { + if (mInstanceStateSaved || mIsPaused) { + return; + } + Assert.notNull(mUiState); + final Intent intent = getIntent(); + final String conversationId = mUiState.getConversationId(); + + final FragmentManager fragmentManager = getFragmentManager(); + final FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); + + final boolean needConversationFragment = mUiState.shouldShowConversationFragment(); + final boolean needContactPickerFragment = mUiState.shouldShowContactPickerFragment(); + ConversationFragment conversationFragment = getConversationFragment(); + + // Set up the conversation fragment. + if (needConversationFragment) { + Assert.notNull(conversationId); + if (conversationFragment == null) { + conversationFragment = new ConversationFragment(); + fragmentTransaction.add(R.id.conversation_fragment_container, + conversationFragment, ConversationFragment.FRAGMENT_TAG); + } + final MessageData draftData = intent.getParcelableExtra( + UIIntents.UI_INTENT_EXTRA_DRAFT_DATA); + if (!needContactPickerFragment) { + // Once the user has committed the audience,remove the draft data from the + // intent to prevent reuse + intent.removeExtra(UIIntents.UI_INTENT_EXTRA_DRAFT_DATA); + } + conversationFragment.setHost(this); + conversationFragment.setConversationInfo(this, conversationId, draftData); + } else if (conversationFragment != null) { + // Don't save draft to DB when removing conversation fragment and switching to + // contact picking mode. The draft is intended for the new group. + conversationFragment.suppressWriteDraft(); + fragmentTransaction.remove(conversationFragment); + } + + // Set up the contact picker fragment. + ContactPickerFragment contactPickerFragment = getContactPicker(); + if (needContactPickerFragment) { + if (contactPickerFragment == null) { + contactPickerFragment = new ContactPickerFragment(); + fragmentTransaction.add(R.id.contact_picker_fragment_container, + contactPickerFragment, ContactPickerFragment.FRAGMENT_TAG); + } + contactPickerFragment.setHost(this); + contactPickerFragment.setContactPickingMode(mUiState.getDesiredContactPickingMode(), + animate); + } else if (contactPickerFragment != null) { + fragmentTransaction.remove(contactPickerFragment); + } + + fragmentTransaction.commit(); + invalidateActionBar(); + } + + @Override + public void onFinishCurrentConversation() { + // Simply finish the current activity. The current design is to leave any empty + // conversations as is. + if (OsUtil.isAtLeastL()) { + finishAfterTransition(); + } else { + finish(); + } + } + + @Override + public boolean shouldResumeComposeMessage() { + return mUiState.shouldResumeComposeMessage(); + } + + @Override + protected void onActivityResult(final int requestCode, final int resultCode, + final Intent data) { + if (requestCode == ConversationFragment.REQUEST_CHOOSE_ATTACHMENTS && + resultCode == RESULT_OK) { + final ConversationFragment conversationFragment = getConversationFragment(); + if (conversationFragment != null) { + conversationFragment.onAttachmentChoosen(); + } else { + LogUtil.e(LogUtil.BUGLE_TAG, "ConversationFragment is missing after launching " + + "AttachmentChooserActivity!"); + } + } else if (resultCode == FINISH_RESULT_CODE) { + finish(); + } + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationActivityUiState.java b/src/com/android/messaging/ui/conversation/ConversationActivityUiState.java new file mode 100644 index 0000000..1469c93 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationActivityUiState.java @@ -0,0 +1,306 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversation; + +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.ui.contact.ContactPickerFragment; +import com.android.messaging.util.Assert; +import com.google.common.annotations.VisibleForTesting; + +/** + * Keeps track of the different UI states that the ConversationActivity may be in. This acts as + * a state machine which, based on different actions (e.g. onAddMoreParticipants), notifies the + * ConversationActivity about any state UI change so it can update the visuals. This class + * implements Parcelable and it's persisted across activity tear down and relaunch. + */ +public class ConversationActivityUiState implements Parcelable, Cloneable { + interface ConversationActivityUiStateHost { + void onConversationContactPickerUiStateChanged(int oldState, int newState, boolean animate); + } + + /*------ Overall UI states (conversation & contact picker) ------*/ + + /** Only a full screen conversation is showing. */ + public static final int STATE_CONVERSATION_ONLY = 1; + /** Only a full screen contact picker is showing asking user to pick the initial contact. */ + public static final int STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT = 2; + /** + * Only a full screen contact picker is showing asking user to pick more participants. This + * happens after the user picked the initial contact, and then decide to go back and add more. + */ + public static final int STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS = 3; + /** + * Only a full screen contact picker is showing asking user to pick more participants. However + * user has reached max number of conversation participants and can add no more. + */ + public static final int STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS = 4; + /** + * A hybrid mode where the conversation view + contact chips view are showing. This happens + * right after the user picked the initial contact for which a 1-1 conversation is fetched or + * created. + */ + public static final int STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW = 5; + + // The overall UI state of the ConversationActivity. + private int mConversationContactUiState; + + // The currently displayed conversation (if any). + private String mConversationId; + + // Indicates whether we should put focus in the compose message view when the + // ConversationFragment is attached. This is a transient state that's not persisted as + // part of the parcelable. + private boolean mPendingResumeComposeMessage = false; + + // The owner ConversationActivity. This is not parceled since the instance always change upon + // object reuse. + private ConversationActivityUiStateHost mHost; + + // Indicates the owning ConverastionActivity is in the process of updating its UI presentation + // to be in sync with the UI states. Outside of the UI updates, the UI states here should + // ALWAYS be consistent with the actual states of the activity. + private int mUiUpdateCount; + + /** + * Create a new instance with an initial conversation id. + */ + ConversationActivityUiState(final String conversationId) { + // The conversation activity may be initialized with only one of two states: + // Conversation-only (when there's a conversation id) or picking initial contact + // (when no conversation id is given). + mConversationId = conversationId; + mConversationContactUiState = conversationId == null ? + STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT : STATE_CONVERSATION_ONLY; + } + + public void setHost(final ConversationActivityUiStateHost host) { + mHost = host; + } + + public boolean shouldShowConversationFragment() { + return mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW || + mConversationContactUiState == STATE_CONVERSATION_ONLY; + } + + public boolean shouldShowContactPickerFragment() { + return mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS || + mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS || + mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT || + mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW; + } + + /** + * Returns whether there's a pending request to resume message compose (i.e. set focus to + * the compose message view and show the soft keyboard). If so, this request will be served + * when the conversation fragment get created and resumed. This happens when the user commits + * participant selection for a group conversation and goes back to the conversation fragment. + * Since conversation fragment creation happens asynchronously, we issue and track this + * pending request for it to be eventually fulfilled. + */ + public boolean shouldResumeComposeMessage() { + if (mPendingResumeComposeMessage) { + // This is a one-shot operation that just keeps track of the pending resume compose + // state. This is also a non-critical operation so we don't care about failure case. + mPendingResumeComposeMessage = false; + return true; + } + return false; + } + + public int getDesiredContactPickingMode() { + switch (mConversationContactUiState) { + case STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS: + return ContactPickerFragment.MODE_PICK_MORE_CONTACTS; + case STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS: + return ContactPickerFragment.MODE_PICK_MAX_PARTICIPANTS; + case STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT: + return ContactPickerFragment.MODE_PICK_INITIAL_CONTACT; + case STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW: + return ContactPickerFragment.MODE_CHIPS_ONLY; + default: + Assert.fail("Invalid contact picking mode for ConversationActivity!"); + return ContactPickerFragment.MODE_UNDEFINED; + } + } + + public String getConversationId() { + return mConversationId; + } + + /** + * Called whenever the contact picker fragment successfully fetched or created a conversation. + */ + public void onGetOrCreateConversation(final String conversationId) { + int newState = STATE_CONVERSATION_ONLY; + if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT) { + newState = STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW; + } else if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS || + mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS) { + newState = STATE_CONVERSATION_ONLY; + } else { + // New conversation should only be created when we are in one of the contact picking + // modes. + Assert.fail("Invalid conversation activity state: can't create conversation!"); + } + mConversationId = conversationId; + performUiStateUpdate(newState, true); + } + + /** + * Called when the user started composing message. If we are in the hybrid chips state, we + * should commit to enter the conversation only state. + */ + public void onStartMessageCompose() { + // This cannot happen when we are in one of the full-screen contact picking states. + Assert.isTrue(mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT && + mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS && + mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS); + if (mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW) { + performUiStateUpdate(STATE_CONVERSATION_ONLY, true); + } + } + + /** + * Called when the user initiated an action to add more participants in the hybrid state, + * namely clicking on the "add more participants" button or entered a new contact chip via + * auto-complete. + */ + public void onAddMoreParticipants() { + if (mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW) { + mPendingResumeComposeMessage = true; + performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS, true); + } else { + // This is only possible in the hybrid state. + Assert.fail("Invalid conversation activity state: can't add more participants!"); + } + } + + /** + * Called each time the number of participants is updated to check against the limit and + * update the ui state accordingly. + */ + public void onParticipantCountUpdated(final boolean canAddMoreParticipants) { + if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS + && !canAddMoreParticipants) { + performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS, false); + } else if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS + && canAddMoreParticipants) { + performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS, false); + } + } + + private void performUiStateUpdate(final int conversationContactState, final boolean animate) { + // This starts one UI update cycle, during which we allow the conversation activity's + // UI presentation to be temporarily out of sync with the states here. + beginUiUpdate(); + + if (conversationContactState != mConversationContactUiState) { + final int oldState = mConversationContactUiState; + mConversationContactUiState = conversationContactState; + notifyOnOverallUiStateChanged(oldState, mConversationContactUiState, animate); + } + endUiUpdate(); + } + + private void notifyOnOverallUiStateChanged( + final int oldState, final int newState, final boolean animate) { + // Always verify state validity whenever we have a state change. + assertValidState(); + Assert.isTrue(isUiUpdateInProgress()); + + // Only do this if we are still attached to the host. mHost can be null if the host + // activity is already destroyed, but due to timing the contained UI components may still + // receive events such as focus change and trigger a callback to the Ui state. We'd like + // to guard against those cases. + if (mHost != null) { + mHost.onConversationContactPickerUiStateChanged(oldState, newState, animate); + } + } + + private void assertValidState() { + // Conversation id may be null IF AND ONLY IF the user is picking the initial contact to + // start a conversation. + Assert.isTrue((mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT) == + (mConversationId == null)); + } + + private void beginUiUpdate() { + mUiUpdateCount++; + } + + private void endUiUpdate() { + if (--mUiUpdateCount < 0) { + Assert.fail("Unbalanced Ui updates!"); + } + } + + private boolean isUiUpdateInProgress() { + return mUiUpdateCount > 0; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeInt(mConversationContactUiState); + dest.writeString(mConversationId); + } + + private ConversationActivityUiState(final Parcel in) { + mConversationContactUiState = in.readInt(); + mConversationId = in.readString(); + + // Always verify state validity whenever we initialize states. + assertValidState(); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public ConversationActivityUiState createFromParcel(final Parcel in) { + return new ConversationActivityUiState(in); + } + + @Override + public ConversationActivityUiState[] newArray(final int size) { + return new ConversationActivityUiState[size]; + } + }; + + @Override + protected ConversationActivityUiState clone() { + try { + return (ConversationActivityUiState) super.clone(); + } catch (CloneNotSupportedException e) { + Assert.fail("ConversationActivityUiState: failed to clone(). Is there a mutable " + + "reference?"); + } + return null; + } + + /** + * allows for overridding the internal UI state. Should never be called except by test code. + */ + @VisibleForTesting + void testSetUiState(final int uiState) { + mConversationContactUiState = uiState; + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationFastScroller.java b/src/com/android/messaging/ui/conversation/ConversationFastScroller.java new file mode 100644 index 0000000..b15f05a --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationFastScroller.java @@ -0,0 +1,489 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversation; + +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Rect; +import android.graphics.drawable.StateListDrawable; +import android.os.Handler; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.RecyclerView.AdapterDataObserver; +import android.support.v7.widget.RecyclerView.ViewHolder; +import android.util.StateSet; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.MeasureSpec; +import android.view.View.OnLayoutChangeListener; +import android.view.ViewGroupOverlay; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ConversationMessageData; +import com.android.messaging.ui.ConversationDrawables; +import com.android.messaging.util.Dates; +import com.android.messaging.util.OsUtil; + +/** + * Adds a "fast-scroll" bar to the conversation RecyclerView that shows the current position within + * the conversation and allows quickly moving to another position by dragging the scrollbar thumb + * up or down. As the thumb is dragged, we show a floating bubble alongside it that shows the + * date/time of the first visible message at the current position. + */ +public class ConversationFastScroller extends RecyclerView.OnScrollListener implements + OnLayoutChangeListener, RecyclerView.OnItemTouchListener { + + /** + * Creates a {@link ConversationFastScroller} instance, attached to the provided + * {@link RecyclerView}. + * + * @param rv the conversation RecyclerView + * @param position where the scrollbar should appear (either {@code POSITION_RIGHT_SIDE} or + * {@code POSITION_LEFT_SIDE}) + * @return a new ConversationFastScroller, or {@code null} if fast-scrolling is not supported + * (the feature requires Jellybean MR2 or newer) + */ + public static ConversationFastScroller addTo(RecyclerView rv, int position) { + if (OsUtil.isAtLeastJB_MR2()) { + return new ConversationFastScroller(rv, position); + } + return null; + } + + public static final int POSITION_RIGHT_SIDE = 0; + public static final int POSITION_LEFT_SIDE = 1; + + private static final int MIN_PAGES_TO_ENABLE = 7; + private static final int SHOW_ANIMATION_DURATION_MS = 150; + private static final int HIDE_ANIMATION_DURATION_MS = 300; + private static final int HIDE_DELAY_MS = 1500; + + private final Context mContext; + private final RecyclerView mRv; + private final ViewGroupOverlay mOverlay; + private final ImageView mTrackImageView; + private final ImageView mThumbImageView; + private final TextView mPreviewTextView; + + private final int mTrackWidth; + private final int mThumbHeight; + private final int mPreviewHeight; + private final int mPreviewMinWidth; + private final int mPreviewMarginTop; + private final int mPreviewMarginLeftRight; + private final int mTouchSlop; + + private final Rect mContainer = new Rect(); + private final Handler mHandler = new Handler(); + + // Whether to render the scrollbar on the right side (otherwise it'll be on the left). + private final boolean mPosRight; + + // Whether the scrollbar is currently visible (it may still be animating). + private boolean mVisible = false; + + // Whether we are waiting to hide the scrollbar (i.e. scrolling has stopped). + private boolean mPendingHide = false; + + // Whether the user is currently dragging the thumb up or down. + private boolean mDragging = false; + + // Animations responsible for hiding the scrollbar & preview. May be null. + private AnimatorSet mHideAnimation; + private ObjectAnimator mHidePreviewAnimation; + + private final Runnable mHideTrackRunnable = new Runnable() { + @Override + public void run() { + hide(true /* animate */); + mPendingHide = false; + } + }; + + private ConversationFastScroller(RecyclerView rv, int position) { + mContext = rv.getContext(); + mRv = rv; + mRv.addOnLayoutChangeListener(this); + mRv.addOnScrollListener(this); + mRv.addOnItemTouchListener(this); + mRv.getAdapter().registerAdapterDataObserver(new AdapterDataObserver() { + @Override + public void onChanged() { + updateScrollPos(); + } + }); + mPosRight = (position == POSITION_RIGHT_SIDE); + + // Cache the dimensions we'll need during layout + final Resources res = mContext.getResources(); + mTrackWidth = res.getDimensionPixelSize(R.dimen.fastscroll_track_width); + mThumbHeight = res.getDimensionPixelSize(R.dimen.fastscroll_thumb_height); + mPreviewHeight = res.getDimensionPixelSize(R.dimen.fastscroll_preview_height); + mPreviewMinWidth = res.getDimensionPixelSize(R.dimen.fastscroll_preview_min_width); + mPreviewMarginTop = res.getDimensionPixelOffset(R.dimen.fastscroll_preview_margin_top); + mPreviewMarginLeftRight = res.getDimensionPixelOffset( + R.dimen.fastscroll_preview_margin_left_right); + mTouchSlop = res.getDimensionPixelOffset(R.dimen.fastscroll_touch_slop); + + final LayoutInflater inflator = LayoutInflater.from(mContext); + mTrackImageView = (ImageView) inflator.inflate(R.layout.fastscroll_track, null); + mThumbImageView = (ImageView) inflator.inflate(R.layout.fastscroll_thumb, null); + mPreviewTextView = (TextView) inflator.inflate(R.layout.fastscroll_preview, null); + + refreshConversationThemeColor(); + + // Add the fast scroll views to the overlay, so they are rendered above the list + mOverlay = rv.getOverlay(); + mOverlay.add(mTrackImageView); + mOverlay.add(mThumbImageView); + mOverlay.add(mPreviewTextView); + + hide(false /* animate */); + mPreviewTextView.setAlpha(0f); + } + + public void refreshConversationThemeColor() { + mPreviewTextView.setBackground( + ConversationDrawables.get().getFastScrollPreviewDrawable(mPosRight)); + if (OsUtil.isAtLeastL()) { + final StateListDrawable drawable = new StateListDrawable(); + drawable.addState(new int[]{ android.R.attr.state_pressed }, + ConversationDrawables.get().getFastScrollThumbDrawable(true /* pressed */)); + drawable.addState(StateSet.WILD_CARD, + ConversationDrawables.get().getFastScrollThumbDrawable(false /* pressed */)); + mThumbImageView.setImageDrawable(drawable); + } else { + // Android pre-L doesn't seem to handle a StateListDrawable containing a tinted + // drawable (it's rendered in the filter base color, which is red), so fall back to + // just the regular (non-pressed) drawable. + mThumbImageView.setImageDrawable( + ConversationDrawables.get().getFastScrollThumbDrawable(false /* pressed */)); + } + } + + @Override + public void onScrollStateChanged(final RecyclerView view, final int newState) { + if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { + // Only show the scrollbar once the user starts scrolling + if (!mVisible && isEnabled()) { + show(); + } + cancelAnyPendingHide(); + } else if (newState == RecyclerView.SCROLL_STATE_IDLE && !mDragging) { + // Hide the scrollbar again after scrolling stops + hideAfterDelay(); + } + } + + private boolean isEnabled() { + final int range = mRv.computeVerticalScrollRange(); + final int extent = mRv.computeVerticalScrollExtent(); + + if (range == 0 || extent == 0) { + return false; // Conversation isn't long enough to scroll + } + // Only enable scrollbars for conversations long enough that they would require several + // flings to scroll through. + final float pages = (float) range / extent; + return (pages > MIN_PAGES_TO_ENABLE); + } + + private void show() { + if (mHideAnimation != null && mHideAnimation.isRunning()) { + mHideAnimation.cancel(); + } + // Slide the scrollbar in from the side + ObjectAnimator trackSlide = ObjectAnimator.ofFloat(mTrackImageView, View.TRANSLATION_X, 0); + ObjectAnimator thumbSlide = ObjectAnimator.ofFloat(mThumbImageView, View.TRANSLATION_X, 0); + AnimatorSet animation = new AnimatorSet(); + animation.playTogether(trackSlide, thumbSlide); + animation.setDuration(SHOW_ANIMATION_DURATION_MS); + animation.start(); + + mVisible = true; + updateScrollPos(); + } + + private void hideAfterDelay() { + cancelAnyPendingHide(); + mHandler.postDelayed(mHideTrackRunnable, HIDE_DELAY_MS); + mPendingHide = true; + } + + private void cancelAnyPendingHide() { + if (mPendingHide) { + mHandler.removeCallbacks(mHideTrackRunnable); + } + } + + private void hide(boolean animate) { + final int hiddenTranslationX = mPosRight ? mTrackWidth : -mTrackWidth; + if (animate) { + // Slide the scrollbar off to the side + ObjectAnimator trackSlide = ObjectAnimator.ofFloat(mTrackImageView, View.TRANSLATION_X, + hiddenTranslationX); + ObjectAnimator thumbSlide = ObjectAnimator.ofFloat(mThumbImageView, View.TRANSLATION_X, + hiddenTranslationX); + mHideAnimation = new AnimatorSet(); + mHideAnimation.playTogether(trackSlide, thumbSlide); + mHideAnimation.setDuration(HIDE_ANIMATION_DURATION_MS); + mHideAnimation.start(); + } else { + mTrackImageView.setTranslationX(hiddenTranslationX); + mThumbImageView.setTranslationX(hiddenTranslationX); + } + + mVisible = false; + } + + private void showPreview() { + if (mHidePreviewAnimation != null && mHidePreviewAnimation.isRunning()) { + mHidePreviewAnimation.cancel(); + } + mPreviewTextView.setAlpha(1f); + } + + private void hidePreview() { + mHidePreviewAnimation = ObjectAnimator.ofFloat(mPreviewTextView, View.ALPHA, 0f); + mHidePreviewAnimation.setDuration(HIDE_ANIMATION_DURATION_MS); + mHidePreviewAnimation.start(); + } + + @Override + public void onScrolled(final RecyclerView view, final int dx, final int dy) { + updateScrollPos(); + } + + private void updateScrollPos() { + if (!mVisible) { + return; + } + final int verticalScrollLength = mContainer.height() - mThumbHeight; + final int verticalScrollStart = mContainer.top + mThumbHeight / 2; + + final float scrollRatio = computeScrollRatio(); + final int thumbCenterY = verticalScrollStart + (int)(verticalScrollLength * scrollRatio); + layoutThumb(thumbCenterY); + + if (mDragging) { + updatePreviewText(); + layoutPreview(thumbCenterY); + } + } + + /** + * Returns the current position in the conversation, as a value between 0 and 1, inclusive. + * The top of the conversation is 0, the bottom is 1, the exact middle is 0.5, and so on. + */ + private float computeScrollRatio() { + final int range = mRv.computeVerticalScrollRange(); + final int extent = mRv.computeVerticalScrollExtent(); + int offset = mRv.computeVerticalScrollOffset(); + + if (range == 0 || extent == 0) { + // If the conversation doesn't scroll, we're at the bottom. + return 1.0f; + } + final int scrollRange = range - extent; + offset = Math.min(offset, scrollRange); + return offset / (float) scrollRange; + } + + private void updatePreviewText() { + final LinearLayoutManager lm = (LinearLayoutManager) mRv.getLayoutManager(); + final int pos = lm.findFirstVisibleItemPosition(); + if (pos == RecyclerView.NO_POSITION) { + return; + } + final ViewHolder vh = mRv.findViewHolderForAdapterPosition(pos); + if (vh == null) { + // This can happen if the messages update while we're dragging the thumb. + return; + } + final ConversationMessageView messageView = (ConversationMessageView) vh.itemView; + final ConversationMessageData messageData = messageView.getData(); + final long timestamp = messageData.getReceivedTimeStamp(); + final CharSequence timestampText = Dates.getFastScrollPreviewTimeString(timestamp); + mPreviewTextView.setText(timestampText); + } + + @Override + public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) { + if (!mVisible) { + return false; + } + // If the user presses down on the scroll thumb, we'll start intercepting events from the + // RecyclerView so we can handle the move events while they're dragging it up/down. + final int action = e.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: + if (isInsideThumb(e.getX(), e.getY())) { + startDrag(); + return true; + } + break; + case MotionEvent.ACTION_MOVE: + if (mDragging) { + return true; + } + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + if (mDragging) { + cancelDrag(); + } + return false; + } + return false; + } + + private boolean isInsideThumb(float x, float y) { + final int hitTargetLeft = mThumbImageView.getLeft() - mTouchSlop; + final int hitTargetRight = mThumbImageView.getRight() + mTouchSlop; + + if (x < hitTargetLeft || x > hitTargetRight) { + return false; + } + if (y < mThumbImageView.getTop() || y > mThumbImageView.getBottom()) { + return false; + } + return true; + } + + @Override + public void onTouchEvent(RecyclerView rv, MotionEvent e) { + if (!mDragging) { + return; + } + final int action = e.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_MOVE: + handleDragMove(e.getY()); + break; + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + cancelDrag(); + break; + } + } + + @Override + public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { + } + + private void startDrag() { + mDragging = true; + mThumbImageView.setPressed(true); + updateScrollPos(); + showPreview(); + cancelAnyPendingHide(); + } + + private void handleDragMove(float y) { + final int verticalScrollLength = mContainer.height() - mThumbHeight; + final int verticalScrollStart = mContainer.top + (mThumbHeight / 2); + + // Convert the desired position from px to a scroll position in the conversation. + float dragScrollRatio = (y - verticalScrollStart) / verticalScrollLength; + dragScrollRatio = Math.max(dragScrollRatio, 0.0f); + dragScrollRatio = Math.min(dragScrollRatio, 1.0f); + + // Scroll the RecyclerView to a new position. + final int itemCount = mRv.getAdapter().getItemCount(); + final int itemPos = (int)((itemCount - 1) * dragScrollRatio); + mRv.scrollToPosition(itemPos); + } + + private void cancelDrag() { + mDragging = false; + mThumbImageView.setPressed(false); + hidePreview(); + hideAfterDelay(); + } + + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, + int oldLeft, int oldTop, int oldRight, int oldBottom) { + if (!mVisible) { + hide(false /* animate */); + } + // The container is the size of the RecyclerView that's visible on screen. We have to + // exclude the top padding, because it's usually hidden behind the conversation action bar. + mContainer.set(left, top + mRv.getPaddingTop(), right, bottom); + layoutTrack(); + updateScrollPos(); + } + + private void layoutTrack() { + int trackHeight = Math.max(0, mContainer.height()); + int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mTrackWidth, MeasureSpec.EXACTLY); + int heightMeasureSpec = MeasureSpec.makeMeasureSpec(trackHeight, MeasureSpec.EXACTLY); + mTrackImageView.measure(widthMeasureSpec, heightMeasureSpec); + + int left = mPosRight ? (mContainer.right - mTrackWidth) : mContainer.left; + int top = mContainer.top; + int right = mPosRight ? mContainer.right : (mContainer.left + mTrackWidth); + int bottom = mContainer.bottom; + mTrackImageView.layout(left, top, right, bottom); + } + + private void layoutThumb(int centerY) { + int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mTrackWidth, MeasureSpec.EXACTLY); + int heightMeasureSpec = MeasureSpec.makeMeasureSpec(mThumbHeight, MeasureSpec.EXACTLY); + mThumbImageView.measure(widthMeasureSpec, heightMeasureSpec); + + int left = mPosRight ? (mContainer.right - mTrackWidth) : mContainer.left; + int top = centerY - (mThumbImageView.getHeight() / 2); + int right = mPosRight ? mContainer.right : (mContainer.left + mTrackWidth); + int bottom = top + mThumbHeight; + mThumbImageView.layout(left, top, right, bottom); + } + + private void layoutPreview(int centerY) { + int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mContainer.width(), MeasureSpec.AT_MOST); + int heightMeasureSpec = MeasureSpec.makeMeasureSpec(mPreviewHeight, MeasureSpec.EXACTLY); + mPreviewTextView.measure(widthMeasureSpec, heightMeasureSpec); + + // Ensure that the preview bubble is at least as wide as it is tall + if (mPreviewTextView.getMeasuredWidth() < mPreviewMinWidth) { + widthMeasureSpec = MeasureSpec.makeMeasureSpec(mPreviewMinWidth, MeasureSpec.EXACTLY); + mPreviewTextView.measure(widthMeasureSpec, heightMeasureSpec); + } + final int previewMinY = mContainer.top + mPreviewMarginTop; + + final int left, right; + if (mPosRight) { + right = mContainer.right - mTrackWidth - mPreviewMarginLeftRight; + left = right - mPreviewTextView.getMeasuredWidth(); + } else { + left = mContainer.left + mTrackWidth + mPreviewMarginLeftRight; + right = left + mPreviewTextView.getMeasuredWidth(); + } + + int bottom = centerY; + int top = bottom - mPreviewTextView.getMeasuredHeight(); + if (top < previewMinY) { + top = previewMinY; + bottom = top + mPreviewTextView.getMeasuredHeight(); + } + mPreviewTextView.layout(left, top, right, bottom); + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationFragment.java b/src/com/android/messaging/ui/conversation/ConversationFragment.java new file mode 100644 index 0000000..a6a191a --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationFragment.java @@ -0,0 +1,1662 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversation; + +import android.Manifest; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.DownloadManager; +import android.app.Fragment; +import android.app.FragmentManager; +import android.app.FragmentTransaction; +import android.content.BroadcastReceiver; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnCancelListener; +import android.content.DialogInterface.OnClickListener; +import android.content.DialogInterface.OnDismissListener; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.res.Configuration; +import android.database.Cursor; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.drawable.ColorDrawable; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.os.Handler; +import android.os.Parcelable; +import android.support.v4.content.LocalBroadcastManager; +import android.support.v4.text.BidiFormatter; +import android.support.v4.text.TextDirectionHeuristicsCompat; +import android.support.v7.app.ActionBar; +import android.support.v7.widget.DefaultItemAnimator; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.RecyclerView.ViewHolder; +import android.text.TextUtils; +import android.view.ActionMode; +import android.view.Display; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.action.InsertNewMessageAction; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.binding.ImmutableBindingRef; +import com.android.messaging.datamodel.data.ConversationData; +import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; +import com.android.messaging.datamodel.data.ConversationMessageData; +import com.android.messaging.datamodel.data.ConversationParticipantsData; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; +import com.android.messaging.ui.AttachmentPreview; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.ConversationDrawables; +import com.android.messaging.ui.SnackBar; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.animation.PopupTransitionAnimation; +import com.android.messaging.ui.contact.AddContactsConfirmationDialog; +import com.android.messaging.ui.conversation.ComposeMessageView.IComposeMessageViewHost; +import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputHost; +import com.android.messaging.ui.conversation.ConversationMessageView.ConversationMessageViewHost; +import com.android.messaging.ui.mediapicker.MediaPicker; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ChangeDefaultSmsAppHelper; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.ImeUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.TextUtil; +import com.android.messaging.util.UiUtils; +import com.android.messaging.util.UriUtil; +import com.google.common.annotations.VisibleForTesting; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * Shows a list of messages/parts comprising a conversation. + */ +public class ConversationFragment extends Fragment implements ConversationDataListener, + IComposeMessageViewHost, ConversationMessageViewHost, ConversationInputHost, + DraftMessageDataListener { + + public interface ConversationFragmentHost extends ImeUtil.ImeStateHost { + void onStartComposeMessage(); + void onConversationMetadataUpdated(); + boolean shouldResumeComposeMessage(); + void onFinishCurrentConversation(); + void invalidateActionBar(); + ActionMode startActionMode(ActionMode.Callback callback); + void dismissActionMode(); + ActionMode getActionMode(); + void onConversationMessagesUpdated(int numberOfMessages); + void onConversationParticipantDataLoaded(int numberOfParticipants); + boolean isActiveAndFocused(); + } + + public static final String FRAGMENT_TAG = "conversation"; + + static final int REQUEST_CHOOSE_ATTACHMENTS = 2; + private static final int JUMP_SCROLL_THRESHOLD = 15; + // We animate the message from draft to message list, if we the message doesn't show up in the + // list within this time limit, then we just do a fade in animation instead + public static final int MESSAGE_ANIMATION_MAX_WAIT = 500; + + private ComposeMessageView mComposeMessageView; + private RecyclerView mRecyclerView; + private ConversationMessageAdapter mAdapter; + private ConversationFastScroller mFastScroller; + + private View mConversationComposeDivider; + private ChangeDefaultSmsAppHelper mChangeDefaultSmsAppHelper; + + private String mConversationId; + // If the fragment receives a draft as part of the invocation this is set + private MessageData mIncomingDraft; + + // This binding keeps track of our associated ConversationData instance + // A binding should have the lifetime of the owning component, + // don't recreate, unbind and bind if you need new data + @VisibleForTesting + final Binding mBinding = BindingBase.createBinding(this); + + // Saved Instance State Data - only for temporal data which is nice to maintain but not + // critical for correctness. + private static final String SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY = "conversationViewState"; + private Parcelable mListState; + + private ConversationFragmentHost mHost; + + protected List mFilterResults; + + // The minimum scrolling distance between RecyclerView's scroll change event beyong which + // a fling motion is considered fast, in which case we'll delay load image attachments for + // perf optimization. + private int mFastFlingThreshold; + + // ConversationMessageView that is currently selected + private ConversationMessageView mSelectedMessage; + + // Attachment data for the attachment within the selected message that was long pressed + private MessagePartData mSelectedAttachment; + + // Normally, as soon as draft message is loaded, we trust the UI state held in + // ComposeMessageView to be the only source of truth (incl. the conversation self id). However, + // there can be external events that forces the UI state to change, such as SIM state changes + // or SIM auto-switching on receiving a message. This receiver is used to receive such + // local broadcast messages and reflect the change in the UI. + private final BroadcastReceiver mConversationSelfIdChangeReceiver = new BroadcastReceiver() { + @Override + public void onReceive(final Context context, final Intent intent) { + final String conversationId = + intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); + final String selfId = + intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_SELF_ID); + Assert.notNull(conversationId); + Assert.notNull(selfId); + if (TextUtils.equals(mBinding.getData().getConversationId(), conversationId)) { + mComposeMessageView.updateConversationSelfIdOnExternalChange(selfId); + } + } + }; + + // Flag to prevent writing draft to DB on pause + private boolean mSuppressWriteDraft; + + // Indicates whether local draft should be cleared due to external draft changes that must + // be reloaded from db + private boolean mClearLocalDraft; + private ImmutableBindingRef mDraftMessageDataModel; + + private boolean isScrolledToBottom() { + if (mRecyclerView.getChildCount() == 0) { + return true; + } + final View lastView = mRecyclerView.getChildAt(mRecyclerView.getChildCount() - 1); + int lastVisibleItem = ((LinearLayoutManager) mRecyclerView + .getLayoutManager()).findLastVisibleItemPosition(); + if (lastVisibleItem < 0) { + // If the recyclerView height is 0, then the last visible item position is -1 + // Try to compute the position of the last item, even though it's not visible + final long id = mRecyclerView.getChildItemId(lastView); + final RecyclerView.ViewHolder holder = mRecyclerView.findViewHolderForItemId(id); + if (holder != null) { + lastVisibleItem = holder.getAdapterPosition(); + } + } + final int totalItemCount = mRecyclerView.getAdapter().getItemCount(); + final boolean isAtBottom = (lastVisibleItem + 1 == totalItemCount); + return isAtBottom && lastView.getBottom() <= mRecyclerView.getHeight(); + } + + private void scrollToBottom(final boolean smoothScroll) { + if (mAdapter.getItemCount() > 0) { + scrollToPosition(mAdapter.getItemCount() - 1, smoothScroll); + } + } + + private int mScrollToDismissThreshold; + private final RecyclerView.OnScrollListener mListScrollListener = + new RecyclerView.OnScrollListener() { + // Keeps track of cumulative scroll delta during a scroll event, which we may use to + // hide the media picker & co. + private int mCumulativeScrollDelta; + private boolean mScrollToDismissHandled; + private boolean mWasScrolledToBottom = true; + private int mScrollState = RecyclerView.SCROLL_STATE_IDLE; + + @Override + public void onScrollStateChanged(final RecyclerView view, final int newState) { + if (newState == RecyclerView.SCROLL_STATE_IDLE) { + // Reset scroll states. + mCumulativeScrollDelta = 0; + mScrollToDismissHandled = false; + } else if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { + mRecyclerView.getItemAnimator().endAnimations(); + } + mScrollState = newState; + } + + @Override + public void onScrolled(final RecyclerView view, final int dx, final int dy) { + if (mScrollState == RecyclerView.SCROLL_STATE_DRAGGING && + !mScrollToDismissHandled) { + mCumulativeScrollDelta += dy; + // Dismiss the keyboard only when the user scroll up (into the past). + if (mCumulativeScrollDelta < -mScrollToDismissThreshold) { + mComposeMessageView.hideAllComposeInputs(false /* animate */); + mScrollToDismissHandled = true; + } + } + if (mWasScrolledToBottom != isScrolledToBottom()) { + mConversationComposeDivider.animate().alpha(isScrolledToBottom() ? 0 : 1); + mWasScrolledToBottom = isScrolledToBottom(); + } + } + }; + + private final ActionMode.Callback mMessageActionModeCallback = new ActionMode.Callback() { + @Override + public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) { + if (mSelectedMessage == null) { + return false; + } + final ConversationMessageData data = mSelectedMessage.getData(); + final MenuInflater menuInflater = getActivity().getMenuInflater(); + menuInflater.inflate(R.menu.conversation_fragment_select_menu, menu); + menu.findItem(R.id.action_download).setVisible(data.getShowDownloadMessage()); + menu.findItem(R.id.action_send).setVisible(data.getShowResendMessage()); + + // ShareActionProvider does not work with ActionMode. So we use a normal menu item. + menu.findItem(R.id.share_message_menu).setVisible(data.getCanForwardMessage()); + menu.findItem(R.id.save_attachment).setVisible(mSelectedAttachment != null); + menu.findItem(R.id.forward_message_menu).setVisible(data.getCanForwardMessage()); + + // TODO: We may want to support copying attachments in the future, but it's + // unclear which attachment to pick when we make this context menu at the message level + // instead of the part level + menu.findItem(R.id.copy_text).setVisible(data.getCanCopyMessageToClipboard()); + + return true; + } + + @Override + public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) { + return true; + } + + @Override + public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) { + final ConversationMessageData data = mSelectedMessage.getData(); + final String messageId = data.getMessageId(); + switch (menuItem.getItemId()) { + case R.id.save_attachment: + if (OsUtil.hasStoragePermission()) { + final SaveAttachmentTask saveAttachmentTask = new SaveAttachmentTask( + getActivity()); + for (final MessagePartData part : data.getAttachments()) { + saveAttachmentTask.addAttachmentToSave(part.getContentUri(), + part.getContentType()); + } + if (saveAttachmentTask.getAttachmentCount() > 0) { + saveAttachmentTask.executeOnThreadPool(); + mHost.dismissActionMode(); + } + } else { + getActivity().requestPermissions( + new String[] { Manifest.permission.WRITE_EXTERNAL_STORAGE }, 0); + } + return true; + case R.id.action_delete_message: + if (mSelectedMessage != null) { + deleteMessage(messageId); + } + return true; + case R.id.action_download: + if (mSelectedMessage != null) { + retryDownload(messageId); + mHost.dismissActionMode(); + } + return true; + case R.id.action_send: + if (mSelectedMessage != null) { + retrySend(messageId); + mHost.dismissActionMode(); + } + return true; + case R.id.copy_text: + Assert.isTrue(data.hasText()); + final ClipboardManager clipboard = (ClipboardManager) getActivity() + .getSystemService(Context.CLIPBOARD_SERVICE); + clipboard.setPrimaryClip( + ClipData.newPlainText(null /* label */, data.getText())); + mHost.dismissActionMode(); + return true; + case R.id.details_menu: + MessageDetailsDialog.show( + getActivity(), data, mBinding.getData().getParticipants(), + mBinding.getData().getSelfParticipantById(data.getSelfParticipantId())); + mHost.dismissActionMode(); + return true; + case R.id.share_message_menu: + shareMessage(data); + mHost.dismissActionMode(); + return true; + case R.id.forward_message_menu: + // TODO: Currently we are forwarding one part at a time, instead of + // the entire message. Change this to forwarding the entire message when we + // use message-based cursor in conversation. + final MessageData message = mBinding.getData().createForwardedMessage(data); + UIIntents.get().launchForwardMessageActivity(getActivity(), message); + mHost.dismissActionMode(); + return true; + } + return false; + } + + private void shareMessage(final ConversationMessageData data) { + // Figure out what to share. + MessagePartData attachmentToShare = mSelectedAttachment; + // If the user long-pressed on the background, we will share the text (if any) + // or the first attachment. + if (mSelectedAttachment == null + && TextUtil.isAllWhitespace(data.getText())) { + final List attachments = data.getAttachments(); + if (attachments.size() > 0) { + attachmentToShare = attachments.get(0); + } + } + + final Intent shareIntent = new Intent(); + shareIntent.setAction(Intent.ACTION_SEND); + if (attachmentToShare == null) { + shareIntent.putExtra(Intent.EXTRA_TEXT, data.getText()); + shareIntent.setType("text/plain"); + } else { + shareIntent.putExtra( + Intent.EXTRA_STREAM, attachmentToShare.getContentUri()); + shareIntent.setType(attachmentToShare.getContentType()); + } + final CharSequence title = getResources().getText(R.string.action_share); + startActivity(Intent.createChooser(shareIntent, title)); + } + + @Override + public void onDestroyActionMode(final ActionMode actionMode) { + selectMessage(null); + } + }; + + /** + * {@inheritDoc} from Fragment + */ + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mFastFlingThreshold = getResources().getDimensionPixelOffset( + R.dimen.conversation_fast_fling_threshold); + mAdapter = new ConversationMessageAdapter(getActivity(), null, this, + null, + // Sets the item click listener on the Recycler item views. + new View.OnClickListener() { + @Override + public void onClick(final View v) { + final ConversationMessageView messageView = (ConversationMessageView) v; + handleMessageClick(messageView); + } + }, + new View.OnLongClickListener() { + @Override + public boolean onLongClick(final View view) { + selectMessage((ConversationMessageView) view); + return true; + } + } + ); + } + + /** + * setConversationInfo() may be called before or after onCreate(). When a user initiate a + * conversation from compose, the ConversationActivity creates this fragment and calls + * setConversationInfo(), so it happens before onCreate(). However, when the activity is + * restored from saved instance state, the ConversationFragment is created automatically by + * the fragment, before ConversationActivity has a chance to call setConversationInfo(). Since + * the ability to start loading data depends on both methods being called, we need to start + * loading when onActivityCreated() is called, which is guaranteed to happen after both. + */ + @Override + public void onActivityCreated(final Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + // Delay showing the message list until the participant list is loaded. + mRecyclerView.setVisibility(View.INVISIBLE); + mBinding.ensureBound(); + mBinding.getData().init(getLoaderManager(), mBinding); + + // Build the input manager with all its required dependencies and pass it along to the + // compose message view. + final ConversationInputManager inputManager = new ConversationInputManager( + getActivity(), this, mComposeMessageView, mHost, getFragmentManagerToUse(), + mBinding, mComposeMessageView.getDraftDataModel(), savedInstanceState); + mComposeMessageView.setInputManager(inputManager); + mComposeMessageView.setConversationDataModel(BindingBase.createBindingReference(mBinding)); + mHost.invalidateActionBar(); + + mDraftMessageDataModel = + BindingBase.createBindingReference(mComposeMessageView.getDraftDataModel()); + mDraftMessageDataModel.getData().addListener(this); + } + + public void onAttachmentChoosen() { + // Attachment has been choosen in the AttachmentChooserActivity, so clear local draft + // and reload draft on resume. + mClearLocalDraft = true; + } + + private int getScrollToMessagePosition() { + final Activity activity = getActivity(); + if (activity == null) { + return -1; + } + + final Intent intent = activity.getIntent(); + if (intent == null) { + return -1; + } + + return intent.getIntExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, -1); + } + + private void clearScrollToMessagePosition() { + final Activity activity = getActivity(); + if (activity == null) { + return; + } + + final Intent intent = activity.getIntent(); + if (intent == null) { + return; + } + intent.putExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, -1); + } + + private final Handler mHandler = new Handler(); + + /** + * {@inheritDoc} from Fragment + */ + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final View view = inflater.inflate(R.layout.conversation_fragment, container, false); + mRecyclerView = (RecyclerView) view.findViewById(android.R.id.list); + final LinearLayoutManager manager = new LinearLayoutManager(getActivity()); + manager.setStackFromEnd(true); + manager.setReverseLayout(false); + mRecyclerView.setHasFixedSize(true); + mRecyclerView.setLayoutManager(manager); + mRecyclerView.setItemAnimator(new DefaultItemAnimator() { + private final List mAddAnimations = new ArrayList(); + private PopupTransitionAnimation mPopupTransitionAnimation; + + @Override + public boolean animateAdd(final ViewHolder holder) { + final ConversationMessageView view = + (ConversationMessageView) holder.itemView; + final ConversationMessageData data = view.getData(); + endAnimation(holder); + final long timeSinceSend = System.currentTimeMillis() - data.getReceivedTimeStamp(); + if (data.getReceivedTimeStamp() == + InsertNewMessageAction.getLastSentMessageTimestamp() && + !data.getIsIncoming() && + timeSinceSend < MESSAGE_ANIMATION_MAX_WAIT) { + final ConversationMessageBubbleView messageBubble = + (ConversationMessageBubbleView) view + .findViewById(R.id.message_content); + final Rect startRect = UiUtils.getMeasuredBoundsOnScreen(mComposeMessageView); + final View composeBubbleView = mComposeMessageView.findViewById( + R.id.compose_message_text); + final Rect composeBubbleRect = + UiUtils.getMeasuredBoundsOnScreen(composeBubbleView); + final AttachmentPreview attachmentView = + (AttachmentPreview) mComposeMessageView.findViewById( + R.id.attachment_draft_view); + final Rect attachmentRect = UiUtils.getMeasuredBoundsOnScreen(attachmentView); + if (attachmentView.getVisibility() == View.VISIBLE) { + startRect.top = attachmentRect.top; + } else { + startRect.top = composeBubbleRect.top; + } + startRect.top -= view.getPaddingTop(); + startRect.bottom = + composeBubbleRect.bottom; + startRect.left += view.getPaddingRight(); + + view.setAlpha(0); + mPopupTransitionAnimation = new PopupTransitionAnimation(startRect, view); + mPopupTransitionAnimation.setOnStartCallback(new Runnable() { + @Override + public void run() { + final int startWidth = composeBubbleRect.width(); + attachmentView.onMessageAnimationStart(); + messageBubble.kickOffMorphAnimation(startWidth, + messageBubble.findViewById(R.id.message_text_and_info) + .getMeasuredWidth()); + } + }); + mPopupTransitionAnimation.setOnStopCallback(new Runnable() { + @Override + public void run() { + view.setAlpha(1); + } + }); + mPopupTransitionAnimation.startAfterLayoutComplete(); + mAddAnimations.add(holder); + return true; + } else { + return super.animateAdd(holder); + } + } + + @Override + public void endAnimation(final ViewHolder holder) { + if (mAddAnimations.remove(holder)) { + holder.itemView.clearAnimation(); + } + super.endAnimation(holder); + } + + @Override + public void endAnimations() { + for (final ViewHolder holder : mAddAnimations) { + holder.itemView.clearAnimation(); + } + mAddAnimations.clear(); + if (mPopupTransitionAnimation != null) { + mPopupTransitionAnimation.cancel(); + } + super.endAnimations(); + } + }); + mRecyclerView.setAdapter(mAdapter); + + if (savedInstanceState != null) { + mListState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY); + } + + mConversationComposeDivider = view.findViewById(R.id.conversation_compose_divider); + mScrollToDismissThreshold = ViewConfiguration.get(getActivity()).getScaledTouchSlop(); + mRecyclerView.addOnScrollListener(mListScrollListener); + mFastScroller = ConversationFastScroller.addTo(mRecyclerView, + UiUtils.isRtlMode() ? ConversationFastScroller.POSITION_LEFT_SIDE : + ConversationFastScroller.POSITION_RIGHT_SIDE); + + mComposeMessageView = (ComposeMessageView) + view.findViewById(R.id.message_compose_view_container); + // Bind the compose message view to the DraftMessageData + mComposeMessageView.bind(DataModel.get().createDraftMessageData( + mBinding.getData().getConversationId()), this); + + return view; + } + + private void scrollToPosition(final int targetPosition, final boolean smoothScroll) { + if (smoothScroll) { + final int maxScrollDelta = JUMP_SCROLL_THRESHOLD; + + final LinearLayoutManager layoutManager = + (LinearLayoutManager) mRecyclerView.getLayoutManager(); + final int firstVisibleItemPosition = + layoutManager.findFirstVisibleItemPosition(); + final int delta = targetPosition - firstVisibleItemPosition; + final int intermediatePosition; + + if (delta > maxScrollDelta) { + intermediatePosition = Math.max(0, targetPosition - maxScrollDelta); + } else if (delta < -maxScrollDelta) { + final int count = layoutManager.getItemCount(); + intermediatePosition = Math.min(count - 1, targetPosition + maxScrollDelta); + } else { + intermediatePosition = -1; + } + if (intermediatePosition != -1) { + mRecyclerView.scrollToPosition(intermediatePosition); + } + mRecyclerView.smoothScrollToPosition(targetPosition); + } else { + mRecyclerView.scrollToPosition(targetPosition); + } + } + + private int getScrollPositionFromBottom() { + final LinearLayoutManager layoutManager = + (LinearLayoutManager) mRecyclerView.getLayoutManager(); + final int lastVisibleItem = + layoutManager.findLastVisibleItemPosition(); + return Math.max(mAdapter.getItemCount() - 1 - lastVisibleItem, 0); + } + + /** + * Display a photo using the Photoviewer component. + */ + @Override + public void displayPhoto(final Uri photoUri, final Rect imageBounds, final boolean isDraft) { + displayPhoto(photoUri, imageBounds, isDraft, mConversationId, getActivity()); + } + + public static void displayPhoto(final Uri photoUri, final Rect imageBounds, + final boolean isDraft, final String conversationId, final Activity activity) { + final Uri imagesUri = + isDraft ? MessagingContentProvider.buildDraftImagesUri(conversationId) + : MessagingContentProvider.buildConversationImagesUri(conversationId); + UIIntents.get().launchFullScreenPhotoViewer( + activity, photoUri, imageBounds, imagesUri); + } + + private void selectMessage(final ConversationMessageView messageView) { + selectMessage(messageView, null /* attachment */); + } + + private void selectMessage(final ConversationMessageView messageView, + final MessagePartData attachment) { + mSelectedMessage = messageView; + if (mSelectedMessage == null) { + mAdapter.setSelectedMessage(null); + mHost.dismissActionMode(); + mSelectedAttachment = null; + return; + } + mSelectedAttachment = attachment; + mAdapter.setSelectedMessage(messageView.getData().getMessageId()); + mHost.startActionMode(mMessageActionModeCallback); + } + + @Override + public void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + if (mListState != null) { + outState.putParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY, mListState); + } + mComposeMessageView.saveInputState(outState); + } + + @Override + public void onResume() { + super.onResume(); + + if (mIncomingDraft == null) { + mComposeMessageView.requestDraftMessage(mClearLocalDraft); + } else { + mComposeMessageView.setDraftMessage(mIncomingDraft); + mIncomingDraft = null; + } + mClearLocalDraft = false; + + // On resume, check if there's a pending request for resuming message compose. This + // may happen when the user commits the contact selection for a group conversation and + // goes from compose back to the conversation fragment. + if (mHost.shouldResumeComposeMessage()) { + mComposeMessageView.resumeComposeMessage(); + } + + setConversationFocus(); + + // On resume, invalidate all message views to show the updated timestamp. + mAdapter.notifyDataSetChanged(); + + LocalBroadcastManager.getInstance(getActivity()).registerReceiver( + mConversationSelfIdChangeReceiver, + new IntentFilter(UIIntents.CONVERSATION_SELF_ID_CHANGE_BROADCAST_ACTION)); + } + + void setConversationFocus() { + if (mHost.isActiveAndFocused()) { + mBinding.getData().setFocus(); + } + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + if (mHost.getActionMode() != null) { + return; + } + + inflater.inflate(R.menu.conversation_menu, menu); + + final ConversationData data = mBinding.getData(); + + // Disable the "people & options" item if we haven't loaded participants yet. + menu.findItem(R.id.action_people_and_options).setEnabled(data.getParticipantsLoaded()); + + // See if we can show add contact action. + final ParticipantData participant = data.getOtherParticipant(); + final boolean addContactActionVisible = (participant != null + && TextUtils.isEmpty(participant.getLookupKey())); + menu.findItem(R.id.action_add_contact).setVisible(addContactActionVisible); + + // See if we should show archive or unarchive. + final boolean isArchived = data.getIsArchived(); + menu.findItem(R.id.action_archive).setVisible(!isArchived); + menu.findItem(R.id.action_unarchive).setVisible(isArchived); + + // Conditionally enable the phone call button. + final boolean supportCallAction = (PhoneUtils.getDefault().isVoiceCapable() && + data.getParticipantPhoneNumber() != null); + menu.findItem(R.id.action_call).setVisible(supportCallAction); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case R.id.action_people_and_options: + Assert.isTrue(mBinding.getData().getParticipantsLoaded()); + UIIntents.get().launchPeopleAndOptionsActivity(getActivity(), mConversationId); + return true; + + case R.id.action_call: + final String phoneNumber = mBinding.getData().getParticipantPhoneNumber(); + Assert.notNull(phoneNumber); + final View targetView = getActivity().findViewById(R.id.action_call); + Point centerPoint; + if (targetView != null) { + final int screenLocation[] = new int[2]; + targetView.getLocationOnScreen(screenLocation); + final int centerX = screenLocation[0] + targetView.getWidth() / 2; + final int centerY = screenLocation[1] + targetView.getHeight() / 2; + centerPoint = new Point(centerX, centerY); + } else { + // In the overflow menu, just use the center of the screen. + final Display display = getActivity().getWindowManager().getDefaultDisplay(); + centerPoint = new Point(display.getWidth() / 2, display.getHeight() / 2); + } + UIIntents.get().launchPhoneCallActivity(getActivity(), phoneNumber, centerPoint); + return true; + + case R.id.action_archive: + mBinding.getData().archiveConversation(mBinding); + closeConversation(mConversationId); + return true; + + case R.id.action_unarchive: + mBinding.getData().unarchiveConversation(mBinding); + return true; + + case R.id.action_settings: + return true; + + case R.id.action_add_contact: + final ParticipantData participant = mBinding.getData().getOtherParticipant(); + Assert.notNull(participant); + final String destination = participant.getNormalizedDestination(); + final Uri avatarUri = AvatarUriUtil.createAvatarUri(participant); + (new AddContactsConfirmationDialog(getActivity(), avatarUri, destination)).show(); + return true; + + case R.id.action_delete: + if (isReadyForAction()) { + new AlertDialog.Builder(getActivity()) + .setTitle(getResources().getQuantityString( + R.plurals.delete_conversations_confirmation_dialog_title, 1)) + .setPositiveButton(R.string.delete_conversation_confirmation_button, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, + final int button) { + deleteConversation(); + } + }) + .setNegativeButton(R.string.delete_conversation_decline_button, null) + .show(); + } else { + warnOfMissingActionConditions(false /*sending*/, + null /*commandToRunAfterActionConditionResolved*/); + } + return true; + } + return super.onOptionsItemSelected(item); + } + + /** + * {@inheritDoc} from ConversationDataListener + */ + @Override + public void onConversationMessagesCursorUpdated(final ConversationData data, + final Cursor cursor, final ConversationMessageData newestMessage, + final boolean isSync) { + mBinding.ensureBound(data); + + // This needs to be determined before swapping cursor, which may change the scroll state. + final boolean scrolledToBottom = isScrolledToBottom(); + final int positionFromBottom = getScrollPositionFromBottom(); + + // If participants not loaded, assume 1:1 since that's the 99% case + final boolean oneOnOne = + !data.getParticipantsLoaded() || data.getOtherParticipant() != null; + mAdapter.setOneOnOne(oneOnOne, false /* invalidate */); + + // Ensure that the action bar is updated with the current data. + invalidateOptionsMenu(); + final Cursor oldCursor = mAdapter.swapCursor(cursor); + + if (cursor != null && oldCursor == null) { + if (mListState != null) { + mRecyclerView.getLayoutManager().onRestoreInstanceState(mListState); + // RecyclerView restores scroll states without triggering scroll change events, so + // we need to manually ensure that they are correctly handled. + mListScrollListener.onScrolled(mRecyclerView, 0, 0); + } + } + + if (isSync) { + // This is a message sync. Syncing messages changes cursor item count, which would + // implicitly change RV's scroll position. We'd like the RV to keep scrolled to the same + // relative position from the bottom (because RV is stacked from bottom), so that it + // stays relatively put as we sync. + final int position = Math.max(mAdapter.getItemCount() - 1 - positionFromBottom, 0); + scrollToPosition(position, false /* smoothScroll */); + } else if (newestMessage != null) { + // Show a snack bar notification if we are not scrolled to the bottom and the new + // message is an incoming message. + if (!scrolledToBottom && newestMessage.getIsIncoming()) { + // If the conversation activity is started but not resumed (if another dialog + // activity was in the foregrond), we will show a system notification instead of + // the snack bar. + if (mBinding.getData().isFocused()) { + UiUtils.showSnackBarWithCustomAction(getActivity(), + getView().getRootView(), + getString(R.string.in_conversation_notify_new_message_text), + SnackBar.Action.createCustomAction(new Runnable() { + @Override + public void run() { + scrollToBottom(true /* smoothScroll */); + mComposeMessageView.hideAllComposeInputs(false /* animate */); + } + }, + getString(R.string.in_conversation_notify_new_message_action)), + null /* interactions */, + SnackBar.Placement.above(mComposeMessageView)); + } + } else { + // We are either already scrolled to the bottom or this is an outgoing message, + // scroll to the bottom to reveal it. + // Don't smooth scroll if we were already at the bottom; instead, we scroll + // immediately so RecyclerView's view animation will take place. + scrollToBottom(!scrolledToBottom); + } + } + + if (cursor != null) { + mHost.onConversationMessagesUpdated(cursor.getCount()); + + // Are we coming from a widget click where we're told to scroll to a particular item? + final int scrollToPos = getScrollToMessagePosition(); + if (scrollToPos >= 0) { + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_TAG, "onConversationMessagesCursorUpdated " + + " scrollToPos: " + scrollToPos + + " cursorCount: " + cursor.getCount()); + } + scrollToPosition(scrollToPos, true /*smoothScroll*/); + clearScrollToMessagePosition(); + } + } + + mHost.invalidateActionBar(); + } + + /** + * {@inheritDoc} from ConversationDataListener + */ + @Override + public void onConversationMetadataUpdated(final ConversationData conversationData) { + mBinding.ensureBound(conversationData); + + if (mSelectedMessage != null && mSelectedAttachment != null) { + // We may have just sent a message and the temp attachment we selected is now gone. + // and it was replaced with some new attachment. Since we don't know which one it + // is we shouldn't reselect it (unless there is just one) In the multi-attachment + // case we would just deselect the message and allow the user to reselect, otherwise we + // may act on old temp data and may crash. + final List currentAttachments = mSelectedMessage.getData().getAttachments(); + if (currentAttachments.size() == 1) { + mSelectedAttachment = currentAttachments.get(0); + } else if (!currentAttachments.contains(mSelectedAttachment)) { + selectMessage(null); + } + } + // Ensure that the action bar is updated with the current data. + invalidateOptionsMenu(); + mHost.onConversationMetadataUpdated(); + mAdapter.notifyDataSetChanged(); + } + + public void setConversationInfo(final Context context, final String conversationId, + final MessageData draftData) { + // TODO: Eventually I would like the Factory to implement + // Factory.get().bindConversationData(mBinding, getActivity(), this, conversationId)); + if (!mBinding.isBound()) { + mConversationId = conversationId; + mIncomingDraft = draftData; + mBinding.bind(DataModel.get().createConversationData(context, this, conversationId)); + } else { + Assert.isTrue(TextUtils.equals(mBinding.getData().getConversationId(), conversationId)); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + // Unbind all the views that we bound to data + if (mComposeMessageView != null) { + mComposeMessageView.unbind(); + } + + // And unbind this fragment from its data + mBinding.unbind(); + mConversationId = null; + } + + void suppressWriteDraft() { + mSuppressWriteDraft = true; + } + + @Override + public void onPause() { + super.onPause(); + if (mComposeMessageView != null && !mSuppressWriteDraft) { + mComposeMessageView.writeDraftMessage(); + } + mSuppressWriteDraft = false; + mBinding.getData().unsetFocus(); + mListState = mRecyclerView.getLayoutManager().onSaveInstanceState(); + + LocalBroadcastManager.getInstance(getActivity()) + .unregisterReceiver(mConversationSelfIdChangeReceiver); + } + + @Override + public void onConfigurationChanged(final Configuration newConfig) { + super.onConfigurationChanged(newConfig); + mRecyclerView.getItemAnimator().endAnimations(); + } + + // TODO: Remove isBound and replace it with ensureBound after b/15704674. + public boolean isBound() { + return mBinding.isBound(); + } + + private FragmentManager getFragmentManagerToUse() { + return OsUtil.isAtLeastJB_MR1() ? getChildFragmentManager() : getFragmentManager(); + } + + public MediaPicker getMediaPicker() { + return (MediaPicker) getFragmentManagerToUse().findFragmentByTag( + MediaPicker.FRAGMENT_TAG); + } + + @Override + public void sendMessage(final MessageData message) { + if (isReadyForAction()) { + if (ensureKnownRecipients()) { + // Merge the caption text from attachments into the text body of the messages + message.consolidateText(); + + mBinding.getData().sendMessage(mBinding, message); + mComposeMessageView.resetMediaPickerState(); + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: conv participants not loaded"); + } + } else { + warnOfMissingActionConditions(true /*sending*/, + new Runnable() { + @Override + public void run() { + sendMessage(message); + } + }); + } + } + + public void setHost(final ConversationFragmentHost host) { + mHost = host; + } + + public String getConversationName() { + return mBinding.getData().getConversationName(); + } + + @Override + public void onComposeEditTextFocused() { + mHost.onStartComposeMessage(); + } + + @Override + public void onAttachmentsCleared() { + // When attachments are removed, reset transient media picker state such as image selection. + mComposeMessageView.resetMediaPickerState(); + } + + /** + * Called to check if all conditions are nominal and a "go" for some action, such as deleting + * a message, that requires this app to be the default app. This is also a precondition + * required for sending a draft. + * @return true if all conditions are nominal and we're ready to send a message + */ + @Override + public boolean isReadyForAction() { + return UiUtils.isReadyForAction(); + } + + /** + * When there's some condition that prevents an operation, such as sending a message, + * call warnOfMissingActionConditions to put up a snackbar and allow the user to repair + * that condition. + * @param sending - true if we're called during a sending operation + * @param commandToRunAfterActionConditionResolved - a runnable to run after the user responds + * positively to the condition prompt and resolves the condition. If null, + * the user will be shown a toast to tap the send button again. + */ + @Override + public void warnOfMissingActionConditions(final boolean sending, + final Runnable commandToRunAfterActionConditionResolved) { + if (mChangeDefaultSmsAppHelper == null) { + mChangeDefaultSmsAppHelper = new ChangeDefaultSmsAppHelper(); + } + mChangeDefaultSmsAppHelper.warnOfMissingActionConditions(sending, + commandToRunAfterActionConditionResolved, mComposeMessageView, + getView().getRootView(), + getActivity(), this); + } + + private boolean ensureKnownRecipients() { + final ConversationData conversationData = mBinding.getData(); + + if (!conversationData.getParticipantsLoaded()) { + // We can't tell yet whether or not we have an unknown recipient + return false; + } + + final ConversationParticipantsData participants = conversationData.getParticipants(); + for (final ParticipantData participant : participants) { + + + if (participant.isUnknownSender()) { + UiUtils.showToast(R.string.unknown_sender); + return false; + } + } + + return true; + } + + public void retryDownload(final String messageId) { + if (isReadyForAction()) { + mBinding.getData().downloadMessage(mBinding, messageId); + } else { + warnOfMissingActionConditions(false /*sending*/, + null /*commandToRunAfterActionConditionResolved*/); + } + } + + public void retrySend(final String messageId) { + if (isReadyForAction()) { + if (ensureKnownRecipients()) { + mBinding.getData().resendMessage(mBinding, messageId); + } + } else { + warnOfMissingActionConditions(true /*sending*/, + new Runnable() { + @Override + public void run() { + retrySend(messageId); + } + + }); + } + } + + void deleteMessage(final String messageId) { + if (isReadyForAction()) { + final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()) + .setTitle(R.string.delete_message_confirmation_dialog_title) + .setMessage(R.string.delete_message_confirmation_dialog_text) + .setPositiveButton(R.string.delete_message_confirmation_button, + new OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + mBinding.getData().deleteMessage(mBinding, messageId); + } + }) + .setNegativeButton(android.R.string.cancel, null); + if (OsUtil.isAtLeastJB_MR1()) { + builder.setOnDismissListener(new OnDismissListener() { + @Override + public void onDismiss(final DialogInterface dialog) { + mHost.dismissActionMode(); + } + }); + } else { + builder.setOnCancelListener(new OnCancelListener() { + @Override + public void onCancel(final DialogInterface dialog) { + mHost.dismissActionMode(); + } + }); + } + builder.create().show(); + } else { + warnOfMissingActionConditions(false /*sending*/, + null /*commandToRunAfterActionConditionResolved*/); + mHost.dismissActionMode(); + } + } + + public void deleteConversation() { + if (isReadyForAction()) { + final Context context = getActivity(); + mBinding.getData().deleteConversation(mBinding); + closeConversation(mConversationId); + } else { + warnOfMissingActionConditions(false /*sending*/, + null /*commandToRunAfterActionConditionResolved*/); + } + } + + @Override + public void closeConversation(final String conversationId) { + if (TextUtils.equals(conversationId, mConversationId)) { + mHost.onFinishCurrentConversation(); + // TODO: Explicitly transition to ConversationList (or just go back)? + } + } + + @Override + public void onConversationParticipantDataLoaded(final ConversationData data) { + mBinding.ensureBound(data); + if (mBinding.getData().getParticipantsLoaded()) { + final boolean oneOnOne = mBinding.getData().getOtherParticipant() != null; + mAdapter.setOneOnOne(oneOnOne, true /* invalidate */); + + // refresh the options menu which will enable the "people & options" item. + invalidateOptionsMenu(); + + mHost.invalidateActionBar(); + + mRecyclerView.setVisibility(View.VISIBLE); + mHost.onConversationParticipantDataLoaded + (mBinding.getData().getNumberOfParticipantsExcludingSelf()); + } + } + + @Override + public void onSubscriptionListDataLoaded(final ConversationData data) { + mBinding.ensureBound(data); + mAdapter.notifyDataSetChanged(); + } + + @Override + public void promptForSelfPhoneNumber() { + if (mComposeMessageView != null) { + // Avoid bug in system which puts soft keyboard over dialog after orientation change + ImeUtil.hideSoftInput(getActivity(), mComposeMessageView); + } + + final FragmentTransaction ft = getActivity().getFragmentManager().beginTransaction(); + final EnterSelfPhoneNumberDialog dialog = EnterSelfPhoneNumberDialog + .newInstance(getConversationSelfSubId()); + dialog.setTargetFragment(this, 0/*requestCode*/); + dialog.show(ft, null/*tag*/); + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + if (mChangeDefaultSmsAppHelper == null) { + mChangeDefaultSmsAppHelper = new ChangeDefaultSmsAppHelper(); + } + mChangeDefaultSmsAppHelper.handleChangeDefaultSmsResult(requestCode, resultCode, null); + } + + public boolean hasMessages() { + return mAdapter != null && mAdapter.getItemCount() > 0; + } + + public boolean onBackPressed() { + if (mComposeMessageView.onBackPressed()) { + return true; + } + return false; + } + + public boolean onNavigationUpPressed() { + return mComposeMessageView.onNavigationUpPressed(); + } + + @Override + public boolean onAttachmentClick(final ConversationMessageView messageView, + final MessagePartData attachment, final Rect imageBounds, final boolean longPress) { + if (longPress) { + selectMessage(messageView, attachment); + return true; + } else if (messageView.getData().getOneClickResendMessage()) { + handleMessageClick(messageView); + return true; + } + + if (attachment.isImage()) { + displayPhoto(attachment.getContentUri(), imageBounds, false /* isDraft */); + } + + if (attachment.isVCard()) { + UIIntents.get().launchVCardDetailActivity(getActivity(), attachment.getContentUri()); + } + + return false; + } + + private void handleMessageClick(final ConversationMessageView messageView) { + if (messageView != mSelectedMessage) { + final ConversationMessageData data = messageView.getData(); + final boolean isReadyToSend = isReadyForAction(); + if (data.getOneClickResendMessage()) { + // Directly resend the message on tap if it's failed + retrySend(data.getMessageId()); + selectMessage(null); + } else if (data.getShowResendMessage() && isReadyToSend) { + // Select the message to show the resend/download/delete options + selectMessage(messageView); + } else if (data.getShowDownloadMessage() && isReadyToSend) { + // Directly download the message on tap + retryDownload(data.getMessageId()); + } else { + // Let the toast from warnOfMissingActionConditions show and skip + // selecting + warnOfMissingActionConditions(false /*sending*/, + null /*commandToRunAfterActionConditionResolved*/); + selectMessage(null); + } + } else { + selectMessage(null); + } + } + + private static class AttachmentToSave { + public final Uri uri; + public final String contentType; + public Uri persistedUri; + + AttachmentToSave(final Uri uri, final String contentType) { + this.uri = uri; + this.contentType = contentType; + } + } + + public static class SaveAttachmentTask extends SafeAsyncTask { + private final Context mContext; + private final List mAttachmentsToSave = new ArrayList<>(); + + public SaveAttachmentTask(final Context context, final Uri contentUri, + final String contentType) { + mContext = context; + addAttachmentToSave(contentUri, contentType); + } + + public SaveAttachmentTask(final Context context) { + mContext = context; + } + + public void addAttachmentToSave(final Uri contentUri, final String contentType) { + mAttachmentsToSave.add(new AttachmentToSave(contentUri, contentType)); + } + + public int getAttachmentCount() { + return mAttachmentsToSave.size(); + } + + @Override + protected Void doInBackgroundTimed(final Void... arg) { + final File appDir = new File(Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_PICTURES), + mContext.getResources().getString(R.string.app_name)); + final File downloadDir = Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS); + for (final AttachmentToSave attachment : mAttachmentsToSave) { + final boolean isImageOrVideo = ContentType.isImageType(attachment.contentType) + || ContentType.isVideoType(attachment.contentType); + attachment.persistedUri = UriUtil.persistContent(attachment.uri, + isImageOrVideo ? appDir : downloadDir, attachment.contentType); + } + return null; + } + + @Override + protected void onPostExecute(final Void result) { + int failCount = 0; + int imageCount = 0; + int videoCount = 0; + int otherCount = 0; + for (final AttachmentToSave attachment : mAttachmentsToSave) { + if (attachment.persistedUri == null) { + failCount++; + continue; + } + + // Inform MediaScanner about the new file + final Intent scanFileIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); + scanFileIntent.setData(attachment.persistedUri); + mContext.sendBroadcast(scanFileIntent); + + if (ContentType.isImageType(attachment.contentType)) { + imageCount++; + } else if (ContentType.isVideoType(attachment.contentType)) { + videoCount++; + } else { + otherCount++; + // Inform DownloadManager of the file so it will show in the "downloads" app + final DownloadManager downloadManager = + (DownloadManager) mContext.getSystemService( + Context.DOWNLOAD_SERVICE); + final String filePath = attachment.persistedUri.getPath(); + final File file = new File(filePath); + + if (file.exists()) { + downloadManager.addCompletedDownload( + file.getName() /* title */, + mContext.getString( + R.string.attachment_file_description) /* description */, + true /* isMediaScannerScannable */, + attachment.contentType, + file.getAbsolutePath(), + file.length(), + false /* showNotification */); + } + } + } + + String message; + if (failCount > 0) { + message = mContext.getResources().getQuantityString( + R.plurals.attachment_save_error, failCount, failCount); + } else { + int messageId = R.plurals.attachments_saved; + if (otherCount > 0) { + if (imageCount + videoCount == 0) { + messageId = R.plurals.attachments_saved_to_downloads; + } + } else { + if (videoCount == 0) { + messageId = R.plurals.photos_saved_to_album; + } else if (imageCount == 0) { + messageId = R.plurals.videos_saved_to_album; + } else { + messageId = R.plurals.attachments_saved_to_album; + } + } + final String appName = mContext.getResources().getString(R.string.app_name); + final int count = imageCount + videoCount + otherCount; + message = mContext.getResources().getQuantityString( + messageId, count, count, appName); + } + UiUtils.showToastAtBottom(message); + } + } + + private void invalidateOptionsMenu() { + final Activity activity = getActivity(); + // TODO: Add the supportInvalidateOptionsMenu call to the host activity. + if (activity == null || !(activity instanceof BugleActionBarActivity)) { + return; + } + ((BugleActionBarActivity) activity).supportInvalidateOptionsMenu(); + } + + @Override + public void setOptionsMenuVisibility(final boolean visible) { + setHasOptionsMenu(visible); + } + + @Override + public int getConversationSelfSubId() { + final String selfParticipantId = mComposeMessageView.getConversationSelfId(); + final ParticipantData self = mBinding.getData().getSelfParticipantById(selfParticipantId); + // If the self id or the self participant data hasn't been loaded yet, fallback to + // the default setting. + return self == null ? ParticipantData.DEFAULT_SELF_SUB_ID : self.getSubId(); + } + + @Override + public void invalidateActionBar() { + mHost.invalidateActionBar(); + } + + @Override + public void dismissActionMode() { + mHost.dismissActionMode(); + } + + @Override + public void selectSim(final SubscriptionListEntry subscriptionData) { + mComposeMessageView.selectSim(subscriptionData); + mHost.onStartComposeMessage(); + } + + @Override + public void onStartComposeMessage() { + mHost.onStartComposeMessage(); + } + + @Override + public SubscriptionListEntry getSubscriptionEntryForSelfParticipant( + final String selfParticipantId, final boolean excludeDefault) { + // TODO: ConversationMessageView is the only one using this. We should probably + // inject this into the view during binding in the ConversationMessageAdapter. + return mBinding.getData().getSubscriptionEntryForSelfParticipant(selfParticipantId, + excludeDefault); + } + + @Override + public SimSelectorView getSimSelectorView() { + return (SimSelectorView) getView().findViewById(R.id.sim_selector); + } + + @Override + public MediaPicker createMediaPicker() { + return new MediaPicker(getActivity()); + } + + @Override + public void notifyOfAttachmentLoadFailed() { + UiUtils.showToastAtBottom(R.string.attachment_load_failed_dialog_message); + } + + @Override + public void warnOfExceedingMessageLimit(final boolean sending, final boolean tooManyVideos) { + warnOfExceedingMessageLimit(sending, mComposeMessageView, mConversationId, + getActivity(), tooManyVideos); + } + + public static void warnOfExceedingMessageLimit(final boolean sending, + final ComposeMessageView composeMessageView, final String conversationId, + final Activity activity, final boolean tooManyVideos) { + final AlertDialog.Builder builder = + new AlertDialog.Builder(activity) + .setTitle(R.string.mms_attachment_limit_reached); + + if (sending) { + if (tooManyVideos) { + builder.setMessage(R.string.video_attachment_limit_exceeded_when_sending); + } else { + builder.setMessage(R.string.attachment_limit_reached_dialog_message_when_sending) + .setNegativeButton(R.string.attachment_limit_reached_send_anyway, + new OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, + final int which) { + composeMessageView.sendMessageIgnoreMessageSizeLimit(); + } + }); + } + builder.setPositiveButton(android.R.string.ok, new OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + showAttachmentChooser(conversationId, activity); + }}); + } else { + builder.setMessage(R.string.attachment_limit_reached_dialog_message_when_composing) + .setPositiveButton(android.R.string.ok, null); + } + builder.show(); + } + + @Override + public void showAttachmentChooser() { + showAttachmentChooser(mConversationId, getActivity()); + } + + public static void showAttachmentChooser(final String conversationId, + final Activity activity) { + UIIntents.get().launchAttachmentChooserActivity(activity, + conversationId, REQUEST_CHOOSE_ATTACHMENTS); + } + + private void updateActionAndStatusBarColor(final ActionBar actionBar) { + final int themeColor = ConversationDrawables.get().getConversationThemeColor(); + actionBar.setBackgroundDrawable(new ColorDrawable(themeColor)); + UiUtils.setStatusBarColor(getActivity(), themeColor); + } + + public void updateActionBar(final ActionBar actionBar) { + if (mComposeMessageView == null || !mComposeMessageView.updateActionBar(actionBar)) { + updateActionAndStatusBarColor(actionBar); + // We update this regardless of whether or not the action bar is showing so that we + // don't get a race when it reappears. + actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_CUSTOM); + actionBar.setDisplayHomeAsUpEnabled(true); + // Reset the back arrow to its default + actionBar.setHomeAsUpIndicator(0); + View customView = actionBar.getCustomView(); + if (customView == null || customView.getId() != R.id.conversation_title_container) { + final LayoutInflater inflator = (LayoutInflater) + getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE); + customView = inflator.inflate(R.layout.action_bar_conversation_name, null); + customView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View v) { + onBackPressed(); + } + }); + actionBar.setCustomView(customView); + } + + final TextView conversationNameView = + (TextView) customView.findViewById(R.id.conversation_title); + final String conversationName = getConversationName(); + if (!TextUtils.isEmpty(conversationName)) { + // RTL : To format conversation title if it happens to be phone numbers. + final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); + final String formattedName = bidiFormatter.unicodeWrap( + UiUtils.commaEllipsize( + conversationName, + conversationNameView.getPaint(), + conversationNameView.getWidth(), + getString(R.string.plus_one), + getString(R.string.plus_n)).toString(), + TextDirectionHeuristicsCompat.LTR); + conversationNameView.setText(formattedName); + // In case phone numbers are mixed in the conversation name, we need to vocalize it. + final String vocalizedConversationName = + AccessibilityUtil.getVocalizedPhoneNumber(getResources(), conversationName); + conversationNameView.setContentDescription(vocalizedConversationName); + getActivity().setTitle(conversationName); + } else { + final String appName = getString(R.string.app_name); + conversationNameView.setText(appName); + getActivity().setTitle(appName); + } + + // When conversation is showing and media picker is not showing, then hide the action + // bar only when we are in landscape mode, with IME open. + if (mHost.isImeOpen() && UiUtils.isLandscapeMode()) { + actionBar.hide(); + } else { + actionBar.show(); + } + } + } + + @Override + public boolean shouldShowSubjectEditor() { + return true; + } + + @Override + public boolean shouldHideAttachmentsWhenSimSelectorShown() { + return false; + } + + @Override + public void showHideSimSelector(final boolean show) { + // no-op for now + } + + @Override + public int getSimSelectorItemLayoutId() { + return R.layout.sim_selector_item_view; + } + + @Override + public Uri getSelfSendButtonIconUri() { + return null; // use default button icon uri + } + + @Override + public int overrideCounterColor() { + return -1; // don't override the color + } + + @Override + public void onAttachmentsChanged(final boolean haveAttachments) { + // no-op for now + } + + @Override + public void onDraftChanged(final DraftMessageData data, final int changeFlags) { + mDraftMessageDataModel.ensureBound(data); + // We're specifically only interested in ATTACHMENTS_CHANGED from the widget. Ignore + // other changes. When the widget changes an attachment, we need to reload the draft. + if (changeFlags == + (DraftMessageData.WIDGET_CHANGED | DraftMessageData.ATTACHMENTS_CHANGED)) { + mClearLocalDraft = true; // force a reload of the draft in onResume + } + } + + @Override + public void onDraftAttachmentLimitReached(final DraftMessageData data) { + // no-op for now + } + + @Override + public void onDraftAttachmentLoadFailed() { + // no-op for now + } + + @Override + public int getAttachmentsClearedFlags() { + return DraftMessageData.ATTACHMENTS_CHANGED; + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationInput.java b/src/com/android/messaging/ui/conversation/ConversationInput.java new file mode 100644 index 0000000..bf60aa8 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationInput.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversation; + +import android.os.Bundle; +import android.support.v7.app.ActionBar; + +/** + * The base class for a method of user input, e.g. media picker. + */ +public abstract class ConversationInput { + /** + * The host component where all input components are contained. This is typically the + * conversation fragment but may be mocked in test code. + */ + public interface ConversationInputBase { + boolean showHideInternal(final ConversationInput target, final boolean show, + final boolean animate); + String getInputStateKey(final ConversationInput input); + void beginUpdate(); + void handleOnShow(final ConversationInput target); + void endUpdate(); + } + + protected boolean mShowing; + protected ConversationInputBase mConversationInputBase; + + public abstract boolean show(boolean animate); + public abstract boolean hide(boolean animate); + + public ConversationInput(ConversationInputBase baseHost, final boolean isShowing) { + mConversationInputBase = baseHost; + mShowing = isShowing; + } + + public boolean onBackPressed() { + if (mShowing) { + mConversationInputBase.showHideInternal(this, false /* show */, true /* animate */); + return true; + } + return false; + } + + public boolean onNavigationUpPressed() { + return false; + } + + /** + * Toggle the visibility of this view. + * @param animate + * @return true if the view is now shown, false if it now hidden + */ + public boolean toggle(final boolean animate) { + mConversationInputBase.showHideInternal(this, !mShowing /* show */, true /* animate */); + return mShowing; + } + + public void saveState(final Bundle savedState) { + savedState.putBoolean(mConversationInputBase.getInputStateKey(this), mShowing); + } + + public void restoreState(final Bundle savedState) { + // Things are hidden by default, so only handle show. + if (savedState.getBoolean(mConversationInputBase.getInputStateKey(this))) { + mConversationInputBase.showHideInternal(this, true /* show */, false /* animate */); + } + } + + public boolean updateActionBar(final ActionBar actionBar) { + return false; + } + + /** + * Update our visibility flag in response to visibility change, both for actions + * initiated by this class (through the show/hide methods), and for external changes + * tracked by event listeners (e.g. ImeStateObserver, MediaPickerListener). As part of + * handling an input showing, we will hide all other inputs to ensure they are mutually + * exclusive. + */ + protected void onVisibilityChanged(final boolean visible) { + if (mShowing != visible) { + mConversationInputBase.beginUpdate(); + mShowing = visible; + if (visible) { + mConversationInputBase.handleOnShow(this); + } + mConversationInputBase.endUpdate(); + } + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationInputManager.java b/src/com/android/messaging/ui/conversation/ConversationInputManager.java new file mode 100644 index 0000000..e10abe7 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationInputManager.java @@ -0,0 +1,550 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversation; + +import android.app.FragmentManager; +import android.content.Context; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.widget.EditText; + +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.binding.ImmutableBindingRef; +import com.android.messaging.datamodel.data.ConversationData; +import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; +import com.android.messaging.datamodel.data.ConversationData.SimpleConversationDataListener; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.PendingAttachmentData; +import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; +import com.android.messaging.ui.ConversationDrawables; +import com.android.messaging.ui.mediapicker.MediaPicker; +import com.android.messaging.ui.mediapicker.MediaPicker.MediaPickerListener; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ImeUtil; +import com.android.messaging.util.ImeUtil.ImeStateHost; +import com.google.common.annotations.VisibleForTesting; + +import java.util.Collection; + +/** + * Manages showing/hiding/persisting different mutually exclusive UI components nested in + * ConversationFragment that take user inputs, i.e. media picker, SIM selector and + * IME keyboard (the IME keyboard is not owned by Bugle, but we try to model it the same way + * as the other components). + */ +public class ConversationInputManager implements ConversationInput.ConversationInputBase { + /** + * The host component where all input components are contained. This is typically the + * conversation fragment but may be mocked in test code. + */ + public interface ConversationInputHost extends DraftMessageSubscriptionDataProvider { + void invalidateActionBar(); + void setOptionsMenuVisibility(boolean visible); + void dismissActionMode(); + void selectSim(SubscriptionListEntry subscriptionData); + void onStartComposeMessage(); + SimSelectorView getSimSelectorView(); + MediaPicker createMediaPicker(); + void showHideSimSelector(boolean show); + int getSimSelectorItemLayoutId(); + } + + /** + * The "sink" component where all inputs components will direct the user inputs to. This is + * typically the ComposeMessageView but may be mocked in test code. + */ + public interface ConversationInputSink { + void onMediaItemsSelected(Collection items); + void onMediaItemsUnselected(MessagePartData item); + void onPendingAttachmentAdded(PendingAttachmentData pendingItem); + void resumeComposeMessage(); + EditText getComposeEditText(); + void setAccessibility(boolean enabled); + } + + private final ConversationInputHost mHost; + private final ConversationInputSink mSink; + + /** Dependencies injected from the host during construction */ + private final FragmentManager mFragmentManager; + private final Context mContext; + private final ImeStateHost mImeStateHost; + private final ImmutableBindingRef mConversationDataModel; + private final ImmutableBindingRef mDraftDataModel; + + private final ConversationInput[] mInputs; + private final ConversationMediaPicker mMediaInput; + private final ConversationSimSelector mSimInput; + private final ConversationImeKeyboard mImeInput; + private int mUpdateCount; + + private final ImeUtil.ImeStateObserver mImeStateObserver = new ImeUtil.ImeStateObserver() { + @Override + public void onImeStateChanged(final boolean imeOpen) { + mImeInput.onVisibilityChanged(imeOpen); + } + }; + + private final ConversationDataListener mDataListener = new SimpleConversationDataListener() { + @Override + public void onConversationParticipantDataLoaded(ConversationData data) { + mConversationDataModel.ensureBound(data); + } + + @Override + public void onSubscriptionListDataLoaded(ConversationData data) { + mConversationDataModel.ensureBound(data); + mSimInput.onSubscriptionListDataLoaded(data.getSubscriptionListData()); + } + }; + + public ConversationInputManager( + final Context context, + final ConversationInputHost host, + final ConversationInputSink sink, + final ImeStateHost imeStateHost, + final FragmentManager fm, + final BindingBase conversationDataModel, + final BindingBase draftDataModel, + final Bundle savedState) { + mHost = host; + mSink = sink; + mFragmentManager = fm; + mContext = context; + mImeStateHost = imeStateHost; + mConversationDataModel = BindingBase.createBindingReference(conversationDataModel); + mDraftDataModel = BindingBase.createBindingReference(draftDataModel); + + // Register listeners on dependencies. + mImeStateHost.registerImeStateObserver(mImeStateObserver); + mConversationDataModel.getData().addConversationDataListener(mDataListener); + + // Initialize the inputs + mMediaInput = new ConversationMediaPicker(this); + mSimInput = new SimSelector(this); + mImeInput = new ConversationImeKeyboard(this, mImeStateHost.isImeOpen()); + mInputs = new ConversationInput[] { mMediaInput, mSimInput, mImeInput }; + + if (savedState != null) { + for (int i = 0; i < mInputs.length; i++) { + mInputs[i].restoreState(savedState); + } + } + updateHostOptionsMenu(); + } + + public void onDetach() { + mImeStateHost.unregisterImeStateObserver(mImeStateObserver); + // Don't need to explicitly unregister for data model events. It will unregister all + // listeners automagically on unbind. + } + + public void onSaveInputState(final Bundle savedState) { + for (int i = 0; i < mInputs.length; i++) { + mInputs[i].saveState(savedState); + } + } + + @Override + public String getInputStateKey(final ConversationInput input) { + return input.getClass().getCanonicalName() + "_savedstate_"; + } + + public boolean onBackPressed() { + for (int i = 0; i < mInputs.length; i++) { + if (mInputs[i].onBackPressed()) { + return true; + } + } + return false; + } + + public boolean onNavigationUpPressed() { + for (int i = 0; i < mInputs.length; i++) { + if (mInputs[i].onNavigationUpPressed()) { + return true; + } + } + return false; + } + + public void resetMediaPickerState() { + mMediaInput.resetViewHolderState(); + } + + public void showHideMediaPicker(final boolean show, final boolean animate) { + showHideInternal(mMediaInput, show, animate); + } + + /** + * Show or hide the sim selector + * @param show visibility + * @param animate whether to animate the change in visibility + * @return true if the state of the visibility was changed + */ + public boolean showHideSimSelector(final boolean show, final boolean animate) { + return showHideInternal(mSimInput, show, animate); + } + + public void showHideImeKeyboard(final boolean show, final boolean animate) { + showHideInternal(mImeInput, show, animate); + } + + public void hideAllInputs(final boolean animate) { + beginUpdate(); + for (int i = 0; i < mInputs.length; i++) { + showHideInternal(mInputs[i], false, animate); + } + endUpdate(); + } + + /** + * Toggle the visibility of the sim selector. + * @param animate + * @param subEntry + * @return true if the view is now shown, false if it now hidden + */ + public boolean toggleSimSelector(final boolean animate, final SubscriptionListEntry subEntry) { + mSimInput.setSelected(subEntry); + return mSimInput.toggle(animate); + } + + public boolean updateActionBar(final ActionBar actionBar) { + for (int i = 0; i < mInputs.length; i++) { + if (mInputs[i].mShowing) { + return mInputs[i].updateActionBar(actionBar); + } + } + return false; + } + + @VisibleForTesting + boolean isMediaPickerVisible() { + return mMediaInput.mShowing; + } + + @VisibleForTesting + boolean isSimSelectorVisible() { + return mSimInput.mShowing; + } + + @VisibleForTesting + boolean isImeKeyboardVisible() { + return mImeInput.mShowing; + } + + @VisibleForTesting + void testNotifyImeStateChanged(final boolean imeOpen) { + mImeStateObserver.onImeStateChanged(imeOpen); + } + + /** + * returns true if the state of the visibility was actually changed + */ + @Override + public boolean showHideInternal(final ConversationInput target, final boolean show, + final boolean animate) { + if (!mConversationDataModel.isBound()) { + return false; + } + + if (target.mShowing == show) { + return false; + } + beginUpdate(); + boolean success; + if (!show) { + success = target.hide(animate); + } else { + success = target.show(animate); + } + + if (success) { + target.onVisibilityChanged(show); + } + endUpdate(); + return true; + } + + @Override + public void handleOnShow(final ConversationInput target) { + if (!mConversationDataModel.isBound()) { + return; + } + beginUpdate(); + + // All inputs are mutually exclusive. Showing one will hide everything else. + // The one exception, is that the keyboard and location media chooser can be open at the + // time to enable searching within that chooser + for (int i = 0; i < mInputs.length; i++) { + final ConversationInput currInput = mInputs[i]; + if (currInput != target) { + // TODO : If there's more exceptions we will want to make this more + // generic + if (currInput instanceof ConversationMediaPicker && + target instanceof ConversationImeKeyboard && + mMediaInput.getExistingOrCreateMediaPicker() != null && + mMediaInput.getExistingOrCreateMediaPicker().canShowIme()) { + // Allow the keyboard and location mediaPicker to be open at the same time, + // but ensure the media picker is full screen to allow enough room + mMediaInput.getExistingOrCreateMediaPicker().setFullScreen(true); + continue; + } + showHideInternal(currInput, false /* show */, false /* animate */); + } + } + // Always dismiss action mode on show. + mHost.dismissActionMode(); + // Invoking any non-keyboard input UI is treated as starting message compose. + if (target != mImeInput) { + mHost.onStartComposeMessage(); + } + endUpdate(); + } + + @Override + public void beginUpdate() { + mUpdateCount++; + } + + @Override + public void endUpdate() { + Assert.isTrue(mUpdateCount > 0); + if (--mUpdateCount == 0) { + // Always try to update the host action bar after every update cycle. + mHost.invalidateActionBar(); + } + } + + private void updateHostOptionsMenu() { + mHost.setOptionsMenuVisibility(!mMediaInput.isOpen()); + } + + /** + * Manages showing/hiding the media picker in conversation. + */ + private class ConversationMediaPicker extends ConversationInput { + public ConversationMediaPicker(ConversationInputBase baseHost) { + super(baseHost, false); + } + + private MediaPicker mMediaPicker; + + @Override + public boolean show(boolean animate) { + if (mMediaPicker == null) { + mMediaPicker = getExistingOrCreateMediaPicker(); + setConversationThemeColor(ConversationDrawables.get().getConversationThemeColor()); + mMediaPicker.setSubscriptionDataProvider(mHost); + mMediaPicker.setDraftMessageDataModel(mDraftDataModel); + mMediaPicker.setListener(new MediaPickerListener() { + @Override + public void onOpened() { + handleStateChange(); + } + + @Override + public void onFullScreenChanged(boolean fullScreen) { + // When we're full screen, we want to disable accessibility on the + // ComposeMessageView controls (attach button, message input, sim chooser) + // that are hiding underneath the action bar. + mSink.setAccessibility(!fullScreen /*enabled*/); + handleStateChange(); + } + + @Override + public void onDismissed() { + // Re-enable accessibility on all controls now that the media picker is + // going away. + mSink.setAccessibility(true /*enabled*/); + handleStateChange(); + } + + private void handleStateChange() { + onVisibilityChanged(isOpen()); + mHost.invalidateActionBar(); + updateHostOptionsMenu(); + } + + @Override + public void onItemsSelected(final Collection items, + final boolean resumeCompose) { + mSink.onMediaItemsSelected(items); + mHost.invalidateActionBar(); + if (resumeCompose) { + mSink.resumeComposeMessage(); + } + } + + @Override + public void onItemUnselected(final MessagePartData item) { + mSink.onMediaItemsUnselected(item); + mHost.invalidateActionBar(); + } + + @Override + public void onConfirmItemSelection() { + mSink.resumeComposeMessage(); + } + + @Override + public void onPendingItemAdded(final PendingAttachmentData pendingItem) { + mSink.onPendingAttachmentAdded(pendingItem); + } + + @Override + public void onChooserSelected(final int chooserIndex) { + mHost.invalidateActionBar(); + mHost.dismissActionMode(); + } + }); + } + + mMediaPicker.open(MediaPicker.MEDIA_TYPE_DEFAULT, animate); + + return isOpen(); + } + + @Override + public boolean hide(boolean animate) { + if (mMediaPicker != null) { + mMediaPicker.dismiss(animate); + } + return !isOpen(); + } + + public void resetViewHolderState() { + if (mMediaPicker != null) { + mMediaPicker.resetViewHolderState(); + } + } + + public void setConversationThemeColor(final int themeColor) { + if (mMediaPicker != null) { + mMediaPicker.setConversationThemeColor(themeColor); + } + } + + private boolean isOpen() { + return (mMediaPicker != null && mMediaPicker.isOpen()); + } + + private MediaPicker getExistingOrCreateMediaPicker() { + if (mMediaPicker != null) { + return mMediaPicker; + } + MediaPicker mediaPicker = (MediaPicker) + mFragmentManager.findFragmentByTag(MediaPicker.FRAGMENT_TAG); + if (mediaPicker == null) { + mediaPicker = mHost.createMediaPicker(); + if (mediaPicker == null) { + return null; // this use of ComposeMessageView doesn't support media picking + } + mFragmentManager.beginTransaction().replace( + R.id.mediapicker_container, + mediaPicker, + MediaPicker.FRAGMENT_TAG).commit(); + } + return mediaPicker; + } + + @Override + public boolean updateActionBar(ActionBar actionBar) { + if (isOpen()) { + mMediaPicker.updateActionBar(actionBar); + return true; + } + return false; + } + + @Override + public boolean onNavigationUpPressed() { + if (isOpen() && mMediaPicker.isFullScreen()) { + return onBackPressed(); + } + return super.onNavigationUpPressed(); + } + + public boolean onBackPressed() { + if (mMediaPicker != null && mMediaPicker.onBackPressed()) { + return true; + } + return super.onBackPressed(); + } + } + + /** + * Manages showing/hiding the SIM selector in conversation. + */ + private class SimSelector extends ConversationSimSelector { + public SimSelector(ConversationInputBase baseHost) { + super(baseHost); + } + + @Override + protected SimSelectorView getSimSelectorView() { + return mHost.getSimSelectorView(); + } + + @Override + public int getSimSelectorItemLayoutId() { + return mHost.getSimSelectorItemLayoutId(); + } + + @Override + protected void selectSim(SubscriptionListEntry item) { + mHost.selectSim(item); + } + + @Override + public boolean show(boolean animate) { + final boolean result = super.show(animate); + mHost.showHideSimSelector(true /*show*/); + return result; + } + + @Override + public boolean hide(boolean animate) { + final boolean result = super.hide(animate); + mHost.showHideSimSelector(false /*show*/); + return result; + } + } + + /** + * Manages showing/hiding the IME keyboard in conversation. + */ + private class ConversationImeKeyboard extends ConversationInput { + public ConversationImeKeyboard(ConversationInputBase baseHost, final boolean isShowing) { + super(baseHost, isShowing); + } + + @Override + public boolean show(boolean animate) { + ImeUtil.get().showImeKeyboard(mContext, mSink.getComposeEditText()); + return true; + } + + @Override + public boolean hide(boolean animate) { + ImeUtil.get().hideImeKeyboard(mContext, mSink.getComposeEditText()); + return true; + } + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java b/src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java new file mode 100644 index 0000000..2748fff --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversation; + +import android.content.Context; +import android.database.Cursor; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.messaging.R; +import com.android.messaging.ui.AsyncImageView; +import com.android.messaging.ui.CursorRecyclerAdapter; +import com.android.messaging.ui.AsyncImageView.AsyncImageViewDelayLoader; +import com.android.messaging.ui.conversation.ConversationMessageView.ConversationMessageViewHost; +import com.android.messaging.util.Assert; + +import java.util.HashSet; +import java.util.List; + +/** + * Provides an interface to expose Conversation Message Cursor data to a UI widget like a + * RecyclerView. + */ +public class ConversationMessageAdapter extends + CursorRecyclerAdapter { + + private final ConversationMessageViewHost mHost; + private final AsyncImageViewDelayLoader mImageViewDelayLoader; + private final View.OnClickListener mViewClickListener; + private final View.OnLongClickListener mViewLongClickListener; + private boolean mOneOnOne; + private String mSelectedMessageId; + + public ConversationMessageAdapter(final Context context, final Cursor cursor, + final ConversationMessageViewHost host, + final AsyncImageViewDelayLoader imageViewDelayLoader, + final View.OnClickListener viewClickListener, + final View.OnLongClickListener longClickListener) { + super(context, cursor, 0); + mHost = host; + mViewClickListener = viewClickListener; + mViewLongClickListener = longClickListener; + mImageViewDelayLoader = imageViewDelayLoader; + setHasStableIds(true); + } + + @Override + public void bindViewHolder(final ConversationMessageViewHolder holder, + final Context context, final Cursor cursor) { + Assert.isTrue(holder.mView instanceof ConversationMessageView); + final ConversationMessageView conversationMessageView = + (ConversationMessageView) holder.mView; + conversationMessageView.bind(cursor, mOneOnOne, mSelectedMessageId); + } + + @Override + public ConversationMessageViewHolder createViewHolder(final Context context, + final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(context); + final ConversationMessageView conversationMessageView = (ConversationMessageView) + layoutInflater.inflate(R.layout.conversation_message_view, null); + conversationMessageView.setHost(mHost); + conversationMessageView.setImageViewDelayLoader(mImageViewDelayLoader); + return new ConversationMessageViewHolder(conversationMessageView, + mViewClickListener, mViewLongClickListener); + } + + public void setSelectedMessage(final String messageId) { + mSelectedMessageId = messageId; + notifyDataSetChanged(); + } + + public void setOneOnOne(final boolean oneOnOne, final boolean invalidate) { + if (mOneOnOne != oneOnOne) { + mOneOnOne = oneOnOne; + if (invalidate) { + notifyDataSetChanged(); + } + } + } + + /** + * ViewHolder that holds a ConversationMessageView. + */ + public static class ConversationMessageViewHolder extends RecyclerView.ViewHolder { + final View mView; + + /** + * @param viewClickListener a View.OnClickListener that should define the interaction when + * an item in the RecyclerView is clicked. + */ + public ConversationMessageViewHolder(final View itemView, + final View.OnClickListener viewClickListener, + final View.OnLongClickListener viewLongClickListener) { + super(itemView); + mView = itemView; + + mView.setOnClickListener(viewClickListener); + mView.setOnLongClickListener(viewLongClickListener); + } + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java b/src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java new file mode 100644 index 0000000..ef6aeb4 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversation; + +import android.animation.Animator; +import android.animation.Animator.AnimatorListener; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.util.AttributeSet; +import android.view.ViewGroup; +import android.widget.LinearLayout; + +import com.android.messaging.R; +import com.android.messaging.annotation.VisibleForAnimation; +import com.android.messaging.datamodel.data.ConversationMessageBubbleData; +import com.android.messaging.datamodel.data.ConversationMessageData; +import com.android.messaging.util.UiUtils; + +/** + * Shows the message bubble for one conversation message. It is able to animate size changes + * by morphing when the message content changes size. + */ +// TODO: Move functionality from ConversationMessageView into this class as appropriate +public class ConversationMessageBubbleView extends LinearLayout { + private int mIntrinsicWidth; + private int mMorphedWidth; + private ObjectAnimator mAnimator; + private boolean mShouldAnimateWidthChange; + private final ConversationMessageBubbleData mData; + private int mRunningStartWidth; + private ViewGroup mBubbleBackground; + + public ConversationMessageBubbleView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mData = new ConversationMessageBubbleData(); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mBubbleBackground = (ViewGroup) findViewById(R.id.message_text_and_info); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + final int newIntrinsicWidth = getMeasuredWidth(); + if (mIntrinsicWidth == 0 && newIntrinsicWidth != mIntrinsicWidth) { + if (mShouldAnimateWidthChange) { + kickOffMorphAnimation(mIntrinsicWidth, newIntrinsicWidth); + } + mIntrinsicWidth = newIntrinsicWidth; + } + + if (mMorphedWidth > 0) { + mBubbleBackground.getLayoutParams().width = mMorphedWidth; + } else { + mBubbleBackground.getLayoutParams().width = LayoutParams.WRAP_CONTENT; + } + mBubbleBackground.requestLayout(); + } + + @VisibleForAnimation + public void setMorphWidth(final int width) { + mMorphedWidth = width; + requestLayout(); + } + + public void bind(final ConversationMessageData data) { + final boolean changed = mData.bind(data); + // Animate width change only when we are binding to the same message, so that we may + // animate view size changes on the same message bubble due to things like status text + // change. + // Don't animate width change when the bubble contains attachments. Width animation is + // only suitable for text-only messages (where the bubble size change due to status or + // time stamp changes). + mShouldAnimateWidthChange = !changed && !data.hasAttachments(); + if (mAnimator == null) { + mMorphedWidth = 0; + } + } + + public void kickOffMorphAnimation(final int oldWidth, final int newWidth) { + if (mAnimator != null) { + mAnimator.setIntValues(mRunningStartWidth, newWidth); + return; + } + mRunningStartWidth = oldWidth; + mAnimator = ObjectAnimator.ofInt(this, "morphWidth", oldWidth, newWidth); + mAnimator.setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION); + mAnimator.addListener(new AnimatorListener() { + @Override + public void onAnimationStart(Animator animator) { + } + + @Override + public void onAnimationEnd(Animator animator) { + mAnimator = null; + mMorphedWidth = 0; + // Allow the bubble to resize if, for example, the status text changed during + // the animation. This will snap to the bigger size if needed. This is intentional + // as animating immediately after looks really bad and switching layout params + // during the original animation does not achieve the desired effect. + mBubbleBackground.getLayoutParams().width = LayoutParams.WRAP_CONTENT; + mBubbleBackground.requestLayout(); + } + + @Override + public void onAnimationCancel(Animator animator) { + } + + @Override + public void onAnimationRepeat(Animator animator) { + } + }); + mAnimator.start(); + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationMessageView.java b/src/com/android/messaging/ui/conversation/ConversationMessageView.java new file mode 100644 index 0000000..e22e2c7 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationMessageView.java @@ -0,0 +1,1206 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversation; + +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.support.annotation.Nullable; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.format.DateUtils; +import android.text.format.Formatter; +import android.text.style.URLSpan; +import android.text.util.Linkify; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.ImageView.ScaleType; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.data.ConversationMessageData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; +import com.android.messaging.datamodel.media.ImageRequestDescriptor; +import com.android.messaging.datamodel.media.MessagePartImageRequestDescriptor; +import com.android.messaging.datamodel.media.UriImageRequestDescriptor; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.AsyncImageView; +import com.android.messaging.ui.AsyncImageView.AsyncImageViewDelayLoader; +import com.android.messaging.ui.AudioAttachmentView; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.ui.ConversationDrawables; +import com.android.messaging.ui.MultiAttachmentLayout; +import com.android.messaging.ui.MultiAttachmentLayout.OnAttachmentClickListener; +import com.android.messaging.ui.PersonItemView; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.VideoThumbnailView; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.UiUtils; +import com.android.messaging.util.YouTubeUtil; +import com.google.common.base.Predicate; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * The view for a single entry in a conversation. + */ +public class ConversationMessageView extends FrameLayout implements View.OnClickListener, + View.OnLongClickListener, OnAttachmentClickListener { + public interface ConversationMessageViewHost { + boolean onAttachmentClick(ConversationMessageView view, MessagePartData attachment, + Rect imageBounds, boolean longPress); + SubscriptionListEntry getSubscriptionEntryForSelfParticipant(String selfParticipantId, + boolean excludeDefault); + } + + private final ConversationMessageData mData; + + private LinearLayout mMessageAttachmentsView; + private MultiAttachmentLayout mMultiAttachmentView; + private AsyncImageView mMessageImageView; + private TextView mMessageTextView; + private boolean mMessageTextHasLinks; + private boolean mMessageHasYouTubeLink; + private TextView mStatusTextView; + private TextView mTitleTextView; + private TextView mMmsInfoTextView; + private LinearLayout mMessageTitleLayout; + private TextView mSenderNameTextView; + private ContactIconView mContactIconView; + private ConversationMessageBubbleView mMessageBubble; + private View mSubjectView; + private TextView mSubjectLabel; + private TextView mSubjectText; + private View mDeliveredBadge; + private ViewGroup mMessageMetadataView; + private ViewGroup mMessageTextAndInfoView; + private TextView mSimNameView; + + private boolean mOneOnOne; + private ConversationMessageViewHost mHost; + + public ConversationMessageView(final Context context, final AttributeSet attrs) { + super(context, attrs); + // TODO: we should switch to using Binding and DataModel factory methods. + mData = new ConversationMessageData(); + } + + @Override + protected void onFinishInflate() { + mContactIconView = (ContactIconView) findViewById(R.id.conversation_icon); + mContactIconView.setOnLongClickListener(new OnLongClickListener() { + @Override + public boolean onLongClick(final View view) { + ConversationMessageView.this.performLongClick(); + return true; + } + }); + + mMessageAttachmentsView = (LinearLayout) findViewById(R.id.message_attachments); + mMultiAttachmentView = (MultiAttachmentLayout) findViewById(R.id.multiple_attachments); + mMultiAttachmentView.setOnAttachmentClickListener(this); + + mMessageImageView = (AsyncImageView) findViewById(R.id.message_image); + mMessageImageView.setOnClickListener(this); + mMessageImageView.setOnLongClickListener(this); + + mMessageTextView = (TextView) findViewById(R.id.message_text); + mMessageTextView.setOnClickListener(this); + IgnoreLinkLongClickHelper.ignoreLinkLongClick(mMessageTextView, this); + + mStatusTextView = (TextView) findViewById(R.id.message_status); + mTitleTextView = (TextView) findViewById(R.id.message_title); + mMmsInfoTextView = (TextView) findViewById(R.id.mms_info); + mMessageTitleLayout = (LinearLayout) findViewById(R.id.message_title_layout); + mSenderNameTextView = (TextView) findViewById(R.id.message_sender_name); + mMessageBubble = (ConversationMessageBubbleView) findViewById(R.id.message_content); + mSubjectView = findViewById(R.id.subject_container); + mSubjectLabel = (TextView) mSubjectView.findViewById(R.id.subject_label); + mSubjectText = (TextView) mSubjectView.findViewById(R.id.subject_text); + mDeliveredBadge = findViewById(R.id.smsDeliveredBadge); + mMessageMetadataView = (ViewGroup) findViewById(R.id.message_metadata); + mMessageTextAndInfoView = (ViewGroup) findViewById(R.id.message_text_and_info); + mSimNameView = (TextView) findViewById(R.id.sim_name); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + final int horizontalSpace = MeasureSpec.getSize(widthMeasureSpec); + final int iconSize = getResources() + .getDimensionPixelSize(R.dimen.conversation_message_contact_icon_size); + + final int unspecifiedMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + final int iconMeasureSpec = MeasureSpec.makeMeasureSpec(iconSize, MeasureSpec.EXACTLY); + + mContactIconView.measure(iconMeasureSpec, iconMeasureSpec); + + final int arrowWidth = + getResources().getDimensionPixelSize(R.dimen.message_bubble_arrow_width); + + // We need to subtract contact icon width twice from the horizontal space to get + // the max leftover space because we want the message bubble to extend no further than the + // starting position of the message bubble in the opposite direction. + final int maxLeftoverSpace = horizontalSpace - mContactIconView.getMeasuredWidth() * 2 + - arrowWidth - getPaddingLeft() - getPaddingRight(); + final int messageContentWidthMeasureSpec = MeasureSpec.makeMeasureSpec(maxLeftoverSpace, + MeasureSpec.AT_MOST); + + mMessageBubble.measure(messageContentWidthMeasureSpec, unspecifiedMeasureSpec); + + final int maxHeight = Math.max(mContactIconView.getMeasuredHeight(), + mMessageBubble.getMeasuredHeight()); + setMeasuredDimension(horizontalSpace, maxHeight + getPaddingBottom() + getPaddingTop()); + } + + @Override + protected void onLayout(final boolean changed, final int left, final int top, final int right, + final int bottom) { + final boolean isRtl = AccessibilityUtil.isLayoutRtl(this); + + final int iconWidth = mContactIconView.getMeasuredWidth(); + final int iconHeight = mContactIconView.getMeasuredHeight(); + final int iconTop = getPaddingTop(); + final int contentWidth = (right -left) - iconWidth - getPaddingLeft() - getPaddingRight(); + final int contentHeight = mMessageBubble.getMeasuredHeight(); + final int contentTop = iconTop; + + final int iconLeft; + final int contentLeft; + if (mData.getIsIncoming()) { + if (isRtl) { + iconLeft = (right - left) - getPaddingRight() - iconWidth; + contentLeft = iconLeft - contentWidth; + } else { + iconLeft = getPaddingLeft(); + contentLeft = iconLeft + iconWidth; + } + } else { + if (isRtl) { + iconLeft = getPaddingLeft(); + contentLeft = iconLeft + iconWidth; + } else { + iconLeft = (right - left) - getPaddingRight() - iconWidth; + contentLeft = iconLeft - contentWidth; + } + } + + mContactIconView.layout(iconLeft, iconTop, iconLeft + iconWidth, iconTop + iconHeight); + + mMessageBubble.layout(contentLeft, contentTop, contentLeft + contentWidth, + contentTop + contentHeight); + } + + /** + * Fills in the data associated with this view. + * + * @param cursor The cursor from a MessageList that this view is in, pointing to its entry. + */ + public void bind(final Cursor cursor) { + bind(cursor, true, null); + } + + /** + * Fills in the data associated with this view. + * + * @param cursor The cursor from a MessageList that this view is in, pointing to its entry. + * @param oneOnOne Whether this is a 1:1 conversation + */ + public void bind(final Cursor cursor, + final boolean oneOnOne, final String selectedMessageId) { + mOneOnOne = oneOnOne; + + // Update our UI model + mData.bind(cursor); + setSelected(TextUtils.equals(mData.getMessageId(), selectedMessageId)); + + // Update text and image content for the view. + updateViewContent(); + + // Update colors and layout parameters for the view. + updateViewAppearance(); + + updateContentDescription(); + } + + public void setHost(final ConversationMessageViewHost host) { + mHost = host; + } + + /** + * Sets a delay loader instance to manage loading / resuming of image attachments. + */ + public void setImageViewDelayLoader(final AsyncImageViewDelayLoader delayLoader) { + Assert.notNull(mMessageImageView); + mMessageImageView.setDelayLoader(delayLoader); + mMultiAttachmentView.setImageViewDelayLoader(delayLoader); + } + + public ConversationMessageData getData() { + return mData; + } + + /** + * Returns whether we should show simplified visual style for the message view (i.e. hide the + * avatar and bubble arrow, reduce padding). + */ + private boolean shouldShowSimplifiedVisualStyle() { + return mData.getCanClusterWithPreviousMessage(); + } + + /** + * Returns whether we need to show message bubble arrow. We don't show arrow if the message + * contains media attachments or if shouldShowSimplifiedVisualStyle() is true. + */ + private boolean shouldShowMessageBubbleArrow() { + return !shouldShowSimplifiedVisualStyle() + && !(mData.hasAttachments() || mMessageHasYouTubeLink); + } + + /** + * Returns whether we need to show a message bubble for text content. + */ + private boolean shouldShowMessageTextBubble() { + if (mData.hasText()) { + return true; + } + final String subjectText = MmsUtils.cleanseMmsSubject(getResources(), + mData.getMmsSubject()); + if (!TextUtils.isEmpty(subjectText)) { + return true; + } + return false; + } + + private void updateViewContent() { + updateMessageContent(); + int titleResId = -1; + int statusResId = -1; + String statusText = null; + switch(mData.getStatus()) { + case MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING: + case MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING: + case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD: + case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD: + titleResId = R.string.message_title_downloading; + statusResId = R.string.message_status_downloading; + break; + + case MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD: + if (!OsUtil.isSecondaryUser()) { + titleResId = R.string.message_title_manual_download; + if (isSelected()) { + statusResId = R.string.message_status_download_action; + } else { + statusResId = R.string.message_status_download; + } + } + break; + + case MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE: + if (!OsUtil.isSecondaryUser()) { + titleResId = R.string.message_title_download_failed; + statusResId = R.string.message_status_download_error; + } + break; + + case MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED: + if (!OsUtil.isSecondaryUser()) { + titleResId = R.string.message_title_download_failed; + if (isSelected()) { + statusResId = R.string.message_status_download_action; + } else { + statusResId = R.string.message_status_download; + } + } + break; + + case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND: + case MessageData.BUGLE_STATUS_OUTGOING_SENDING: + statusResId = R.string.message_status_sending; + break; + + case MessageData.BUGLE_STATUS_OUTGOING_RESENDING: + case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY: + statusResId = R.string.message_status_send_retrying; + break; + + case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER: + statusResId = R.string.message_status_send_failed_emergency_number; + break; + + case MessageData.BUGLE_STATUS_OUTGOING_FAILED: + // don't show the error state unless we're the default sms app + if (PhoneUtils.getDefault().isDefaultSmsApp()) { + if (isSelected()) { + statusResId = R.string.message_status_resend; + } else { + statusResId = MmsUtils.mapRawStatusToErrorResourceId( + mData.getStatus(), mData.getRawTelephonyStatus()); + } + break; + } + // FALL THROUGH HERE + + case MessageData.BUGLE_STATUS_OUTGOING_COMPLETE: + case MessageData.BUGLE_STATUS_INCOMING_COMPLETE: + default: + if (!mData.getCanClusterWithNextMessage()) { + statusText = mData.getFormattedReceivedTimeStamp(); + } + break; + } + + final boolean titleVisible = (titleResId >= 0); + if (titleVisible) { + final String titleText = getResources().getString(titleResId); + mTitleTextView.setText(titleText); + + final String mmsInfoText = getResources().getString( + R.string.mms_info, + Formatter.formatFileSize(getContext(), mData.getSmsMessageSize()), + DateUtils.formatDateTime( + getContext(), + mData.getMmsExpiry(), + DateUtils.FORMAT_SHOW_DATE | + DateUtils.FORMAT_SHOW_TIME | + DateUtils.FORMAT_NUMERIC_DATE | + DateUtils.FORMAT_NO_YEAR)); + mMmsInfoTextView.setText(mmsInfoText); + mMessageTitleLayout.setVisibility(View.VISIBLE); + } else { + mMessageTitleLayout.setVisibility(View.GONE); + } + + final String subjectText = MmsUtils.cleanseMmsSubject(getResources(), + mData.getMmsSubject()); + final boolean subjectVisible = !TextUtils.isEmpty(subjectText); + + final boolean senderNameVisible = !mOneOnOne && !mData.getCanClusterWithNextMessage() + && mData.getIsIncoming(); + if (senderNameVisible) { + mSenderNameTextView.setText(mData.getSenderDisplayName()); + mSenderNameTextView.setVisibility(View.VISIBLE); + } else { + mSenderNameTextView.setVisibility(View.GONE); + } + + if (statusResId >= 0) { + statusText = getResources().getString(statusResId); + } + + // We set the text even if the view will be GONE for accessibility + mStatusTextView.setText(statusText); + final boolean statusVisible = !TextUtils.isEmpty(statusText); + if (statusVisible) { + mStatusTextView.setVisibility(View.VISIBLE); + } else { + mStatusTextView.setVisibility(View.GONE); + } + + final boolean deliveredBadgeVisible = + mData.getStatus() == MessageData.BUGLE_STATUS_OUTGOING_DELIVERED; + mDeliveredBadge.setVisibility(deliveredBadgeVisible ? View.VISIBLE : View.GONE); + + // Update the sim indicator. + final boolean showSimIconAsIncoming = mData.getIsIncoming() && + (!mData.hasAttachments() || shouldShowMessageTextBubble()); + final SubscriptionListEntry subscriptionEntry = + mHost.getSubscriptionEntryForSelfParticipant(mData.getSelfParticipantId(), + true /* excludeDefault */); + final boolean simNameVisible = subscriptionEntry != null && + !TextUtils.isEmpty(subscriptionEntry.displayName) && + !mData.getCanClusterWithNextMessage(); + if (simNameVisible) { + final String simNameText = mData.getIsIncoming() ? getResources().getString( + R.string.incoming_sim_name_text, subscriptionEntry.displayName) : + subscriptionEntry.displayName; + mSimNameView.setText(simNameText); + mSimNameView.setTextColor(showSimIconAsIncoming ? getResources().getColor( + R.color.timestamp_text_incoming) : subscriptionEntry.displayColor); + mSimNameView.setVisibility(VISIBLE); + } else { + mSimNameView.setText(null); + mSimNameView.setVisibility(GONE); + } + + final boolean metadataVisible = senderNameVisible || statusVisible + || deliveredBadgeVisible || simNameVisible; + mMessageMetadataView.setVisibility(metadataVisible ? View.VISIBLE : View.GONE); + + final boolean messageTextAndOrInfoVisible = titleVisible || subjectVisible + || mData.hasText() || metadataVisible; + mMessageTextAndInfoView.setVisibility( + messageTextAndOrInfoVisible ? View.VISIBLE : View.GONE); + + if (shouldShowSimplifiedVisualStyle()) { + mContactIconView.setVisibility(View.GONE); + mContactIconView.setImageResourceUri(null); + } else { + mContactIconView.setVisibility(View.VISIBLE); + final Uri avatarUri = AvatarUriUtil.createAvatarUri( + mData.getSenderProfilePhotoUri(), + mData.getSenderFullName(), + mData.getSenderNormalizedDestination(), + mData.getSenderContactLookupKey()); + mContactIconView.setImageResourceUri(avatarUri, mData.getSenderContactId(), + mData.getSenderContactLookupKey(), mData.getSenderNormalizedDestination()); + } + } + + private void updateMessageContent() { + // We must update the text before the attachments since we search the text to see if we + // should make a preview youtube image in the attachments + updateMessageText(); + updateMessageAttachments(); + updateMessageSubject(); + mMessageBubble.bind(mData); + } + + private void updateMessageAttachments() { + // Bind video, audio, and VCard attachments. If there are multiple, they stack vertically. + bindAttachmentsOfSameType(sVideoFilter, + R.layout.message_video_attachment, mVideoViewBinder, VideoThumbnailView.class); + bindAttachmentsOfSameType(sAudioFilter, + R.layout.message_audio_attachment, mAudioViewBinder, AudioAttachmentView.class); + bindAttachmentsOfSameType(sVCardFilter, + R.layout.message_vcard_attachment, mVCardViewBinder, PersonItemView.class); + + // Bind image attachments. If there are multiple, they are shown in a collage view. + final List imageParts = mData.getAttachments(sImageFilter); + if (imageParts.size() > 1) { + Collections.sort(imageParts, sImageComparator); + mMultiAttachmentView.bindAttachments(imageParts, null, imageParts.size()); + mMultiAttachmentView.setVisibility(View.VISIBLE); + } else { + mMultiAttachmentView.setVisibility(View.GONE); + } + + // In the case that we have no image attachments and exactly one youtube link in a message + // then we will show a preview. + String youtubeThumbnailUrl = null; + String originalYoutubeLink = null; + if (mMessageTextHasLinks && imageParts.size() == 0) { + CharSequence messageTextWithSpans = mMessageTextView.getText(); + final URLSpan[] spans = ((Spanned) messageTextWithSpans).getSpans(0, + messageTextWithSpans.length(), URLSpan.class); + for (URLSpan span : spans) { + String url = span.getURL(); + String youtubeLinkForUrl = YouTubeUtil.getYoutubePreviewImageLink(url); + if (!TextUtils.isEmpty(youtubeLinkForUrl)) { + if (TextUtils.isEmpty(youtubeThumbnailUrl)) { + // Save the youtube link if we don't already have one + youtubeThumbnailUrl = youtubeLinkForUrl; + originalYoutubeLink = url; + } else { + // We already have a youtube link. This means we have two youtube links so + // we shall show none. + youtubeThumbnailUrl = null; + originalYoutubeLink = null; + break; + } + } + } + } + // We need to keep track if we have a youtube link in the message so that we will not show + // the arrow + mMessageHasYouTubeLink = !TextUtils.isEmpty(youtubeThumbnailUrl); + + // We will show the message image view if there is one attachment or one youtube link + if (imageParts.size() == 1 || mMessageHasYouTubeLink) { + // Get the display metrics for a hint for how large to pull the image data into + final WindowManager windowManager = (WindowManager) getContext(). + getSystemService(Context.WINDOW_SERVICE); + final DisplayMetrics displayMetrics = new DisplayMetrics(); + windowManager.getDefaultDisplay().getMetrics(displayMetrics); + + final int iconSize = getResources() + .getDimensionPixelSize(R.dimen.conversation_message_contact_icon_size); + final int desiredWidth = displayMetrics.widthPixels - iconSize - iconSize; + + if (imageParts.size() == 1) { + final MessagePartData imagePart = imageParts.get(0); + // If the image is big, we want to scale it down to save memory since we're going to + // scale it down to fit into the bubble width. We don't constrain the height. + final ImageRequestDescriptor imageRequest = + new MessagePartImageRequestDescriptor(imagePart, + desiredWidth, + MessagePartData.UNSPECIFIED_SIZE, + false); + adjustImageViewBounds(imagePart); + mMessageImageView.setImageResourceId(imageRequest); + mMessageImageView.setTag(imagePart); + } else { + // Youtube Thumbnail image + final ImageRequestDescriptor imageRequest = + new UriImageRequestDescriptor(Uri.parse(youtubeThumbnailUrl), desiredWidth, + MessagePartData.UNSPECIFIED_SIZE, true /* allowCompression */, + true /* isStatic */, false /* cropToCircle */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + mMessageImageView.setImageResourceId(imageRequest); + mMessageImageView.setTag(originalYoutubeLink); + } + mMessageImageView.setVisibility(View.VISIBLE); + } else { + mMessageImageView.setImageResourceId(null); + mMessageImageView.setVisibility(View.GONE); + } + + // Show the message attachments container if any of its children are visible + boolean attachmentsVisible = false; + for (int i = 0, size = mMessageAttachmentsView.getChildCount(); i < size; i++) { + final View attachmentView = mMessageAttachmentsView.getChildAt(i); + if (attachmentView.getVisibility() == View.VISIBLE) { + attachmentsVisible = true; + break; + } + } + mMessageAttachmentsView.setVisibility(attachmentsVisible ? View.VISIBLE : View.GONE); + } + + private void bindAttachmentsOfSameType(final Predicate attachmentTypeFilter, + final int attachmentViewLayoutRes, final AttachmentViewBinder viewBinder, + final Class attachmentViewClass) { + final LayoutInflater layoutInflater = LayoutInflater.from(getContext()); + + // Iterate through all attachments of a particular type (video, audio, etc). + // Find the first attachment index that matches the given type if possible. + int attachmentViewIndex = -1; + View existingAttachmentView; + do { + existingAttachmentView = mMessageAttachmentsView.getChildAt(++attachmentViewIndex); + } while (existingAttachmentView != null && + !(attachmentViewClass.isInstance(existingAttachmentView))); + + for (final MessagePartData attachment : mData.getAttachments(attachmentTypeFilter)) { + View attachmentView = mMessageAttachmentsView.getChildAt(attachmentViewIndex); + if (!attachmentViewClass.isInstance(attachmentView)) { + attachmentView = layoutInflater.inflate(attachmentViewLayoutRes, + mMessageAttachmentsView, false /* attachToRoot */); + attachmentView.setOnClickListener(this); + attachmentView.setOnLongClickListener(this); + mMessageAttachmentsView.addView(attachmentView, attachmentViewIndex); + } + viewBinder.bindView(attachmentView, attachment); + attachmentView.setTag(attachment); + attachmentView.setVisibility(View.VISIBLE); + attachmentViewIndex++; + } + // If there are unused views left over, unbind or remove them. + while (attachmentViewIndex < mMessageAttachmentsView.getChildCount()) { + final View attachmentView = mMessageAttachmentsView.getChildAt(attachmentViewIndex); + if (attachmentViewClass.isInstance(attachmentView)) { + mMessageAttachmentsView.removeViewAt(attachmentViewIndex); + } else { + // No more views of this type; we're done. + break; + } + } + } + + private void updateMessageSubject() { + final String subjectText = MmsUtils.cleanseMmsSubject(getResources(), + mData.getMmsSubject()); + final boolean subjectVisible = !TextUtils.isEmpty(subjectText); + + if (subjectVisible) { + mSubjectText.setText(subjectText); + mSubjectView.setVisibility(View.VISIBLE); + } else { + mSubjectView.setVisibility(View.GONE); + } + } + + private void updateMessageText() { + final String text = mData.getText(); + if (!TextUtils.isEmpty(text)) { + mMessageTextView.setText(text); + // Linkify phone numbers, web urls, emails, and map addresses to allow users to + // click on them and take the default intent. + mMessageTextHasLinks = Linkify.addLinks(mMessageTextView, Linkify.ALL); + mMessageTextView.setVisibility(View.VISIBLE); + } else { + mMessageTextView.setVisibility(View.GONE); + mMessageTextHasLinks = false; + } + } + + private void updateViewAppearance() { + final Resources res = getResources(); + final ConversationDrawables drawableProvider = ConversationDrawables.get(); + final boolean incoming = mData.getIsIncoming(); + final boolean outgoing = !incoming; + final boolean showArrow = shouldShowMessageBubbleArrow(); + + final int messageTopPaddingClustered = + res.getDimensionPixelSize(R.dimen.message_padding_same_author); + final int messageTopPaddingDefault = + res.getDimensionPixelSize(R.dimen.message_padding_default); + final int arrowWidth = res.getDimensionPixelOffset(R.dimen.message_bubble_arrow_width); + final int messageTextMinHeightDefault = res.getDimensionPixelSize( + R.dimen.conversation_message_contact_icon_size); + final int messageTextLeftRightPadding = res.getDimensionPixelOffset( + R.dimen.message_text_left_right_padding); + final int textTopPaddingDefault = res.getDimensionPixelOffset( + R.dimen.message_text_top_padding); + final int textBottomPaddingDefault = res.getDimensionPixelOffset( + R.dimen.message_text_bottom_padding); + + // These values depend on whether the message has text, attachments, or both. + // We intentionally don't set defaults, so the compiler will tell us if we forget + // to set one of them, or if we set one more than once. + final int contentLeftPadding, contentRightPadding; + final Drawable textBackground; + final int textMinHeight; + final int textTopMargin; + final int textTopPadding, textBottomPadding; + final int textLeftPadding, textRightPadding; + + if (mData.hasAttachments()) { + if (shouldShowMessageTextBubble()) { + // Text and attachment(s) + contentLeftPadding = incoming ? arrowWidth : 0; + contentRightPadding = outgoing ? arrowWidth : 0; + textBackground = drawableProvider.getBubbleDrawable( + isSelected(), + incoming, + false /* needArrow */, + mData.hasIncomingErrorStatus()); + textMinHeight = messageTextMinHeightDefault; + textTopMargin = messageTopPaddingClustered; + textTopPadding = textTopPaddingDefault; + textBottomPadding = textBottomPaddingDefault; + textLeftPadding = messageTextLeftRightPadding; + textRightPadding = messageTextLeftRightPadding; + } else { + // Attachment(s) only + contentLeftPadding = incoming ? arrowWidth : 0; + contentRightPadding = outgoing ? arrowWidth : 0; + textBackground = null; + textMinHeight = 0; + textTopMargin = 0; + textTopPadding = 0; + textBottomPadding = 0; + textLeftPadding = 0; + textRightPadding = 0; + } + } else { + // Text only + contentLeftPadding = (!showArrow && incoming) ? arrowWidth : 0; + contentRightPadding = (!showArrow && outgoing) ? arrowWidth : 0; + textBackground = drawableProvider.getBubbleDrawable( + isSelected(), + incoming, + shouldShowMessageBubbleArrow(), + mData.hasIncomingErrorStatus()); + textMinHeight = messageTextMinHeightDefault; + textTopMargin = 0; + textTopPadding = textTopPaddingDefault; + textBottomPadding = textBottomPaddingDefault; + if (showArrow && incoming) { + textLeftPadding = messageTextLeftRightPadding + arrowWidth; + } else { + textLeftPadding = messageTextLeftRightPadding; + } + if (showArrow && outgoing) { + textRightPadding = messageTextLeftRightPadding + arrowWidth; + } else { + textRightPadding = messageTextLeftRightPadding; + } + } + + // These values do not depend on whether the message includes attachments + final int gravity = incoming ? (Gravity.START | Gravity.CENTER_VERTICAL) : + (Gravity.END | Gravity.CENTER_VERTICAL); + final int messageTopPadding = shouldShowSimplifiedVisualStyle() ? + messageTopPaddingClustered : messageTopPaddingDefault; + final int metadataTopPadding = res.getDimensionPixelOffset( + R.dimen.message_metadata_top_padding); + + // Update the message text/info views + ImageUtils.setBackgroundDrawableOnView(mMessageTextAndInfoView, textBackground); + mMessageTextAndInfoView.setMinimumHeight(textMinHeight); + final LinearLayout.LayoutParams textAndInfoLayoutParams = + (LinearLayout.LayoutParams) mMessageTextAndInfoView.getLayoutParams(); + textAndInfoLayoutParams.topMargin = textTopMargin; + + if (UiUtils.isRtlMode()) { + // Need to switch right and left padding in RtL mode + mMessageTextAndInfoView.setPadding(textRightPadding, textTopPadding, textLeftPadding, + textBottomPadding); + mMessageBubble.setPadding(contentRightPadding, 0, contentLeftPadding, 0); + } else { + mMessageTextAndInfoView.setPadding(textLeftPadding, textTopPadding, textRightPadding, + textBottomPadding); + mMessageBubble.setPadding(contentLeftPadding, 0, contentRightPadding, 0); + } + + // Update the message row and message bubble views + setPadding(getPaddingLeft(), messageTopPadding, getPaddingRight(), 0); + mMessageBubble.setGravity(gravity); + updateMessageAttachmentsAppearance(gravity); + + mMessageMetadataView.setPadding(0, metadataTopPadding, 0, 0); + + updateTextAppearance(); + + requestLayout(); + } + + private void updateContentDescription() { + StringBuilder description = new StringBuilder(); + + Resources res = getResources(); + String separator = res.getString(R.string.enumeration_comma); + + // Sender information + boolean hasPlainTextMessage = !(TextUtils.isEmpty(mData.getText()) || + mMessageTextHasLinks); + if (mData.getIsIncoming()) { + int senderResId = hasPlainTextMessage + ? R.string.incoming_text_sender_content_description + : R.string.incoming_sender_content_description; + description.append(res.getString(senderResId, mData.getSenderDisplayName())); + } else { + int senderResId = hasPlainTextMessage + ? R.string.outgoing_text_sender_content_description + : R.string.outgoing_sender_content_description; + description.append(res.getString(senderResId)); + } + + if (mSubjectView.getVisibility() == View.VISIBLE) { + description.append(separator); + description.append(mSubjectText.getText()); + } + + if (mMessageTextView.getVisibility() == View.VISIBLE) { + // If the message has hyperlinks, we will let the user navigate to the text message so + // that the hyperlink can be clicked. Otherwise, the text message does not need to + // be reachable. + if (mMessageTextHasLinks) { + mMessageTextView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); + } else { + mMessageTextView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); + description.append(separator); + description.append(mMessageTextView.getText()); + } + } + + if (mMessageTitleLayout.getVisibility() == View.VISIBLE) { + description.append(separator); + description.append(mTitleTextView.getText()); + + description.append(separator); + description.append(mMmsInfoTextView.getText()); + } + + if (mStatusTextView.getVisibility() == View.VISIBLE) { + description.append(separator); + description.append(mStatusTextView.getText()); + } + + if (mSimNameView.getVisibility() == View.VISIBLE) { + description.append(separator); + description.append(mSimNameView.getText()); + } + + if (mDeliveredBadge.getVisibility() == View.VISIBLE) { + description.append(separator); + description.append(res.getString(R.string.delivered_status_content_description)); + } + + setContentDescription(description); + } + + private void updateMessageAttachmentsAppearance(final int gravity) { + mMessageAttachmentsView.setGravity(gravity); + + // Tint image/video attachments when selected + final int selectedImageTint = getResources().getColor(R.color.message_image_selected_tint); + if (mMessageImageView.getVisibility() == View.VISIBLE) { + if (isSelected()) { + mMessageImageView.setColorFilter(selectedImageTint); + } else { + mMessageImageView.clearColorFilter(); + } + } + if (mMultiAttachmentView.getVisibility() == View.VISIBLE) { + if (isSelected()) { + mMultiAttachmentView.setColorFilter(selectedImageTint); + } else { + mMultiAttachmentView.clearColorFilter(); + } + } + for (int i = 0, size = mMessageAttachmentsView.getChildCount(); i < size; i++) { + final View attachmentView = mMessageAttachmentsView.getChildAt(i); + if (attachmentView instanceof VideoThumbnailView + && attachmentView.getVisibility() == View.VISIBLE) { + final VideoThumbnailView videoView = (VideoThumbnailView) attachmentView; + if (isSelected()) { + videoView.setColorFilter(selectedImageTint); + } else { + videoView.clearColorFilter(); + } + } + } + + // If there are multiple attachment bubbles in a single message, add some separation. + final int multipleAttachmentPadding = + getResources().getDimensionPixelSize(R.dimen.message_padding_same_author); + + boolean previousVisibleView = false; + for (int i = 0, size = mMessageAttachmentsView.getChildCount(); i < size; i++) { + final View attachmentView = mMessageAttachmentsView.getChildAt(i); + if (attachmentView.getVisibility() == View.VISIBLE) { + final int margin = previousVisibleView ? multipleAttachmentPadding : 0; + ((LinearLayout.LayoutParams) attachmentView.getLayoutParams()).topMargin = margin; + // updateViewAppearance calls requestLayout() at the end, so we don't need to here + previousVisibleView = true; + } + } + } + + private void updateTextAppearance() { + int messageColorResId; + int statusColorResId = -1; + int infoColorResId = -1; + int timestampColorResId; + int subjectLabelColorResId; + if (isSelected()) { + messageColorResId = R.color.message_text_color_incoming; + statusColorResId = R.color.message_action_status_text; + infoColorResId = R.color.message_action_info_text; + if (shouldShowMessageTextBubble()) { + timestampColorResId = R.color.message_action_timestamp_text; + subjectLabelColorResId = R.color.message_action_timestamp_text; + } else { + // If there's no text, the timestamp will be shown below the attachments, + // against the conversation view background. + timestampColorResId = R.color.timestamp_text_outgoing; + subjectLabelColorResId = R.color.timestamp_text_outgoing; + } + } else { + messageColorResId = (mData.getIsIncoming() ? + R.color.message_text_color_incoming : R.color.message_text_color_outgoing); + statusColorResId = messageColorResId; + infoColorResId = R.color.timestamp_text_incoming; + switch(mData.getStatus()) { + + case MessageData.BUGLE_STATUS_OUTGOING_FAILED: + case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER: + timestampColorResId = R.color.message_failed_timestamp_text; + subjectLabelColorResId = R.color.timestamp_text_outgoing; + break; + + case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND: + case MessageData.BUGLE_STATUS_OUTGOING_SENDING: + case MessageData.BUGLE_STATUS_OUTGOING_RESENDING: + case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY: + case MessageData.BUGLE_STATUS_OUTGOING_COMPLETE: + case MessageData.BUGLE_STATUS_OUTGOING_DELIVERED: + timestampColorResId = R.color.timestamp_text_outgoing; + subjectLabelColorResId = R.color.timestamp_text_outgoing; + break; + + case MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE: + case MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED: + messageColorResId = R.color.message_text_color_incoming_download_failed; + timestampColorResId = R.color.message_download_failed_timestamp_text; + subjectLabelColorResId = R.color.message_text_color_incoming_download_failed; + statusColorResId = R.color.message_download_failed_status_text; + infoColorResId = R.color.message_info_text_incoming_download_failed; + break; + + case MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING: + case MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING: + case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD: + case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD: + case MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD: + timestampColorResId = R.color.message_text_color_incoming; + subjectLabelColorResId = R.color.message_text_color_incoming; + infoColorResId = R.color.timestamp_text_incoming; + break; + + case MessageData.BUGLE_STATUS_INCOMING_COMPLETE: + default: + timestampColorResId = R.color.timestamp_text_incoming; + subjectLabelColorResId = R.color.timestamp_text_incoming; + infoColorResId = -1; // Not used + break; + } + } + final int messageColor = getResources().getColor(messageColorResId); + mMessageTextView.setTextColor(messageColor); + mMessageTextView.setLinkTextColor(messageColor); + mSubjectText.setTextColor(messageColor); + if (statusColorResId >= 0) { + mTitleTextView.setTextColor(getResources().getColor(statusColorResId)); + } + if (infoColorResId >= 0) { + mMmsInfoTextView.setTextColor(getResources().getColor(infoColorResId)); + } + if (timestampColorResId == R.color.timestamp_text_incoming && + mData.hasAttachments() && !shouldShowMessageTextBubble()) { + timestampColorResId = R.color.timestamp_text_outgoing; + } + mStatusTextView.setTextColor(getResources().getColor(timestampColorResId)); + + mSubjectLabel.setTextColor(getResources().getColor(subjectLabelColorResId)); + mSenderNameTextView.setTextColor(getResources().getColor(timestampColorResId)); + } + + /** + * If we don't know the size of the image, we want to show it in a fixed-sized frame to + * avoid janks when the image is loaded and resized. Otherwise, we can set the imageview to + * take on normal layout params. + */ + private void adjustImageViewBounds(final MessagePartData imageAttachment) { + Assert.isTrue(ContentType.isImageType(imageAttachment.getContentType())); + final ViewGroup.LayoutParams layoutParams = mMessageImageView.getLayoutParams(); + if (imageAttachment.getWidth() == MessagePartData.UNSPECIFIED_SIZE || + imageAttachment.getHeight() == MessagePartData.UNSPECIFIED_SIZE) { + // We don't know the size of the image attachment, enable letterboxing on the image + // and show a fixed sized attachment. This should happen at most once per image since + // after the image is loaded we then save the image dimensions to the db so that the + // next time we can display the full size. + layoutParams.width = getResources() + .getDimensionPixelSize(R.dimen.image_attachment_fallback_width); + layoutParams.height = getResources() + .getDimensionPixelSize(R.dimen.image_attachment_fallback_height); + mMessageImageView.setScaleType(ScaleType.CENTER_CROP); + } else { + layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT; + layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT; + // ScaleType.CENTER_INSIDE and FIT_CENTER behave similarly for most images. However, + // FIT_CENTER works better for small images as it enlarges the image such that the + // minimum size ("android:minWidth" etc) is honored. + mMessageImageView.setScaleType(ScaleType.FIT_CENTER); + } + } + + @Override + public void onClick(final View view) { + final Object tag = view.getTag(); + if (tag instanceof MessagePartData) { + final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(view); + onAttachmentClick((MessagePartData) tag, bounds, false /* longPress */); + } else if (tag instanceof String) { + // Currently the only object that would make a tag of a string is a youtube preview + // image + UIIntents.get().launchBrowserForUrl(getContext(), (String) tag); + } + } + + @Override + public boolean onLongClick(final View view) { + if (view == mMessageTextView) { + // Preemptively handle the long click event on message text so it's not handled by + // the link spans. + return performLongClick(); + } + + final Object tag = view.getTag(); + if (tag instanceof MessagePartData) { + final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(view); + return onAttachmentClick((MessagePartData) tag, bounds, true /* longPress */); + } + + return false; + } + + @Override + public boolean onAttachmentClick(final MessagePartData attachment, + final Rect viewBoundsOnScreen, final boolean longPress) { + return mHost.onAttachmentClick(this, attachment, viewBoundsOnScreen, longPress); + } + + public ContactIconView getContactIconView() { + return mContactIconView; + } + + // Sort photos in MultiAttachLayout in the same order as the ConversationImagePartsView + static final Comparator sImageComparator = new Comparator(){ + @Override + public int compare(final MessagePartData x, final MessagePartData y) { + return x.getPartId().compareTo(y.getPartId()); + } + }; + + static final Predicate sVideoFilter = new Predicate() { + @Override + public boolean apply(final MessagePartData part) { + return part.isVideo(); + } + }; + + static final Predicate sAudioFilter = new Predicate() { + @Override + public boolean apply(final MessagePartData part) { + return part.isAudio(); + } + }; + + static final Predicate sVCardFilter = new Predicate() { + @Override + public boolean apply(final MessagePartData part) { + return part.isVCard(); + } + }; + + static final Predicate sImageFilter = new Predicate() { + @Override + public boolean apply(final MessagePartData part) { + return part.isImage(); + } + }; + + interface AttachmentViewBinder { + void bindView(View view, MessagePartData attachment); + void unbind(View view); + } + + final AttachmentViewBinder mVideoViewBinder = new AttachmentViewBinder() { + @Override + public void bindView(final View view, final MessagePartData attachment) { + ((VideoThumbnailView) view).setSource(attachment, mData.getIsIncoming()); + } + + @Override + public void unbind(final View view) { + ((VideoThumbnailView) view).setSource((Uri) null, mData.getIsIncoming()); + } + }; + + final AttachmentViewBinder mAudioViewBinder = new AttachmentViewBinder() { + @Override + public void bindView(final View view, final MessagePartData attachment) { + final AudioAttachmentView audioView = (AudioAttachmentView) view; + audioView.bindMessagePartData(attachment, isSelected() || mData.getIsIncoming()); + audioView.setBackground(ConversationDrawables.get().getBubbleDrawable( + isSelected(), mData.getIsIncoming(), false /* needArrow */, + mData.hasIncomingErrorStatus())); + } + + @Override + public void unbind(final View view) { + ((AudioAttachmentView) view).bindMessagePartData(null, mData.getIsIncoming()); + } + }; + + final AttachmentViewBinder mVCardViewBinder = new AttachmentViewBinder() { + @Override + public void bindView(final View view, final MessagePartData attachment) { + final PersonItemView personView = (PersonItemView) view; + personView.bind(DataModel.get().createVCardContactItemData(getContext(), + attachment)); + personView.setBackground(ConversationDrawables.get().getBubbleDrawable( + isSelected(), mData.getIsIncoming(), false /* needArrow */, + mData.hasIncomingErrorStatus())); + final int nameTextColorRes; + final int detailsTextColorRes; + if (isSelected()) { + nameTextColorRes = R.color.message_text_color_incoming; + detailsTextColorRes = R.color.message_text_color_incoming; + } else { + nameTextColorRes = mData.getIsIncoming() ? R.color.message_text_color_incoming + : R.color.message_text_color_outgoing; + detailsTextColorRes = mData.getIsIncoming() ? R.color.timestamp_text_incoming + : R.color.timestamp_text_outgoing; + } + personView.setNameTextColor(getResources().getColor(nameTextColorRes)); + personView.setDetailsTextColor(getResources().getColor(detailsTextColorRes)); + } + + @Override + public void unbind(final View view) { + ((PersonItemView) view).bind(null); + } + }; + + /** + * A helper class that allows us to handle long clicks on linkified message text view (i.e. to + * select the message) so it's not handled by the link spans to launch apps for the links. + */ + private static class IgnoreLinkLongClickHelper implements OnLongClickListener, OnTouchListener { + private boolean mIsLongClick; + private final OnLongClickListener mDelegateLongClickListener; + + /** + * Ignore long clicks on linkified texts for a given text view. + * @param textView the TextView to ignore long clicks on + * @param longClickListener a delegate OnLongClickListener to be called when the view is + * long clicked. + */ + public static void ignoreLinkLongClick(final TextView textView, + @Nullable final OnLongClickListener longClickListener) { + final IgnoreLinkLongClickHelper helper = + new IgnoreLinkLongClickHelper(longClickListener); + textView.setOnLongClickListener(helper); + textView.setOnTouchListener(helper); + } + + private IgnoreLinkLongClickHelper(@Nullable final OnLongClickListener longClickListener) { + mDelegateLongClickListener = longClickListener; + } + + @Override + public boolean onLongClick(final View v) { + // Record that this click is a long click. + mIsLongClick = true; + if (mDelegateLongClickListener != null) { + return mDelegateLongClickListener.onLongClick(v); + } + return false; + } + + @Override + public boolean onTouch(final View v, final MotionEvent event) { + if (event.getActionMasked() == MotionEvent.ACTION_UP && mIsLongClick) { + // This touch event is a long click, preemptively handle this touch event so that + // the link span won't get a onClicked() callback. + mIsLongClick = false; + return true; + } + + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + mIsLongClick = false; + } + return false; + } + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationSimSelector.java b/src/com/android/messaging/ui/conversation/ConversationSimSelector.java new file mode 100644 index 0000000..fc43a46 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationSimSelector.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversation; + +import android.content.Context; +import android.support.v4.util.Pair; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.SubscriptionListData; +import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; +import com.android.messaging.ui.conversation.SimSelectorView.SimSelectorViewListener; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.Assert; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.ThreadUtil; + +/** + * Manages showing/hiding the SIM selector in conversation. + */ +abstract class ConversationSimSelector extends ConversationInput { + private SimSelectorView mSimSelectorView; + private Pair mPendingShow; + private boolean mDataReady; + private String mSelectedSimText; + + public ConversationSimSelector(ConversationInputBase baseHost) { + super(baseHost, false); + } + + public void onSubscriptionListDataLoaded(final SubscriptionListData subscriptionListData) { + ensureSimSelectorView(); + mSimSelectorView.bind(subscriptionListData); + mDataReady = subscriptionListData != null && subscriptionListData.hasData(); + if (mPendingShow != null && mDataReady) { + Assert.isTrue(OsUtil.isAtLeastL_MR1()); + final boolean show = mPendingShow.first; + final boolean animate = mPendingShow.second; + ThreadUtil.getMainThreadHandler().post(new Runnable() { + @Override + public void run() { + // This will No-Op if we are no longer attached to the host. + mConversationInputBase.showHideInternal(ConversationSimSelector.this, + show, animate); + } + }); + mPendingShow = null; + } + } + + private void announcedSelectedSim() { + final Context context = Factory.get().getApplicationContext(); + if (AccessibilityUtil.isTouchExplorationEnabled(context) && + !TextUtils.isEmpty(mSelectedSimText)) { + AccessibilityUtil.announceForAccessibilityCompat( + mSimSelectorView, null, + context.getString(R.string.selected_sim_content_message, mSelectedSimText)); + } + } + + public void setSelected(final SubscriptionListEntry subEntry) { + mSelectedSimText = subEntry == null ? null : subEntry.displayName; + } + + @Override + public boolean show(boolean animate) { + announcedSelectedSim(); + return showHide(true, animate); + } + + @Override + public boolean hide(boolean animate) { + return showHide(false, animate); + } + + private boolean showHide(final boolean show, final boolean animate) { + if (!OsUtil.isAtLeastL_MR1()) { + return false; + } + + if (mDataReady) { + mSimSelectorView.showOrHide(show, animate); + return mSimSelectorView.isOpen() == show; + } else { + mPendingShow = Pair.create(show, animate); + return false; + } + } + + private void ensureSimSelectorView() { + if (mSimSelectorView == null) { + // Grab the SIM selector view from the host. This class assumes ownership of it. + mSimSelectorView = getSimSelectorView(); + mSimSelectorView.setItemLayoutId(getSimSelectorItemLayoutId()); + mSimSelectorView.setListener(new SimSelectorViewListener() { + + @Override + public void onSimSelectorVisibilityChanged(boolean visible) { + onVisibilityChanged(visible); + } + + @Override + public void onSimItemClicked(SubscriptionListEntry item) { + selectSim(item); + } + }); + } + } + + protected abstract SimSelectorView getSimSelectorView(); + protected abstract void selectSim(final SubscriptionListEntry item); + protected abstract int getSimSelectorItemLayoutId(); + +} diff --git a/src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java b/src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java new file mode 100644 index 0000000..e3ad601 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversation; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.widget.EditText; + +import com.android.messaging.R; +import com.android.messaging.datamodel.ParticipantRefresh; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.UiUtils; + +/** + * The dialog for the user to enter the phone number of their sim. + */ +public class EnterSelfPhoneNumberDialog extends DialogFragment { + private EditText mEditText; + private int mSubId; + + public static EnterSelfPhoneNumberDialog newInstance(final int subId) { + final EnterSelfPhoneNumberDialog dialog = new EnterSelfPhoneNumberDialog(); + dialog.mSubId = subId; + return dialog; + } + + @Override + public Dialog onCreateDialog(final Bundle savedInstanceState) { + final Context context = getActivity(); + final LayoutInflater inflater = LayoutInflater.from(context); + mEditText = (EditText) inflater.inflate(R.layout.enter_phone_number_view, null, false); + + final AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.enter_phone_number_title) + .setMessage(R.string.enter_phone_number_text) + .setView(mEditText) + .setNegativeButton(android.R.string.cancel, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, + final int button) { + dismiss(); + } + }) + .setPositiveButton(android.R.string.ok, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, + final int button) { + final String newNumber = mEditText.getText().toString(); + dismiss(); + if (!TextUtils.isEmpty(newNumber)) { + savePhoneNumberInPrefs(newNumber); + // TODO: Remove this toast and just auto-send + // the message instead + UiUtils.showToast( + R.string + .toast_after_setting_default_sms_app_for_message_send); + } + } + }); + return builder.create(); + } + + private void savePhoneNumberInPrefs(final String newPhoneNumber) { + final BuglePrefs subPrefs = BuglePrefs.getSubscriptionPrefs(mSubId); + subPrefs.putString(getString(R.string.mms_phone_number_pref_key), + newPhoneNumber); + // Update the self participants so the new phone number will be reflected + // everywhere in the UI. + ParticipantRefresh.refreshSelfParticipants(); + } +} diff --git a/src/com/android/messaging/ui/conversation/LaunchConversationActivity.java b/src/com/android/messaging/ui/conversation/LaunchConversationActivity.java new file mode 100644 index 0000000..8af9f75 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/LaunchConversationActivity.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversation; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.LaunchConversationData; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.UiUtils; +import com.android.messaging.util.UriUtil; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; + +/** + * Launches ConversationActivity for sending a message to, or viewing messages from, a specific + * recipient. + *

+ * (This activity should be marked noHistory="true" in AndroidManifest.xml) + */ +public class LaunchConversationActivity extends Activity implements + LaunchConversationData.LaunchConversationDataListener { + static final String SMS_BODY = "sms_body"; + static final String ADDRESS = "address"; + final Binding mBinding = BindingBase.createBinding(this); + String mSmsBody; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (UiUtils.redirectToPermissionCheckIfNeeded(this)) { + return; + } + + final Intent intent = getIntent(); + final String action = intent.getAction(); + if (Intent.ACTION_SENDTO.equals(action) || Intent.ACTION_VIEW.equals(action)) { + String[] recipients = UriUtil.parseRecipientsFromSmsMmsUri(intent.getData()); + final boolean haveAddress = !TextUtils.isEmpty(intent.getStringExtra(ADDRESS)); + final boolean haveEmail = !TextUtils.isEmpty(intent.getStringExtra(Intent.EXTRA_EMAIL)); + if (recipients == null && (haveAddress || haveEmail)) { + if (haveAddress) { + recipients = new String[] { intent.getStringExtra(ADDRESS) }; + } else { + recipients = new String[] { intent.getStringExtra(Intent.EXTRA_EMAIL) }; + } + } + mSmsBody = intent.getStringExtra(SMS_BODY); + if (TextUtils.isEmpty(mSmsBody)) { + // Used by intents sent from the web YouTube (and perhaps others). + mSmsBody = getBody(intent.getData()); + if (TextUtils.isEmpty(mSmsBody)) { + // If that fails, try yet another method apps use to share text + if (ContentType.TEXT_PLAIN.equals(intent.getType())) { + mSmsBody = intent.getStringExtra(Intent.EXTRA_TEXT); + } + } + } + if (recipients != null) { + mBinding.bind(DataModel.get().createLaunchConversationData(this)); + mBinding.getData().getOrCreateConversation(mBinding, recipients); + } else { + // No recipients were specified in the intent. + // Start a new conversation with contact picker. The new conversation will be + // primed with the (optional) message in mSmsBody. + onGetOrCreateNewConversation(null); + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Unsupported conversation intent action : " + action); + } + // As of M, activities without a visible window must finish before onResume completes. + finish(); + } + + private String getBody(final Uri uri) { + if (uri == null) { + return null; + } + String urlStr = uri.getSchemeSpecificPart(); + if (!urlStr.contains("?")) { + return null; + } + urlStr = urlStr.substring(urlStr.indexOf('?') + 1); + final String[] params = urlStr.split("&"); + for (final String p : params) { + if (p.startsWith("body=")) { + try { + return URLDecoder.decode(p.substring(5), "UTF-8"); + } catch (final UnsupportedEncodingException e) { + // Invalid URL, ignore + } + } + } + return null; + } + + @Override + public void onGetOrCreateNewConversation(final String conversationId) { + final Context context = Factory.get().getApplicationContext(); + UIIntents.get().launchConversationActivityWithParentStack(context, conversationId, + mSmsBody); + } + + @Override + public void onGetOrCreateNewConversationFailed() { + UiUtils.showToastAtBottom(R.string.conversation_creation_failure); + } +} diff --git a/src/com/android/messaging/ui/conversation/MessageBubbleBackground.java b/src/com/android/messaging/ui/conversation/MessageBubbleBackground.java new file mode 100644 index 0000000..4c22970 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/MessageBubbleBackground.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversation; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.LinearLayout; + +import com.android.messaging.R; + +public class MessageBubbleBackground extends LinearLayout { + private final int mSnapWidthPixels; + + public MessageBubbleBackground(Context context, AttributeSet attrs) { + super(context, attrs); + mSnapWidthPixels = context.getResources().getDimensionPixelSize( + R.dimen.conversation_bubble_width_snap); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + final int widthPadding = getPaddingLeft() + getPaddingRight(); + int bubbleWidth = getMeasuredWidth() - widthPadding; + final int maxWidth = MeasureSpec.getSize(widthMeasureSpec) - widthPadding; + // Round up to next snapWidthPixels + bubbleWidth = Math.min(maxWidth, + (int) (Math.ceil(bubbleWidth / (float) mSnapWidthPixels) * mSnapWidthPixels)); + super.onMeasure( + MeasureSpec.makeMeasureSpec(bubbleWidth + widthPadding, MeasureSpec.EXACTLY), + heightMeasureSpec); + } +} diff --git a/src/com/android/messaging/ui/conversation/MessageDetailsDialog.java b/src/com/android/messaging/ui/conversation/MessageDetailsDialog.java new file mode 100644 index 0000000..89b9148 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/MessageDetailsDialog.java @@ -0,0 +1,381 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversation; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.res.Resources; +import android.net.Uri; +import android.text.TextUtils; +import android.text.format.Formatter; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.data.ConversationMessageData; +import com.android.messaging.datamodel.data.ConversationParticipantsData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.mmslib.pdu.PduHeaders; +import com.android.messaging.sms.DatabaseMessages.MmsMessage; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.Dates; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.SafeAsyncTask; + +import java.util.List; + +public class MessageDetailsDialog { + private static final String RECIPIENT_SEPARATOR = ", "; + + // All methods are static, no creating this class + private MessageDetailsDialog() { + } + + public static void show(final Context context, final ConversationMessageData data, + final ConversationParticipantsData participants, final ParticipantData self) { + if (DebugUtils.isDebugEnabled()) { + new SafeAsyncTask() { + @Override + protected String doInBackgroundTimed(Void... params) { + return getMessageDetails(context, data, participants, self); + } + + @Override + protected void onPostExecute(String messageDetails) { + showDialog(context, messageDetails); + } + }.executeOnThreadPool(null, null, null); + } else { + String messageDetails = getMessageDetails(context, data, participants, self); + showDialog(context, messageDetails); + } + } + + private static String getMessageDetails(final Context context, + final ConversationMessageData data, + final ConversationParticipantsData participants, final ParticipantData self) { + String messageDetails = null; + if (data.getIsSms()) { + messageDetails = getSmsMessageDetails(data, participants, self); + } else { + // TODO: Handle SMS_TYPE_MMS_PUSH_NOTIFICATION type differently? + messageDetails = getMmsMessageDetails(context, data, participants, self); + } + + return messageDetails; + } + + private static void showDialog(final Context context, String messageDetails) { + if (!TextUtils.isEmpty(messageDetails)) { + new AlertDialog.Builder(context) + .setTitle(R.string.message_details_title) + .setMessage(messageDetails) + .setCancelable(true) + .show(); + } + } + + /** + * Return a string, separated by newlines, that contains a number of labels and values + * for this sms message. The string will be displayed in a modal dialog. + * @return string list of various message properties + */ + private static String getSmsMessageDetails(final ConversationMessageData data, + final ConversationParticipantsData participants, final ParticipantData self) { + final Resources res = Factory.get().getApplicationContext().getResources(); + final StringBuilder details = new StringBuilder(); + + // Type: Text message + details.append(res.getString(R.string.message_type_label)); + details.append(res.getString(R.string.text_message)); + + // From: +1425xxxxxxx + // or To: +1425xxxxxxx + final String rawSender = data.getSenderNormalizedDestination(); + if (!TextUtils.isEmpty(rawSender)) { + details.append('\n'); + details.append(res.getString(R.string.from_label)); + details.append(rawSender); + } + final String rawRecipients = getRecipientParticipantString(participants, + data.getParticipantId(), data.getIsIncoming(), data.getSelfParticipantId()); + if (!TextUtils.isEmpty(rawRecipients)) { + details.append('\n'); + details.append(res.getString(R.string.to_address_label)); + details.append(rawRecipients); + } + + // Sent: Mon 11:42AM + if (data.getIsIncoming()) { + if (data.getSentTimeStamp() != MmsUtils.INVALID_TIMESTAMP) { + details.append('\n'); + details.append(res.getString(R.string.sent_label)); + details.append( + Dates.getMessageDetailsTimeString(data.getSentTimeStamp()).toString()); + } + } + + // Sent: Mon 11:43AM + // or Received: Mon 11:43AM + appendSentOrReceivedTimestamp(res, details, data); + + appendSimInfo(res, self, details); + + if (DebugUtils.isDebugEnabled()) { + appendDebugInfo(details, data); + } + + return details.toString(); + } + + /** + * Return a string, separated by newlines, that contains a number of labels and values + * for this mms message. The string will be displayed in a modal dialog. + * @return string list of various message properties + */ + private static String getMmsMessageDetails(Context context, final ConversationMessageData data, + final ConversationParticipantsData participants, final ParticipantData self) { + final Resources res = Factory.get().getApplicationContext().getResources(); + // TODO: when we support non-auto-download of mms messages, we'll have to handle + // the case when the message is a PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND and display + // something different. See the Messaging app's MessageUtils.getNotificationIndDetails() + + final StringBuilder details = new StringBuilder(); + + // Type: Multimedia message. + details.append(res.getString(R.string.message_type_label)); + details.append(res.getString(R.string.multimedia_message)); + + // From: +1425xxxxxxx + final String rawSender = data.getSenderNormalizedDestination(); + details.append('\n'); + details.append(res.getString(R.string.from_label)); + details.append(!TextUtils.isEmpty(rawSender) ? rawSender : + res.getString(R.string.hidden_sender_address)); + + // To: +1425xxxxxxx + final String rawRecipients = getRecipientParticipantString(participants, + data.getParticipantId(), data.getIsIncoming(), data.getSelfParticipantId()); + if (!TextUtils.isEmpty(rawRecipients)) { + details.append('\n'); + details.append(res.getString(R.string.to_address_label)); + details.append(rawRecipients); + } + + // Sent: Tue 3:05PM + // or Received: Tue 3:05PM + appendSentOrReceivedTimestamp(res, details, data); + + // Subject: You're awesome + details.append('\n'); + details.append(res.getString(R.string.subject_label)); + if (!TextUtils.isEmpty(MmsUtils.cleanseMmsSubject(res, data.getMmsSubject()))) { + details.append(data.getMmsSubject()); + } + + // Priority: High/Normal/Low + details.append('\n'); + details.append(res.getString(R.string.priority_label)); + details.append(getPriorityDescription(res, data.getSmsPriority())); + + // Message size: 30 KB + if (data.getSmsMessageSize() > 0) { + details.append('\n'); + details.append(res.getString(R.string.message_size_label)); + details.append(Formatter.formatFileSize(context, data.getSmsMessageSize())); + } + + appendSimInfo(res, self, details); + + if (DebugUtils.isDebugEnabled()) { + appendDebugInfo(details, data); + } + + return details.toString(); + } + + private static void appendSentOrReceivedTimestamp(Resources res, StringBuilder details, + ConversationMessageData data) { + int labelId = -1; + if (data.getIsIncoming()) { + labelId = R.string.received_label; + } else if (data.getIsSendComplete()) { + labelId = R.string.sent_label; + } + if (labelId >= 0) { + details.append('\n'); + details.append(res.getString(labelId)); + details.append( + Dates.getMessageDetailsTimeString(data.getReceivedTimeStamp()).toString()); + } + } + + @DoesNotRunOnMainThread + private static void appendDebugInfo(StringBuilder details, ConversationMessageData data) { + // We grab the thread id from the database, so this needs to run in the background + Assert.isNotMainThread(); + details.append("\n\n"); + details.append("DEBUG"); + + details.append('\n'); + details.append("Message id: "); + details.append(data.getMessageId()); + + final String telephonyUri = data.getSmsMessageUri(); + details.append('\n'); + details.append("Telephony uri: "); + details.append(telephonyUri); + + final String conversationId = data.getConversationId(); + + if (conversationId == null) { + return; + } + + details.append('\n'); + details.append("Conversation id: "); + details.append(conversationId); + + final long threadId = BugleDatabaseOperations.getThreadId(DataModel.get().getDatabase(), + conversationId); + + details.append('\n'); + details.append("Conversation telephony thread id: "); + details.append(threadId); + + MmsMessage mms = null; + + if (data.getIsMms()) { + if (telephonyUri == null) { + return; + } + mms = MmsUtils.loadMms(Uri.parse(telephonyUri)); + if (mms == null) { + return; + } + + // We log the thread id again to check that they are internally consistent + final long mmsThreadId = mms.mThreadId; + details.append('\n'); + details.append("Telephony thread id: "); + details.append(mmsThreadId); + + // Log the MMS content location + final String mmsContentLocation = mms.mContentLocation; + details.append('\n'); + details.append("Content location URL: "); + details.append(mmsContentLocation); + } + + final String recipientsString = MmsUtils.getRawRecipientIdsForThread(threadId); + if (recipientsString != null) { + details.append('\n'); + details.append("Thread recipient ids: "); + details.append(recipientsString); + } + + final List recipients = MmsUtils.getRecipientsByThread(threadId); + if (recipients != null) { + details.append('\n'); + details.append("Thread recipients: "); + details.append(recipients.toString()); + + if (mms != null) { + final String from = MmsUtils.getMmsSender(recipients, mms.getUri()); + details.append('\n'); + details.append("Sender: "); + details.append(from); + } + } + } + + private static String getRecipientParticipantString( + final ConversationParticipantsData participants, final String senderId, + final boolean addSelf, final String selfId) { + final StringBuilder recipients = new StringBuilder(); + for (final ParticipantData participant : participants) { + if (TextUtils.equals(participant.getId(), senderId)) { + // Don't add sender + continue; + } + if (participant.isSelf() && + (!participant.getId().equals(selfId) || !addSelf)) { + // For self participants, don't add the one that's not relevant to this message + // or if we are asked not to add self + continue; + } + final String phoneNumber = participant.getNormalizedDestination(); + // Don't add empty number. This should not happen. But if that happens + // we should not add it. + if (!TextUtils.isEmpty(phoneNumber)) { + if (recipients.length() > 0) { + recipients.append(RECIPIENT_SEPARATOR); + } + recipients.append(phoneNumber); + } + } + return recipients.toString(); + } + + /** + * Convert the numeric mms priority into a human-readable string + * @param res + * @param priorityValue coded PduHeader priority + * @return string representation of the priority + */ + private static String getPriorityDescription(final Resources res, final int priorityValue) { + switch(priorityValue) { + case PduHeaders.PRIORITY_HIGH: + return res.getString(R.string.priority_high); + case PduHeaders.PRIORITY_LOW: + return res.getString(R.string.priority_low); + case PduHeaders.PRIORITY_NORMAL: + default: + return res.getString(R.string.priority_normal); + } + } + + private static void appendSimInfo(final Resources res, + final ParticipantData self, final StringBuilder outString) { + if (!OsUtil.isAtLeastL_MR1() + || self == null + || PhoneUtils.getDefault().getActiveSubscriptionCount() < 2) { + return; + } + // The appended SIM info would look like: + // SIM: SUB 01 + // or SIM: SIM 1 + // or SIM: Unknown + Assert.isTrue(self.isSelf()); + outString.append('\n'); + outString.append(res.getString(R.string.sim_label)); + if (self.isActiveSubscription() && !self.isDefaultSelf()) { + final String subscriptionName = self.getSubscriptionName(); + if (TextUtils.isEmpty(subscriptionName)) { + outString.append(res.getString(R.string.sim_slot_identifier, + self.getDisplaySlotId())); + } else { + outString.append(subscriptionName); + } + } + } +} diff --git a/src/com/android/messaging/ui/conversation/SimIconView.java b/src/com/android/messaging/ui/conversation/SimIconView.java new file mode 100644 index 0000000..e2e446c --- /dev/null +++ b/src/com/android/messaging/ui/conversation/SimIconView.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversation; + +import android.content.Context; +import android.graphics.Outline; +import android.net.Uri; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewOutlineProvider; + +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.OsUtil; + +/** + * Shows SIM avatar icon in the SIM switcher / Self-send button. + */ +public class SimIconView extends ContactIconView { + public SimIconView(Context context, AttributeSet attrs) { + super(context, attrs); + if (OsUtil.isAtLeastL()) { + setOutlineProvider(new ViewOutlineProvider() { + @Override + public void getOutline(View v, Outline outline) { + outline.setOval(0, 0, v.getWidth(), v.getHeight()); + } + }); + } + } + + @Override + protected void maybeInitializeOnClickListener() { + // TODO: SIM icon view shouldn't consume or handle clicks, but it should if + // this is the send button for the only SIM in the device or if MSIM is not supported. + } +} diff --git a/src/com/android/messaging/ui/conversation/SimSelectorItemView.java b/src/com/android/messaging/ui/conversation/SimSelectorItemView.java new file mode 100644 index 0000000..3058d31 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/SimSelectorItemView.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversation; + +import android.content.Context; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; +import com.android.messaging.util.Assert; + +/** + * Shows a view for a SIM in the SIM selector. + */ +public class SimSelectorItemView extends LinearLayout { + public interface HostInterface { + void onSimItemClicked(SubscriptionListEntry item); + } + + private SubscriptionListEntry mData; + private TextView mNameTextView; + private TextView mDetailsTextView; + private SimIconView mSimIconView; + private HostInterface mHost; + + public SimSelectorItemView(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + mNameTextView = (TextView) findViewById(R.id.name); + mDetailsTextView = (TextView) findViewById(R.id.details); + mSimIconView = (SimIconView) findViewById(R.id.sim_icon); + setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + mHost.onSimItemClicked(mData); + } + }); + } + + public void bind(final SubscriptionListEntry simEntry) { + Assert.notNull(simEntry); + mData = simEntry; + updateViewAppearance(); + } + + public void setHostInterface(final HostInterface host) { + mHost = host; + } + + private void updateViewAppearance() { + Assert.notNull(mData); + final String displayName = mData.displayName; + if (TextUtils.isEmpty(displayName)) { + mNameTextView.setVisibility(GONE); + } else { + mNameTextView.setVisibility(VISIBLE); + mNameTextView.setText(displayName); + } + + final String details = mData.displayDestination; + if (TextUtils.isEmpty(details)) { + mDetailsTextView.setVisibility(GONE); + } else { + mDetailsTextView.setVisibility(VISIBLE); + mDetailsTextView.setText(details); + } + + mSimIconView.setImageResourceUri(mData.iconUri); + } +} diff --git a/src/com/android/messaging/ui/conversation/SimSelectorView.java b/src/com/android/messaging/ui/conversation/SimSelectorView.java new file mode 100644 index 0000000..b07ff19 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/SimSelectorView.java @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversation; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.TranslateAnimation; +import android.widget.ArrayAdapter; +import android.widget.FrameLayout; +import android.widget.ListView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.SubscriptionListData; +import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; +import com.android.messaging.util.UiUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * Displays a SIM selector above the compose message view and overlays the message list. + */ +public class SimSelectorView extends FrameLayout implements SimSelectorItemView.HostInterface { + public interface SimSelectorViewListener { + void onSimItemClicked(SubscriptionListEntry item); + void onSimSelectorVisibilityChanged(boolean visible); + } + + private ListView mSimListView; + private final SimSelectorAdapter mAdapter; + private boolean mShow; + private SimSelectorViewListener mListener; + private int mItemLayoutId; + + public SimSelectorView(Context context, AttributeSet attrs) { + super(context, attrs); + mAdapter = new SimSelectorAdapter(getContext()); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mSimListView = (ListView) findViewById(R.id.sim_list); + mSimListView.setAdapter(mAdapter); + + // Clicking anywhere outside the switcher list should dismiss. + setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + showOrHide(false, true); + } + }); + } + + public void bind(final SubscriptionListData data) { + mAdapter.bindData(data.getActiveSubscriptionEntriesExcludingDefault()); + } + + public void setItemLayoutId(final int layoutId) { + mItemLayoutId = layoutId; + } + + public void setListener(final SimSelectorViewListener listener) { + mListener = listener; + } + + public void toggleVisibility() { + showOrHide(!mShow, true); + } + + public void showOrHide(final boolean show, final boolean animate) { + final boolean oldShow = mShow; + mShow = show && mAdapter.getCount() > 1; + if (oldShow != mShow) { + if (mListener != null) { + mListener.onSimSelectorVisibilityChanged(mShow); + } + + if (animate) { + // Fade in the background pane. + setVisibility(VISIBLE); + setAlpha(mShow ? 0.0f : 1.0f); + animate().alpha(mShow ? 1.0f : 0.0f) + .setDuration(UiUtils.REVEAL_ANIMATION_DURATION) + .withEndAction(new Runnable() { + @Override + public void run() { + setAlpha(1.0f); + setVisibility(mShow ? VISIBLE : GONE); + } + }); + } else { + setVisibility(mShow ? VISIBLE : GONE); + } + + // Slide in the SIM selector list via a translate animation. + mSimListView.setVisibility(mShow ? VISIBLE : GONE); + if (animate) { + mSimListView.clearAnimation(); + final TranslateAnimation translateAnimation = new TranslateAnimation( + Animation.RELATIVE_TO_SELF, 0, Animation.RELATIVE_TO_SELF, 0, + Animation.RELATIVE_TO_SELF, mShow ? 1.0f : 0.0f, + Animation.RELATIVE_TO_SELF, mShow ? 0.0f : 1.0f); + translateAnimation.setInterpolator(UiUtils.EASE_OUT_INTERPOLATOR); + translateAnimation.setDuration(UiUtils.REVEAL_ANIMATION_DURATION); + mSimListView.startAnimation(translateAnimation); + } + } + } + + /** + * An adapter that takes a list of SubscriptionListEntry and displays them as a list of + * available SIMs in the SIM selector. + */ + private class SimSelectorAdapter extends ArrayAdapter { + public SimSelectorAdapter(final Context context) { + super(context, R.layout.sim_selector_item_view, new ArrayList()); + } + + public void bindData(final List newList) { + clear(); + addAll(newList); + notifyDataSetChanged(); + } + + @Override + public View getView(final int position, final View convertView, final ViewGroup parent) { + SimSelectorItemView itemView; + if (convertView != null && convertView instanceof SimSelectorItemView) { + itemView = (SimSelectorItemView) convertView; + } else { + final LayoutInflater inflater = (LayoutInflater) getContext() + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + itemView = (SimSelectorItemView) inflater.inflate(mItemLayoutId, + parent, false); + itemView.setHostInterface(SimSelectorView.this); + } + itemView.bind(getItem(position)); + return itemView; + } + } + + @Override + public void onSimItemClicked(SubscriptionListEntry item) { + mListener.onSimItemClicked(item); + showOrHide(false, true); + } + + public boolean isOpen() { + return mShow; + } +} diff --git a/src/com/android/messaging/ui/conversationlist/AbstractConversationListActivity.java b/src/com/android/messaging/ui/conversationlist/AbstractConversationListActivity.java new file mode 100644 index 0000000..dbbbb15 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/AbstractConversationListActivity.java @@ -0,0 +1,339 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversationlist; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Fragment; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.res.Resources; +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.view.View; + +import com.android.messaging.R; +import com.android.messaging.datamodel.action.DeleteConversationAction; +import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction; +import com.android.messaging.datamodel.action.UpdateConversationOptionsAction; +import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.SnackBar; +import com.android.messaging.ui.SnackBarInteraction; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.contact.AddContactsConfirmationDialog; +import com.android.messaging.ui.conversationlist.ConversationListFragment.ConversationListFragmentHost; +import com.android.messaging.ui.conversationlist.MultiSelectActionModeCallback.SelectedConversation; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.Trace; +import com.android.messaging.util.UiUtils; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import javax.annotation.Nullable; + +/** + * Base class for many Conversation List activities. This will handle the common actions of multi + * select and common launching of intents. + */ +public abstract class AbstractConversationListActivity extends BugleActionBarActivity + implements ConversationListFragmentHost, MultiSelectActionModeCallback.Listener { + + private static final int REQUEST_SET_DEFAULT_SMS_APP = 1; + + protected ConversationListFragment mConversationListFragment; + + @Override + public void onAttachFragment(final Fragment fragment) { + Trace.beginSection("AbstractConversationListActivity.onAttachFragment"); + // Fragment could be debug dialog + if (fragment instanceof ConversationListFragment) { + mConversationListFragment = (ConversationListFragment) fragment; + mConversationListFragment.setHost(this); + } + Trace.endSection(); + } + + @Override + public void onBackPressed() { + // If action mode is active dismiss it + if (getActionMode() != null) { + dismissActionMode(); + return; + } + super.onBackPressed(); + } + + protected void startMultiSelectActionMode() { + startActionMode(new MultiSelectActionModeCallback(this)); + } + + protected void exitMultiSelectState() { + mConversationListFragment.showFab(); + dismissActionMode(); + mConversationListFragment.updateUi(); + } + + protected boolean isInConversationListSelectMode() { + return getActionModeCallback() instanceof MultiSelectActionModeCallback; + } + + @Override + public boolean isSelectionMode() { + return isInConversationListSelectMode(); + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + } + + @Override + public void onActionBarDelete(final Collection conversations) { + if (!PhoneUtils.getDefault().isDefaultSmsApp()) { + // TODO: figure out a good way to combine this with the implementation in + // ConversationFragment doing similar things + final Activity activity = this; + UiUtils.showSnackBarWithCustomAction(this, + getWindow().getDecorView().getRootView(), + getString(R.string.requires_default_sms_app), + SnackBar.Action.createCustomAction(new Runnable() { + @Override + public void run() { + final Intent intent = + UIIntents.get().getChangeDefaultSmsAppIntent(activity); + startActivityForResult(intent, REQUEST_SET_DEFAULT_SMS_APP); + } + }, + getString(R.string.requires_default_sms_change_button)), + null /* interactions */, + null /* placement */); + return; + } + + new AlertDialog.Builder(this) + .setTitle(getResources().getQuantityString( + R.plurals.delete_conversations_confirmation_dialog_title, + conversations.size())) + .setPositiveButton(R.string.delete_conversation_confirmation_button, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, + final int button) { + for (final SelectedConversation conversation : conversations) { + DeleteConversationAction.deleteConversation( + conversation.conversationId, + conversation.timestamp); + } + exitMultiSelectState(); + } + }) + .setNegativeButton(R.string.delete_conversation_decline_button, null) + .show(); + } + + @Override + public void onActionBarArchive(final Iterable conversations, + final boolean isToArchive) { + final ArrayList conversationIds = new ArrayList(); + for (final SelectedConversation conversation : conversations) { + final String conversationId = conversation.conversationId; + conversationIds.add(conversationId); + if (isToArchive) { + UpdateConversationArchiveStatusAction.archiveConversation(conversationId); + } else { + UpdateConversationArchiveStatusAction.unarchiveConversation(conversationId); + } + } + + final Runnable undoRunnable = new Runnable() { + @Override + public void run() { + for (final String conversationId : conversationIds) { + if (isToArchive) { + UpdateConversationArchiveStatusAction.unarchiveConversation(conversationId); + } else { + UpdateConversationArchiveStatusAction.archiveConversation(conversationId); + } + } + } + }; + + final int textId = + isToArchive ? R.string.archived_toast_message : R.string.unarchived_toast_message; + final String message = getResources().getString(textId, conversationIds.size()); + UiUtils.showSnackBar(this, findViewById(android.R.id.list), message, undoRunnable, + SnackBar.Action.SNACK_BAR_UNDO, + mConversationListFragment.getSnackBarInteractions()); + exitMultiSelectState(); + } + + @Override + public void onActionBarNotification(final Iterable conversations, + final boolean isNotificationOn) { + for (final SelectedConversation conversation : conversations) { + UpdateConversationOptionsAction.enableConversationNotifications( + conversation.conversationId, isNotificationOn); + } + + final int textId = isNotificationOn ? + R.string.notification_on_toast_message : R.string.notification_off_toast_message; + final String message = getResources().getString(textId, 1); + UiUtils.showSnackBar(this, findViewById(android.R.id.list), message, + null /* undoRunnable */, + SnackBar.Action.SNACK_BAR_UNDO, mConversationListFragment.getSnackBarInteractions()); + exitMultiSelectState(); + } + + @Override + public void onActionBarAddContact(final SelectedConversation conversation) { + final Uri avatarUri; + if (conversation.icon != null) { + avatarUri = Uri.parse(conversation.icon); + } else { + avatarUri = null; + } + final AddContactsConfirmationDialog dialog = new AddContactsConfirmationDialog( + this, avatarUri, conversation.otherParticipantNormalizedDestination); + dialog.show(); + exitMultiSelectState(); + } + + @Override + public void onActionBarBlock(final SelectedConversation conversation) { + final Resources res = getResources(); + new AlertDialog.Builder(this) + .setTitle(res.getString(R.string.block_confirmation_title, + conversation.otherParticipantNormalizedDestination)) + .setMessage(res.getString(R.string.block_confirmation_message)) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface arg0, final int arg1) { + final Context context = AbstractConversationListActivity.this; + final View listView = findViewById(android.R.id.list); + final List interactions = + mConversationListFragment.getSnackBarInteractions(); + final UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener + undoListener = + new UpdateDestinationBlockedActionSnackBar( + context, listView, null /* undoRunnable */, + interactions); + final Runnable undoRunnable = new Runnable() { + @Override + public void run() { + UpdateDestinationBlockedAction.updateDestinationBlocked( + conversation.otherParticipantNormalizedDestination, false, + conversation.conversationId, + undoListener); + } + }; + final UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener + listener = new UpdateDestinationBlockedActionSnackBar( + context, listView, undoRunnable, interactions); + UpdateDestinationBlockedAction.updateDestinationBlocked( + conversation.otherParticipantNormalizedDestination, true, + conversation.conversationId, + listener); + exitMultiSelectState(); + } + }) + .create() + .show(); + } + + @Override + public void onConversationClick(final ConversationListData listData, + final ConversationListItemData conversationListItemData, + final boolean isLongClick, + final ConversationListItemView conversationView) { + if (isLongClick && !isInConversationListSelectMode()) { + startMultiSelectActionMode(); + } + + if (isInConversationListSelectMode()) { + final MultiSelectActionModeCallback multiSelectActionMode = + (MultiSelectActionModeCallback) getActionModeCallback(); + multiSelectActionMode.toggleSelect(listData, conversationListItemData); + mConversationListFragment.updateUi(); + } else { + final String conversationId = conversationListItemData.getConversationId(); + Bundle sceneTransitionAnimationOptions = null; + boolean hasCustomTransitions = false; + + UIIntents.get().launchConversationActivity( + this, conversationId, null, + sceneTransitionAnimationOptions, + hasCustomTransitions); + } + } + + @Override + public void onCreateConversationClick() { + UIIntents.get().launchCreateNewConversationActivity(this, null); + } + + + @Override + public boolean isConversationSelected(final String conversationId) { + return isInConversationListSelectMode() && + ((MultiSelectActionModeCallback) getActionModeCallback()).isSelected( + conversationId); + } + + public void onActionBarDebug() { + DebugUtils.showDebugOptions(this); + } + + private static class UpdateDestinationBlockedActionSnackBar + implements UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener { + private final Context mContext; + private final View mParentView; + private final Runnable mUndoRunnable; + private final List mInteractions; + + UpdateDestinationBlockedActionSnackBar(final Context context, + @NonNull final View parentView, @Nullable final Runnable undoRunnable, + @Nullable List interactions) { + mContext = context; + mParentView = parentView; + mUndoRunnable = undoRunnable; + mInteractions = interactions; + } + + @Override + public void onUpdateDestinationBlockedAction( + final UpdateDestinationBlockedAction action, + final boolean success, final boolean block, + final String destination) { + if (success) { + final int messageId = block ? R.string.blocked_toast_message + : R.string.unblocked_toast_message; + final String message = mContext.getResources().getString(messageId, 1); + UiUtils.showSnackBar(mContext, mParentView, message, mUndoRunnable, + SnackBar.Action.SNACK_BAR_UNDO, mInteractions); + } + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ArchivedConversationListActivity.java b/src/com/android/messaging/ui/conversationlist/ArchivedConversationListActivity.java new file mode 100644 index 0000000..366c7d3 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ArchivedConversationListActivity.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversationlist; + +import android.graphics.drawable.ColorDrawable; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.view.Menu; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.util.DebugUtils; + +public class ArchivedConversationListActivity extends AbstractConversationListActivity { + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + final ConversationListFragment fragment = + ConversationListFragment.createArchivedConversationListFragment(); + getFragmentManager().beginTransaction().add(android.R.id.content, fragment).commit(); + invalidateActionBar(); + } + + protected void updateActionBar(ActionBar actionBar) { + actionBar.setTitle(getString(R.string.archived_activity_title)); + actionBar.setDisplayShowTitleEnabled(true); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setBackgroundDrawable(new ColorDrawable( + getResources().getColor( + R.color.archived_conversation_action_bar_background_color_dark))); + actionBar.show(); + super.updateActionBar(actionBar); + } + + @Override + public void onBackPressed() { + if (isInConversationListSelectMode()) { + exitMultiSelectState(); + } else { + super.onBackPressed(); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + if (super.onCreateOptionsMenu(menu)) { + return true; + } + getMenuInflater().inflate(R.menu.archived_conversation_list_menu, menu); + final MenuItem item = menu.findItem(R.id.action_debug_options); + if (item != null) { + final boolean enableDebugItems = DebugUtils.isDebugEnabled(); + item.setVisible(enableDebugItems).setEnabled(enableDebugItems); + } + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem menuItem) { + switch(menuItem.getItemId()) { + case R.id.action_debug_options: + onActionBarDebug(); + return true; + case android.R.id.home: + onActionBarHome(); + return true; + default: + return super.onOptionsItemSelected(menuItem); + } + } + + @Override + public void onActionBarHome() { + onBackPressed(); + } + + @Override + public boolean isSwipeAnimatable() { + return false; + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListActivity.java b/src/com/android/messaging/ui/conversationlist/ConversationListActivity.java new file mode 100644 index 0000000..f8abe81 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ConversationListActivity.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversationlist; + +import android.graphics.drawable.ColorDrawable; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.view.Menu; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.Trace; + +public class ConversationListActivity extends AbstractConversationListActivity { + @Override + protected void onCreate(final Bundle savedInstanceState) { + Trace.beginSection("ConversationListActivity.onCreate"); + super.onCreate(savedInstanceState); + setContentView(R.layout.conversation_list_activity); + Trace.endSection(); + invalidateActionBar(); + } + + @Override + protected void updateActionBar(final ActionBar actionBar) { + actionBar.setTitle(getString(R.string.app_name)); + actionBar.setDisplayShowTitleEnabled(true); + actionBar.setDisplayHomeAsUpEnabled(false); + actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD); + actionBar.setBackgroundDrawable(new ColorDrawable( + getResources().getColor(R.color.action_bar_background_color))); + actionBar.show(); + super.updateActionBar(actionBar); + } + + @Override + public void onResume() { + super.onResume(); + // Invalidate the menu as items that are based on settings may have changed + // while not in the app (e.g. Talkback enabled/disable affects new conversation + // button) + supportInvalidateOptionsMenu(); + } + + @Override + public void onBackPressed() { + if (isInConversationListSelectMode()) { + exitMultiSelectState(); + } else { + super.onBackPressed(); + } + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + if (super.onCreateOptionsMenu(menu)) { + return true; + } + getMenuInflater().inflate(R.menu.conversation_list_fragment_menu, menu); + final MenuItem item = menu.findItem(R.id.action_debug_options); + if (item != null) { + final boolean enableDebugItems = DebugUtils.isDebugEnabled(); + item.setVisible(enableDebugItems).setEnabled(enableDebugItems); + } + return true; + } + + @Override + public boolean onOptionsItemSelected(final MenuItem menuItem) { + switch(menuItem.getItemId()) { + case R.id.action_start_new_conversation: + onActionBarStartNewConversation(); + return true; + case R.id.action_settings: + onActionBarSettings(); + return true; + case R.id.action_debug_options: + onActionBarDebug(); + return true; + case R.id.action_show_archived: + onActionBarArchived(); + return true; + case R.id.action_show_blocked_contacts: + onActionBarBlockedParticipants(); + return true; + } + return super.onOptionsItemSelected(menuItem); + } + + @Override + public void onActionBarHome() { + exitMultiSelectState(); + } + + public void onActionBarStartNewConversation() { + UIIntents.get().launchCreateNewConversationActivity(this, null); + } + + public void onActionBarSettings() { + UIIntents.get().launchSettingsActivity(this); + } + + public void onActionBarBlockedParticipants() { + UIIntents.get().launchBlockedParticipantsActivity(this); + } + + public void onActionBarArchived() { + UIIntents.get().launchArchivedConversationsActivity(this); + } + + @Override + public boolean isSwipeAnimatable() { + return !isInConversationListSelectMode(); + } + + @Override + public void onWindowFocusChanged(final boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + final ConversationListFragment conversationListFragment = + (ConversationListFragment) getFragmentManager().findFragmentById( + R.id.conversation_list_fragment); + // When the screen is turned on, the last used activity gets resumed, but it gets + // window focus only after the lock screen is unlocked. + if (hasFocus && conversationListFragment != null) { + conversationListFragment.setScrolledToNewestConversationIfNeeded(); + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListAdapter.java b/src/com/android/messaging/ui/conversationlist/ConversationListAdapter.java new file mode 100644 index 0000000..629c4ae --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ConversationListAdapter.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversationlist; + +import android.content.Context; +import android.database.Cursor; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import com.android.messaging.R; +import com.android.messaging.ui.CursorRecyclerAdapter; +import com.android.messaging.ui.conversationlist.ConversationListItemView.HostInterface; + +/** + * Provides an interface to expose Conversation List Cursor data to a UI widget like a ListView. + */ +public class ConversationListAdapter + extends CursorRecyclerAdapter { + + private final ConversationListItemView.HostInterface mClivHostInterface; + + public ConversationListAdapter(final Context context, final Cursor cursor, + final ConversationListItemView.HostInterface clivHostInterface) { + super(context, cursor, 0); + mClivHostInterface = clivHostInterface; + setHasStableIds(true); + } + + /** + * @see com.android.messaging.ui.CursorRecyclerAdapter#bindViewHolder( + * android.support.v7.widget.RecyclerView.ViewHolder, android.content.Context, + * android.database.Cursor) + */ + @Override + public void bindViewHolder(final ConversationListViewHolder holder, final Context context, + final Cursor cursor) { + final ConversationListItemView conversationListItemView = holder.mView; + conversationListItemView.bind(cursor, mClivHostInterface); + } + + @Override + public ConversationListViewHolder createViewHolder(final Context context, + final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(context); + final ConversationListItemView itemView = + (ConversationListItemView) layoutInflater.inflate( + R.layout.conversation_list_item_view, null); + return new ConversationListViewHolder(itemView); + } + + /** + * ViewHolder that holds a ConversationListItemView. + */ + public static class ConversationListViewHolder extends RecyclerView.ViewHolder { + final ConversationListItemView mView; + + public ConversationListViewHolder(final ConversationListItemView itemView) { + super(itemView); + mView = itemView; + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListFragment.java b/src/com/android/messaging/ui/conversationlist/ConversationListFragment.java new file mode 100644 index 0000000..2f868d4 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ConversationListFragment.java @@ -0,0 +1,446 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversationlist; + +import android.app.Activity; +import android.app.Fragment; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcelable; +import android.support.v4.view.ViewCompat; +import android.support.v4.view.ViewGroupCompat; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.ViewGroup.MarginLayoutParams; +import android.view.ViewPropertyAnimator; +import android.view.accessibility.AccessibilityManager; +import android.widget.AbsListView; +import android.widget.ImageView; + +import com.android.messaging.R; +import com.android.messaging.annotation.VisibleForAnimation; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.ui.BugleAnimationTags; +import com.android.messaging.ui.ListEmptyView; +import com.android.messaging.ui.SnackBarInteraction; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ImeUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.UiUtils; +import com.google.common.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.List; + +/** + * Shows a list of conversations. + */ +public class ConversationListFragment extends Fragment implements ConversationListDataListener, + ConversationListItemView.HostInterface { + private static final String BUNDLE_ARCHIVED_MODE = "archived_mode"; + private static final String BUNDLE_FORWARD_MESSAGE_MODE = "forward_message_mode"; + private static final boolean VERBOSE = false; + + private MenuItem mShowBlockedMenuItem; + private boolean mArchiveMode; + private boolean mBlockedAvailable; + private boolean mForwardMessageMode; + + public interface ConversationListFragmentHost { + public void onConversationClick(final ConversationListData listData, + final ConversationListItemData conversationListItemData, + final boolean isLongClick, + final ConversationListItemView conversationView); + public void onCreateConversationClick(); + public boolean isConversationSelected(final String conversationId); + public boolean isSwipeAnimatable(); + public boolean isSelectionMode(); + public boolean hasWindowFocus(); + } + + private ConversationListFragmentHost mHost; + private RecyclerView mRecyclerView; + private ImageView mStartNewConversationButton; + private ListEmptyView mEmptyListMessageView; + private ConversationListAdapter mAdapter; + + // Saved Instance State Data - only for temporal data which is nice to maintain but not + // critical for correctness. + private static final String SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY = + "conversationListViewState"; + private Parcelable mListState; + + @VisibleForTesting + final Binding mListBinding = BindingBase.createBinding(this); + + public static ConversationListFragment createArchivedConversationListFragment() { + return createConversationListFragment(BUNDLE_ARCHIVED_MODE); + } + + public static ConversationListFragment createForwardMessageConversationListFragment() { + return createConversationListFragment(BUNDLE_FORWARD_MESSAGE_MODE); + } + + public static ConversationListFragment createConversationListFragment(String modeKeyName) { + final ConversationListFragment fragment = new ConversationListFragment(); + final Bundle bundle = new Bundle(); + bundle.putBoolean(modeKeyName, true); + fragment.setArguments(bundle); + return fragment; + } + + /** + * {@inheritDoc} from Fragment + */ + @Override + public void onCreate(final Bundle bundle) { + super.onCreate(bundle); + mListBinding.getData().init(getLoaderManager(), mListBinding); + mAdapter = new ConversationListAdapter(getActivity(), null, this); + } + + @Override + public void onResume() { + super.onResume(); + + Assert.notNull(mHost); + setScrolledToNewestConversationIfNeeded(); + + updateUi(); + } + + public void setScrolledToNewestConversationIfNeeded() { + if (!mArchiveMode + && !mForwardMessageMode + && isScrolledToFirstConversation() + && mHost.hasWindowFocus()) { + mListBinding.getData().setScrolledToNewestConversation(true); + } + } + + private boolean isScrolledToFirstConversation() { + int firstItemPosition = ((LinearLayoutManager) mRecyclerView.getLayoutManager()) + .findFirstCompletelyVisibleItemPosition(); + return firstItemPosition == 0; + } + + /** + * {@inheritDoc} from Fragment + */ + @Override + public void onDestroy() { + super.onDestroy(); + mListBinding.unbind(); + mHost = null; + } + + /** + * {@inheritDoc} from Fragment + */ + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.conversation_list_fragment, + container, false); + mRecyclerView = (RecyclerView) rootView.findViewById(android.R.id.list); + mEmptyListMessageView = (ListEmptyView) rootView.findViewById(R.id.no_conversations_view); + mEmptyListMessageView.setImageHint(R.drawable.ic_oobe_conv_list); + // The default behavior for default layout param generation by LinearLayoutManager is to + // provide width and height of WRAP_CONTENT, but this is not desirable for + // ConversationListFragment; the view in each row should be a width of MATCH_PARENT so that + // the entire row is tappable. + final Activity activity = getActivity(); + final LinearLayoutManager manager = new LinearLayoutManager(activity) { + @Override + public RecyclerView.LayoutParams generateDefaultLayoutParams() { + return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + } + }; + mRecyclerView.setLayoutManager(manager); + mRecyclerView.setHasFixedSize(true); + mRecyclerView.setAdapter(mAdapter); + mRecyclerView.setOnScrollListener(new RecyclerView.OnScrollListener() { + int mCurrentState = AbsListView.OnScrollListener.SCROLL_STATE_IDLE; + + @Override + public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) { + if (mCurrentState == AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL + || mCurrentState == AbsListView.OnScrollListener.SCROLL_STATE_FLING) { + ImeUtil.get().hideImeKeyboard(getActivity(), mRecyclerView); + } + + if (isScrolledToFirstConversation()) { + setScrolledToNewestConversationIfNeeded(); + } else { + mListBinding.getData().setScrolledToNewestConversation(false); + } + } + + @Override + public void onScrollStateChanged(final RecyclerView recyclerView, final int newState) { + mCurrentState = newState; + } + }); + mRecyclerView.addOnItemTouchListener(new ConversationListSwipeHelper(mRecyclerView)); + + if (savedInstanceState != null) { + mListState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY); + } + + mStartNewConversationButton = (ImageView) rootView.findViewById( + R.id.start_new_conversation_button); + if (mArchiveMode) { + mStartNewConversationButton.setVisibility(View.GONE); + } else { + mStartNewConversationButton.setVisibility(View.VISIBLE); + mStartNewConversationButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View clickView) { + mHost.onCreateConversationClick(); + } + }); + } + ViewCompat.setTransitionName(mStartNewConversationButton, BugleAnimationTags.TAG_FABICON); + + // The root view has a non-null background, which by default is deemed by the framework + // to be a "transition group," where all child views are animated together during an + // activity transition. However, we want each individual items in the recycler view to + // show explode animation themselves, so we explicitly tag the root view to be a non-group. + ViewGroupCompat.setTransitionGroup(rootView, false); + + setHasOptionsMenu(true); + return rootView; + } + + @Override + public void onAttach(final Activity activity) { + super.onAttach(activity); + if (VERBOSE) { + LogUtil.v(LogUtil.BUGLE_TAG, "Attaching List"); + } + final Bundle arguments = getArguments(); + if (arguments != null) { + mArchiveMode = arguments.getBoolean(BUNDLE_ARCHIVED_MODE, false); + mForwardMessageMode = arguments.getBoolean(BUNDLE_FORWARD_MESSAGE_MODE, false); + } + mListBinding.bind(DataModel.get().createConversationListData(activity, this, mArchiveMode)); + } + + + @Override + public void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + if (mListState != null) { + outState.putParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY, mListState); + } + } + + @Override + public void onPause() { + super.onPause(); + mListState = mRecyclerView.getLayoutManager().onSaveInstanceState(); + mListBinding.getData().setScrolledToNewestConversation(false); + } + + /** + * Call this immediately after attaching the fragment + */ + public void setHost(final ConversationListFragmentHost host) { + Assert.isNull(mHost); + mHost = host; + } + + @Override + public void onConversationListCursorUpdated(final ConversationListData data, + final Cursor cursor) { + mListBinding.ensureBound(data); + final Cursor oldCursor = mAdapter.swapCursor(cursor); + updateEmptyListUi(cursor == null || cursor.getCount() == 0); + if (mListState != null && cursor != null && oldCursor == null) { + mRecyclerView.getLayoutManager().onRestoreInstanceState(mListState); + } + } + + @Override + public void setBlockedParticipantsAvailable(final boolean blockedAvailable) { + mBlockedAvailable = blockedAvailable; + if (mShowBlockedMenuItem != null) { + mShowBlockedMenuItem.setVisible(blockedAvailable); + } + } + + public void updateUi() { + mAdapter.notifyDataSetChanged(); + } + + @Override + public void onPrepareOptionsMenu(final Menu menu) { + super.onPrepareOptionsMenu(menu); + final MenuItem startNewConversationMenuItem = + menu.findItem(R.id.action_start_new_conversation); + if (startNewConversationMenuItem != null) { + // It is recommended for the Floating Action button functionality to be duplicated as a + // menu + AccessibilityManager accessibilityManager = (AccessibilityManager) + getActivity().getSystemService(Context.ACCESSIBILITY_SERVICE); + startNewConversationMenuItem.setVisible(accessibilityManager + .isTouchExplorationEnabled()); + } + + final MenuItem archive = menu.findItem(R.id.action_show_archived); + if (archive != null) { + archive.setVisible(true); + } + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + if (!isAdded()) { + // Guard against being called before we're added to the activity + return; + } + + mShowBlockedMenuItem = menu.findItem(R.id.action_show_blocked_contacts); + if (mShowBlockedMenuItem != null) { + mShowBlockedMenuItem.setVisible(mBlockedAvailable); + } + } + + /** + * {@inheritDoc} from ConversationListItemView.HostInterface + */ + @Override + public void onConversationClicked(final ConversationListItemData conversationListItemData, + final boolean isLongClick, final ConversationListItemView conversationView) { + final ConversationListData listData = mListBinding.getData(); + mHost.onConversationClick(listData, conversationListItemData, isLongClick, + conversationView); + } + + /** + * {@inheritDoc} from ConversationListItemView.HostInterface + */ + @Override + public boolean isConversationSelected(final String conversationId) { + return mHost.isConversationSelected(conversationId); + } + + @Override + public boolean isSwipeAnimatable() { + return mHost.isSwipeAnimatable(); + } + + // Show and hide empty list UI as needed with appropriate text based on view specifics + private void updateEmptyListUi(final boolean isEmpty) { + if (isEmpty) { + int emptyListText; + if (!mListBinding.getData().getHasFirstSyncCompleted()) { + emptyListText = R.string.conversation_list_first_sync_text; + } else if (mArchiveMode) { + emptyListText = R.string.archived_conversation_list_empty_text; + } else { + emptyListText = R.string.conversation_list_empty_text; + } + mEmptyListMessageView.setTextHint(emptyListText); + mEmptyListMessageView.setVisibility(View.VISIBLE); + mEmptyListMessageView.setIsImageVisible(true); + mEmptyListMessageView.setIsVerticallyCentered(true); + } else { + mEmptyListMessageView.setVisibility(View.GONE); + } + } + + @Override + public List getSnackBarInteractions() { + final List interactions = new ArrayList(1); + final SnackBarInteraction fabInteraction = + new SnackBarInteraction.BasicSnackBarInteraction(mStartNewConversationButton); + interactions.add(fabInteraction); + return interactions; + } + + private ViewPropertyAnimator getNormalizedFabAnimator() { + return mStartNewConversationButton.animate() + .setInterpolator(UiUtils.DEFAULT_INTERPOLATOR) + .setDuration(getActivity().getResources().getInteger( + R.integer.fab_animation_duration_ms)); + } + + public ViewPropertyAnimator dismissFab() { + // To prevent clicking while animating. + mStartNewConversationButton.setEnabled(false); + final MarginLayoutParams lp = + (MarginLayoutParams) mStartNewConversationButton.getLayoutParams(); + final float fabWidthWithLeftRightMargin = mStartNewConversationButton.getWidth() + + lp.leftMargin + lp.rightMargin; + final int direction = AccessibilityUtil.isLayoutRtl(mStartNewConversationButton) ? -1 : 1; + return getNormalizedFabAnimator().translationX(direction * fabWidthWithLeftRightMargin); + } + + public ViewPropertyAnimator showFab() { + return getNormalizedFabAnimator().translationX(0).withEndAction(new Runnable() { + @Override + public void run() { + // Re-enable clicks after the animation. + mStartNewConversationButton.setEnabled(true); + } + }); + } + + public View getHeroElementForTransition() { + return mArchiveMode ? null : mStartNewConversationButton; + } + + @VisibleForAnimation + public RecyclerView getRecyclerView() { + return mRecyclerView; + } + + @Override + public void startFullScreenPhotoViewer( + final Uri initialPhoto, final Rect initialPhotoBounds, final Uri photosUri) { + UIIntents.get().launchFullScreenPhotoViewer( + getActivity(), initialPhoto, initialPhotoBounds, photosUri); + } + + @Override + public void startFullScreenVideoViewer(final Uri videoUri) { + UIIntents.get().launchFullScreenVideoViewer(getActivity(), videoUri); + } + + @Override + public boolean isSelectionMode() { + return mHost != null && mHost.isSelectionMode(); + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListItemView.java b/src/com/android/messaging/ui/conversationlist/ConversationListItemView.java new file mode 100644 index 0000000..7525182 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ConversationListItemView.java @@ -0,0 +1,643 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversationlist; + +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Color; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.net.Uri; +import android.support.v4.text.BidiFormatter; +import android.support.v4.text.TextDirectionHeuristicsCompat; +import android.text.TextPaint; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.View.OnLayoutChangeListener; +import android.view.View.OnLongClickListener; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.annotation.VisibleForAnimation; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.media.UriImageRequestDescriptor; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.AsyncImageView; +import com.android.messaging.ui.AudioAttachmentView; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.ui.SnackBar; +import com.android.messaging.ui.SnackBarInteraction; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.Typefaces; +import com.android.messaging.util.UiUtils; +import com.android.messaging.util.UriUtil; + +import java.util.List; + +/** + * The view for a single entry in a conversation list. + */ +public class ConversationListItemView extends FrameLayout implements OnClickListener, + OnLongClickListener, OnLayoutChangeListener { + static final int UNREAD_SNIPPET_LINE_COUNT = 3; + static final int NO_UNREAD_SNIPPET_LINE_COUNT = 1; + private int mListItemReadColor; + private int mListItemUnreadColor; + private Typeface mListItemReadTypeface; + private Typeface mListItemUnreadTypeface; + private static String sPlusOneString; + private static String sPlusNString; + + public interface HostInterface { + boolean isConversationSelected(final String conversationId); + void onConversationClicked(final ConversationListItemData conversationListItemData, + boolean isLongClick, final ConversationListItemView conversationView); + boolean isSwipeAnimatable(); + List getSnackBarInteractions(); + void startFullScreenPhotoViewer(final Uri initialPhoto, final Rect initialPhotoBounds, + final Uri photosUri); + void startFullScreenVideoViewer(final Uri videoUri); + boolean isSelectionMode(); + } + + private final OnClickListener fullScreenPreviewClickListener = new OnClickListener() { + @Override + public void onClick(final View v) { + final String previewType = mData.getShowDraft() ? + mData.getDraftPreviewContentType() : mData.getPreviewContentType(); + Assert.isTrue(ContentType.isImageType(previewType) || + ContentType.isVideoType(previewType)); + + final Uri previewUri = mData.getShowDraft() ? + mData.getDraftPreviewUri() : mData.getPreviewUri(); + if (ContentType.isImageType(previewType)) { + final Uri imagesUri = mData.getShowDraft() ? + MessagingContentProvider.buildDraftImagesUri(mData.getConversationId()) : + MessagingContentProvider + .buildConversationImagesUri(mData.getConversationId()); + final Rect previewImageBounds = UiUtils.getMeasuredBoundsOnScreen(v); + mHostInterface.startFullScreenPhotoViewer( + previewUri, previewImageBounds, imagesUri); + } else { + mHostInterface.startFullScreenVideoViewer(previewUri); + } + } + }; + + private final ConversationListItemData mData; + + private int mAnimatingCount; + private ViewGroup mSwipeableContainer; + private ViewGroup mCrossSwipeBackground; + private ViewGroup mSwipeableContent; + private TextView mConversationNameView; + private TextView mSnippetTextView; + private TextView mSubjectTextView; + private TextView mTimestampTextView; + private ContactIconView mContactIconView; + private ImageView mContactCheckmarkView; + private ImageView mNotificationBellView; + private ImageView mFailedStatusIconView; + private ImageView mCrossSwipeArchiveLeftImageView; + private ImageView mCrossSwipeArchiveRightImageView; + private AsyncImageView mImagePreviewView; + private AudioAttachmentView mAudioAttachmentView; + private HostInterface mHostInterface; + + public ConversationListItemView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mData = new ConversationListItemData(); + final Resources res = context.getResources(); + } + + @Override + protected void onFinishInflate() { + mSwipeableContainer = (ViewGroup) findViewById(R.id.swipeableContainer); + mCrossSwipeBackground = (ViewGroup) findViewById(R.id.crossSwipeBackground); + mSwipeableContent = (ViewGroup) findViewById(R.id.swipeableContent); + mConversationNameView = (TextView) findViewById(R.id.conversation_name); + mSnippetTextView = (TextView) findViewById(R.id.conversation_snippet); + mSubjectTextView = (TextView) findViewById(R.id.conversation_subject); + mTimestampTextView = (TextView) findViewById(R.id.conversation_timestamp); + mContactIconView = (ContactIconView) findViewById(R.id.conversation_icon); + mContactCheckmarkView = (ImageView) findViewById(R.id.conversation_checkmark); + mNotificationBellView = (ImageView) findViewById(R.id.conversation_notification_bell); + mFailedStatusIconView = (ImageView) findViewById(R.id.conversation_failed_status_icon); + mCrossSwipeArchiveLeftImageView = (ImageView) findViewById(R.id.crossSwipeArchiveIconLeft); + mCrossSwipeArchiveRightImageView = + (ImageView) findViewById(R.id.crossSwipeArchiveIconRight); + mImagePreviewView = (AsyncImageView) findViewById(R.id.conversation_image_preview); + mAudioAttachmentView = (AudioAttachmentView) findViewById(R.id.audio_attachment_view); + mConversationNameView.addOnLayoutChangeListener(this); + mSnippetTextView.addOnLayoutChangeListener(this); + + final Resources resources = getContext().getResources(); + mListItemReadColor = resources.getColor(R.color.conversation_list_item_read); + mListItemUnreadColor = resources.getColor(R.color.conversation_list_item_unread); + + mListItemReadTypeface = Typefaces.getRobotoNormal(); + mListItemUnreadTypeface = Typefaces.getRobotoBold(); + + if (OsUtil.isAtLeastL()) { + setTransitionGroup(true); + } + } + + @Override + public void onLayoutChange(final View v, final int left, final int top, final int right, + final int bottom, final int oldLeft, final int oldTop, final int oldRight, + final int oldBottom) { + if (v == mConversationNameView) { + setConversationName(); + } else if (v == mSnippetTextView) { + setSnippet(); + } else if (v == mSubjectTextView) { + setSubject(); + } + } + + private void setConversationName() { + if (mData.getIsRead() || mData.getShowDraft()) { + mConversationNameView.setTextColor(mListItemReadColor); + mConversationNameView.setTypeface(mListItemReadTypeface); + } else { + mConversationNameView.setTextColor(mListItemUnreadColor); + mConversationNameView.setTypeface(mListItemUnreadTypeface); + } + + final String conversationName = mData.getName(); + + // For group conversations, ellipsize the group members that do not fit + final CharSequence ellipsizedName = UiUtils.commaEllipsize( + conversationName, + mConversationNameView.getPaint(), + mConversationNameView.getMeasuredWidth(), + getPlusOneString(), + getPlusNString()); + // RTL : To format conversation name if it happens to be phone number. + final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); + final String bidiFormattedName = bidiFormatter.unicodeWrap( + ellipsizedName.toString(), + TextDirectionHeuristicsCompat.LTR); + + mConversationNameView.setText(bidiFormattedName); + } + + private static String getPlusOneString() { + if (sPlusOneString == null) { + sPlusOneString = Factory.get().getApplicationContext().getResources() + .getString(R.string.plus_one); + } + return sPlusOneString; + } + + private static String getPlusNString() { + if (sPlusNString == null) { + sPlusNString = Factory.get().getApplicationContext().getResources() + .getString(R.string.plus_n); + } + return sPlusNString; + } + + private void setSubject() { + final String subjectText = mData.getShowDraft() ? + mData.getDraftSubject() : + MmsUtils.cleanseMmsSubject(getContext().getResources(), mData.getSubject()); + if (!TextUtils.isEmpty(subjectText)) { + final String subjectPrepend = getResources().getString(R.string.subject_label); + mSubjectTextView.setText(TextUtils.concat(subjectPrepend, subjectText)); + mSubjectTextView.setVisibility(VISIBLE); + } else { + mSubjectTextView.setVisibility(GONE); + } + } + + private void setSnippet() { + mSnippetTextView.setText(getSnippetText()); + } + + // Resource Ids of content descriptions prefixes for different message status. + private static final int [][][] sPrimaryContentDescriptions = { + // 1:1 conversation + { + // Incoming message + { + R.string.one_on_one_incoming_failed_message_prefix, + R.string.one_on_one_incoming_successful_message_prefix + }, + // Outgoing message + { + R.string.one_on_one_outgoing_failed_message_prefix, + R.string.one_on_one_outgoing_successful_message_prefix, + R.string.one_on_one_outgoing_draft_message_prefix, + R.string.one_on_one_outgoing_sending_message_prefix, + } + }, + + // Group conversation + { + // Incoming message + { + R.string.group_incoming_failed_message_prefix, + R.string.group_incoming_successful_message_prefix, + }, + // Outgoing message + { + R.string.group_outgoing_failed_message_prefix, + R.string.group_outgoing_successful_message_prefix, + R.string.group_outgoing_draft_message_prefix, + R.string.group_outgoing_sending_message_prefix, + } + } + }; + + // Resource Id of the secondary part of the content description for an edge case of a message + // which is in both draft status and failed status. + private static final int sSecondaryContentDescription = + R.string.failed_message_content_description; + + // 1:1 versus group + private static final int CONV_TYPE_ONE_ON_ONE_INDEX = 0; + private static final int CONV_TYPE_ONE_GROUP_INDEX = 1; + // Direction + private static final int DIRECTION_INCOMING_INDEX = 0; + private static final int DIRECTION_OUTGOING_INDEX = 1; + // Message status + private static final int MESSAGE_STATUS_FAILED_INDEX = 0; + private static final int MESSAGE_STATUS_SUCCESSFUL_INDEX = 1; + private static final int MESSAGE_STATUS_DRAFT_INDEX = 2; + private static final int MESSAGE_STATUS_SENDING_INDEX = 3; + + private static final int WIDTH_FOR_ACCESSIBLE_CONVERSATION_NAME = 600; + + public static String buildContentDescription(final Resources resources, + final ConversationListItemData data, final TextPaint conversationNameViewPaint) { + int messageStatusIndex; + boolean outgoingSnippet = data.getIsMessageTypeOutgoing() || data.getShowDraft(); + if (outgoingSnippet) { + if (data.getShowDraft()) { + messageStatusIndex = MESSAGE_STATUS_DRAFT_INDEX; + } else if (data.getIsSendRequested()) { + messageStatusIndex = MESSAGE_STATUS_SENDING_INDEX; + } else { + messageStatusIndex = data.getIsFailedStatus() ? MESSAGE_STATUS_FAILED_INDEX + : MESSAGE_STATUS_SUCCESSFUL_INDEX; + } + } else { + messageStatusIndex = data.getIsFailedStatus() ? MESSAGE_STATUS_FAILED_INDEX + : MESSAGE_STATUS_SUCCESSFUL_INDEX; + } + + int resId = sPrimaryContentDescriptions + [data.getIsGroup() ? CONV_TYPE_ONE_GROUP_INDEX : CONV_TYPE_ONE_ON_ONE_INDEX] + [outgoingSnippet ? DIRECTION_OUTGOING_INDEX : DIRECTION_INCOMING_INDEX] + [messageStatusIndex]; + + final String snippetText = data.getShowDraft() ? + data.getDraftSnippetText() : data.getSnippetText(); + + final String conversationName = data.getName(); + String senderOrConvName = outgoingSnippet ? conversationName : data.getSnippetSenderName(); + + String primaryContentDescription = resources.getString(resId, senderOrConvName, + snippetText == null ? "" : snippetText, + data.getFormattedTimestamp(), + // This is used only for incoming group messages + conversationName); + String contentDescription = primaryContentDescription; + + // An edge case : for an outgoing message, it might be in both draft status and + // failed status. + if (outgoingSnippet && data.getShowDraft() && data.getIsFailedStatus()) { + StringBuilder contentDescriptionBuilder = new StringBuilder(); + contentDescriptionBuilder.append(primaryContentDescription); + + String secondaryContentDescription = + resources.getString(sSecondaryContentDescription); + contentDescriptionBuilder.append(" "); + contentDescriptionBuilder.append(secondaryContentDescription); + contentDescription = contentDescriptionBuilder.toString(); + } + return contentDescription; + } + + /** + * Fills in the data associated with this view. + * + * @param cursor The cursor from a ConversationList that this view is in, pointing to its + * entry. + */ + public void bind(final Cursor cursor, final HostInterface hostInterface) { + // Update our UI model + mHostInterface = hostInterface; + mData.bind(cursor); + + resetAnimatingState(); + + mSwipeableContainer.setOnClickListener(this); + mSwipeableContainer.setOnLongClickListener(this); + + final Resources resources = getContext().getResources(); + + int color; + final int maxLines; + final Typeface typeface; + final int typefaceStyle = mData.getShowDraft() ? Typeface.ITALIC : Typeface.NORMAL; + final String snippetText = getSnippetText(); + + if (mData.getIsRead() || mData.getShowDraft()) { + maxLines = TextUtils.isEmpty(snippetText) ? 0 : NO_UNREAD_SNIPPET_LINE_COUNT; + color = mListItemReadColor; + typeface = mListItemReadTypeface; + } else { + maxLines = TextUtils.isEmpty(snippetText) ? 0 : UNREAD_SNIPPET_LINE_COUNT; + color = mListItemUnreadColor; + typeface = mListItemUnreadTypeface; + } + + mSnippetTextView.setMaxLines(maxLines); + mSnippetTextView.setTextColor(color); + mSnippetTextView.setTypeface(typeface, typefaceStyle); + mSubjectTextView.setTextColor(color); + mSubjectTextView.setTypeface(typeface, typefaceStyle); + + setSnippet(); + setConversationName(); + setSubject(); + setContentDescription(buildContentDescription(resources, mData, + mConversationNameView.getPaint())); + + final boolean isDefaultSmsApp = PhoneUtils.getDefault().isDefaultSmsApp(); + // don't show the error state unless we're the default sms app + if (mData.getIsFailedStatus() && isDefaultSmsApp) { + mTimestampTextView.setTextColor(resources.getColor(R.color.conversation_list_error)); + mTimestampTextView.setTypeface(mListItemReadTypeface, typefaceStyle); + int failureMessageId = R.string.message_status_download_failed; + if (mData.getIsMessageTypeOutgoing()) { + failureMessageId = MmsUtils.mapRawStatusToErrorResourceId(mData.getMessageStatus(), + mData.getMessageRawTelephonyStatus()); + } + mTimestampTextView.setText(resources.getString(failureMessageId)); + } else if (mData.getShowDraft() + || mData.getMessageStatus() == MessageData.BUGLE_STATUS_OUTGOING_DRAFT + // also check for unknown status which we get because sometimes the conversation + // row is left with a latest_message_id of a no longer existing message and + // therefore the join values come back as null (or in this case zero). + || mData.getMessageStatus() == MessageData.BUGLE_STATUS_UNKNOWN) { + mTimestampTextView.setTextColor(mListItemReadColor); + mTimestampTextView.setTypeface(mListItemReadTypeface, typefaceStyle); + mTimestampTextView.setText(resources.getString( + R.string.conversation_list_item_view_draft_message)); + } else { + mTimestampTextView.setTextColor(mListItemReadColor); + mTimestampTextView.setTypeface(mListItemReadTypeface, typefaceStyle); + final String formattedTimestamp = mData.getFormattedTimestamp(); + if (mData.getIsSendRequested()) { + mTimestampTextView.setText(R.string.message_status_sending); + } else { + mTimestampTextView.setText(formattedTimestamp); + } + } + + final boolean isSelected = mHostInterface.isConversationSelected(mData.getConversationId()); + setSelected(isSelected); + Uri iconUri = null; + int contactIconVisibility = GONE; + int checkmarkVisiblity = GONE; + int failStatusVisiblity = GONE; + if (isSelected) { + checkmarkVisiblity = VISIBLE; + } else { + contactIconVisibility = VISIBLE; + // Only show the fail icon if it is not a group conversation. + // And also require that we be the default sms app. + if (mData.getIsFailedStatus() && !mData.getIsGroup() && isDefaultSmsApp) { + failStatusVisiblity = VISIBLE; + } + } + if (mData.getIcon() != null) { + iconUri = Uri.parse(mData.getIcon()); + } + mContactIconView.setImageResourceUri(iconUri, mData.getParticipantContactId(), + mData.getParticipantLookupKey(), mData.getOtherParticipantNormalizedDestination()); + mContactIconView.setVisibility(contactIconVisibility); + mContactIconView.setOnLongClickListener(this); + mContactIconView.setClickable(!mHostInterface.isSelectionMode()); + mContactIconView.setLongClickable(!mHostInterface.isSelectionMode()); + + mContactCheckmarkView.setVisibility(checkmarkVisiblity); + mFailedStatusIconView.setVisibility(failStatusVisiblity); + + final Uri previewUri = mData.getShowDraft() ? + mData.getDraftPreviewUri() : mData.getPreviewUri(); + final String previewContentType = mData.getShowDraft() ? + mData.getDraftPreviewContentType() : mData.getPreviewContentType(); + OnClickListener previewClickListener = null; + Uri previewImageUri = null; + int previewImageVisibility = GONE; + int audioPreviewVisiblity = GONE; + if (previewUri != null && !TextUtils.isEmpty(previewContentType)) { + if (ContentType.isAudioType(previewContentType)) { + mAudioAttachmentView.bind(previewUri, false); + audioPreviewVisiblity = VISIBLE; + } else if (ContentType.isVideoType(previewContentType)) { + previewImageUri = UriUtil.getUriForResourceId( + getContext(), R.drawable.ic_preview_play); + previewClickListener = fullScreenPreviewClickListener; + previewImageVisibility = VISIBLE; + } else if (ContentType.isImageType(previewContentType)) { + previewImageUri = previewUri; + previewClickListener = fullScreenPreviewClickListener; + previewImageVisibility = VISIBLE; + } + } + + final int imageSize = resources.getDimensionPixelSize( + R.dimen.conversation_list_image_preview_size); + mImagePreviewView.setImageResourceId( + new UriImageRequestDescriptor(previewImageUri, imageSize, imageSize, + true /* allowCompression */, false /* isStatic */, false /*cropToCircle*/, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */)); + mImagePreviewView.setOnLongClickListener(this); + mImagePreviewView.setVisibility(previewImageVisibility); + mImagePreviewView.setOnClickListener(previewClickListener); + mAudioAttachmentView.setOnLongClickListener(this); + mAudioAttachmentView.setVisibility(audioPreviewVisiblity); + + final int notificationBellVisiblity = mData.getNotificationEnabled() ? GONE : VISIBLE; + mNotificationBellView.setVisibility(notificationBellVisiblity); + } + + public boolean isSwipeAnimatable() { + return mHostInterface.isSwipeAnimatable(); + } + + @VisibleForAnimation + public float getSwipeTranslationX() { + return mSwipeableContainer.getTranslationX(); + } + + @VisibleForAnimation + public void setSwipeTranslationX(final float translationX) { + mSwipeableContainer.setTranslationX(translationX); + if (translationX == 0) { + mCrossSwipeBackground.setVisibility(View.GONE); + mCrossSwipeArchiveLeftImageView.setVisibility(GONE); + mCrossSwipeArchiveRightImageView.setVisibility(GONE); + + mSwipeableContainer.setBackgroundColor(Color.TRANSPARENT); + } else { + mCrossSwipeBackground.setVisibility(View.VISIBLE); + if (translationX > 0) { + mCrossSwipeArchiveLeftImageView.setVisibility(VISIBLE); + mCrossSwipeArchiveRightImageView.setVisibility(GONE); + } else { + mCrossSwipeArchiveLeftImageView.setVisibility(GONE); + mCrossSwipeArchiveRightImageView.setVisibility(VISIBLE); + } + mSwipeableContainer.setBackgroundResource(R.drawable.swipe_shadow_drag); + } + } + + public void onSwipeComplete() { + final String conversationId = mData.getConversationId(); + UpdateConversationArchiveStatusAction.archiveConversation(conversationId); + + final Runnable undoRunnable = new Runnable() { + @Override + public void run() { + UpdateConversationArchiveStatusAction.unarchiveConversation(conversationId); + } + }; + final String message = getResources().getString(R.string.archived_toast_message, 1); + UiUtils.showSnackBar(getContext(), getRootView(), message, undoRunnable, + SnackBar.Action.SNACK_BAR_UNDO, + mHostInterface.getSnackBarInteractions()); + } + + private void setShortAndLongClickable(final boolean clickable) { + setClickable(clickable); + setLongClickable(clickable); + } + + private void resetAnimatingState() { + mAnimatingCount = 0; + setShortAndLongClickable(true); + setSwipeTranslationX(0); + } + + /** + * Notifies this view that it is undergoing animation. This view should disable its click + * targets. + * + * The animating counter is used to reset the swipe controller when the counter becomes 0. A + * positive counter also makes the view not clickable. + */ + public final void setAnimating(final boolean animating) { + final int oldAnimatingCount = mAnimatingCount; + if (animating) { + mAnimatingCount++; + } else { + mAnimatingCount--; + if (mAnimatingCount < 0) { + mAnimatingCount = 0; + } + } + + if (mAnimatingCount == 0) { + // New count is 0. All animations ended. + setShortAndLongClickable(true); + } else if (oldAnimatingCount == 0) { + // New count is > 0. Waiting for some animations to end. + setShortAndLongClickable(false); + } + } + + public boolean isAnimating() { + return mAnimatingCount > 0; + } + + /** + * {@inheritDoc} from OnClickListener + */ + @Override + public void onClick(final View v) { + processClick(v, false); + } + + /** + * {@inheritDoc} from OnLongClickListener + */ + @Override + public boolean onLongClick(final View v) { + return processClick(v, true); + } + + private boolean processClick(final View v, final boolean isLongClick) { + Assert.isTrue(v == mSwipeableContainer || v == mContactIconView || v == mImagePreviewView); + Assert.notNull(mData.getName()); + + if (mHostInterface != null) { + mHostInterface.onConversationClicked(mData, isLongClick, this); + return true; + } + return false; + } + + public View getSwipeableContent() { + return mSwipeableContent; + } + + public View getContactIconView() { + return mContactIconView; + } + + private String getSnippetText() { + String snippetText = mData.getShowDraft() ? + mData.getDraftSnippetText() : mData.getSnippetText(); + final String previewContentType = mData.getShowDraft() ? + mData.getDraftPreviewContentType() : mData.getPreviewContentType(); + if (TextUtils.isEmpty(snippetText)) { + Resources resources = getResources(); + // Use the attachment type as a snippet so the preview doesn't look odd + if (ContentType.isAudioType(previewContentType)) { + snippetText = resources.getString(R.string.conversation_list_snippet_audio_clip); + } else if (ContentType.isImageType(previewContentType)) { + snippetText = resources.getString(R.string.conversation_list_snippet_picture); + } else if (ContentType.isVideoType(previewContentType)) { + snippetText = resources.getString(R.string.conversation_list_snippet_video); + } else if (ContentType.isVCardType(previewContentType)) { + snippetText = resources.getString(R.string.conversation_list_snippet_vcard); + } + } + return snippetText; + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListSwipeHelper.java b/src/com/android/messaging/ui/conversationlist/ConversationListSwipeHelper.java new file mode 100644 index 0000000..4988259 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ConversationListSwipeHelper.java @@ -0,0 +1,462 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversationlist; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.animation.TimeInterpolator; +import android.content.Context; +import android.content.res.Resources; +import android.support.v4.view.ViewCompat; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.RecyclerView.OnItemTouchListener; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; +import com.android.messaging.util.UiUtils; + +/** + * Animation and touch helper class for Conversation List swipe. + */ +public class ConversationListSwipeHelper implements OnItemTouchListener { + private static final int UNIT_SECONDS = 1000; + private static final boolean ANIMATING = true; + + private static final float ERROR_FACTOR_MULTIPLIER = 1.2f; + private static final float PERCENTAGE_OF_WIDTH_TO_DISMISS = 0.4f; + private static final float FLING_PERCENTAGE_OF_WIDTH_TO_DISMISS = 0.05f; + + private static final int SWIPE_DIRECTION_NONE = 0; + private static final int SWIPE_DIRECTION_LEFT = 1; + private static final int SWIPE_DIRECTION_RIGHT = 2; + + private final RecyclerView mRecyclerView; + private final long mDefaultRestoreAnimationDuration; + private final long mDefaultDismissAnimationDuration; + private final long mMaxTranslationAnimationDuration; + private final int mTouchSlop; + private final int mMinimumFlingVelocity; + private final int mMaximumFlingVelocity; + + /* Valid throughout a single gesture. */ + private VelocityTracker mVelocityTracker; + private float mInitialX; + private float mInitialY; + private boolean mIsSwiping; + private ConversationListItemView mListItemView; + + public ConversationListSwipeHelper(final RecyclerView recyclerView) { + mRecyclerView = recyclerView; + + final Context context = mRecyclerView.getContext(); + final Resources res = context.getResources(); + mDefaultRestoreAnimationDuration = res.getInteger(R.integer.swipe_duration_ms); + mDefaultDismissAnimationDuration = res.getInteger(R.integer.swipe_duration_ms); + mMaxTranslationAnimationDuration = res.getInteger(R.integer.swipe_duration_ms); + + final ViewConfiguration viewConfiguration = ViewConfiguration.get(context); + mTouchSlop = viewConfiguration.getScaledPagingTouchSlop(); + mMaximumFlingVelocity = Math.min( + viewConfiguration.getScaledMaximumFlingVelocity(), + res.getInteger(R.integer.swipe_max_fling_velocity_px_per_s)); + mMinimumFlingVelocity = viewConfiguration.getScaledMinimumFlingVelocity(); + } + + @Override + public boolean onInterceptTouchEvent(final RecyclerView recyclerView, final MotionEvent event) { + if (event.getPointerCount() > 1) { + // Ignore subsequent pointers. + return false; + } + + // We are not yet tracking a swipe gesture. Begin detection by spying on + // touch events bubbling down to our children. + final int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: + if (!hasGestureSwipeTarget()) { + onGestureStart(); + + mVelocityTracker.addMovement(event); + mInitialX = event.getX(); + mInitialY = event.getY(); + + final View viewAtPoint = mRecyclerView.findChildViewUnder(mInitialX, mInitialY); + final ConversationListItemView child = (ConversationListItemView) viewAtPoint; + if (viewAtPoint instanceof ConversationListItemView && + child != null && child.isSwipeAnimatable()) { + // Begin detecting swipe on the target for the rest of the gesture. + mListItemView = child; + if (mListItemView.isAnimating()) { + mListItemView = null; + } + } else { + mListItemView = null; + } + } + break; + case MotionEvent.ACTION_MOVE: + if (hasValidGestureSwipeTarget()) { + mVelocityTracker.addMovement(event); + + final int historicalCount = event.getHistorySize(); + // First consume the historical events, then consume the current ones. + for (int i = 0; i < historicalCount + 1; i++) { + float currX; + float currY; + if (i < historicalCount) { + currX = event.getHistoricalX(i); + currY = event.getHistoricalY(i); + } else { + currX = event.getX(); + currY = event.getY(); + } + final float deltaX = currX - mInitialX; + final float deltaY = currY - mInitialY; + final float absDeltaX = Math.abs(deltaX); + final float absDeltaY = Math.abs(deltaY); + + if (!mIsSwiping && absDeltaY > mTouchSlop + && absDeltaY > (ERROR_FACTOR_MULTIPLIER * absDeltaX)) { + // Stop detecting swipe for the remainder of this gesture. + onGestureEnd(); + return false; + } + + if (absDeltaX > mTouchSlop) { + // Swipe detected. Return true so we can handle the gesture in + // onTouchEvent. + mIsSwiping = true; + + // We don't want to suddenly jump the slop distance. + mInitialX = event.getX(); + mInitialY = event.getY(); + + onSwipeGestureStart(mListItemView); + return true; + } + } + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + if (hasGestureSwipeTarget()) { + onGestureEnd(); + } + break; + } + + // Start intercepting touch events from children if we detect a swipe. + return mIsSwiping; + } + + @Override + public void onTouchEvent(final RecyclerView recyclerView, final MotionEvent event) { + // We should only be here if we intercepted the touch due to swipe. + Assert.isTrue(mIsSwiping); + + // We are now tracking a swipe gesture. + mVelocityTracker.addMovement(event); + + final int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_OUTSIDE: + case MotionEvent.ACTION_MOVE: + if (hasValidGestureSwipeTarget()) { + mListItemView.setSwipeTranslationX(event.getX() - mInitialX); + } + break; + case MotionEvent.ACTION_UP: + if (hasValidGestureSwipeTarget()) { + final float maxVelocity = mMaximumFlingVelocity; + mVelocityTracker.computeCurrentVelocity(UNIT_SECONDS, maxVelocity); + final float velocityX = getLastComputedXVelocity(); + + final float translationX = mListItemView.getSwipeTranslationX(); + + int swipeDirection = SWIPE_DIRECTION_NONE; + if (translationX != 0) { + swipeDirection = + translationX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT; + } else if (velocityX != 0) { + swipeDirection = + velocityX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT; + } + + final boolean fastEnough = isTargetSwipedFastEnough(); + final boolean farEnough = isTargetSwipedFarEnough(); + + final boolean shouldDismiss = (fastEnough || farEnough); + + if (shouldDismiss) { + if (fastEnough) { + animateDismiss(mListItemView, velocityX); + } else { + animateDismiss(mListItemView, swipeDirection); + } + } else { + animateRestore(mListItemView, velocityX); + } + + onSwipeGestureEnd(mListItemView, + shouldDismiss ? swipeDirection : SWIPE_DIRECTION_NONE); + } else { + onGestureEnd(); + } + break; + case MotionEvent.ACTION_CANCEL: + if (hasValidGestureSwipeTarget()) { + animateRestore(mListItemView, 0f); + onSwipeGestureEnd(mListItemView, SWIPE_DIRECTION_NONE); + } else { + onGestureEnd(); + } + break; + } + } + + + @Override + public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { + } + + /** + * We have started to intercept a series of touch events. + */ + private void onGestureStart() { + mIsSwiping = false; + // Work around bug in RecyclerView that sends two identical ACTION_DOWN + // events to #onInterceptTouchEvent. + if (mVelocityTracker == null) { + mVelocityTracker = VelocityTracker.obtain(); + } + mVelocityTracker.clear(); + } + + /** + * The series of touch events has been detected as a swipe. + * + * Now that the gesture is a swipe, we will begin translating the view of the + * given viewHolder. + */ + private void onSwipeGestureStart(final ConversationListItemView itemView) { + mRecyclerView.getParent().requestDisallowInterceptTouchEvent(true); + setHardwareAnimatingLayerType(itemView, ANIMATING); + itemView.setAnimating(true); + } + + /** + * The current swipe gesture is complete. + */ + private void onSwipeGestureEnd(final ConversationListItemView itemView, + final int swipeDirection) { + if (swipeDirection == SWIPE_DIRECTION_RIGHT || swipeDirection == SWIPE_DIRECTION_LEFT) { + itemView.onSwipeComplete(); + } + + // Balances out onSwipeGestureStart. + itemView.setAnimating(false); + + onGestureEnd(); + } + + /** + * The series of touch events has ended in an {@link MotionEvent#ACTION_UP} + * or {@link MotionEvent#ACTION_CANCEL}. + */ + private void onGestureEnd() { + mVelocityTracker.recycle(); + mVelocityTracker = null; + mIsSwiping = false; + mListItemView = null; + } + + /** + * A swipe animation has started. + */ + private void onSwipeAnimationStart(final ConversationListItemView itemView) { + // Disallow interactions. + itemView.setAnimating(true); + ViewCompat.setHasTransientState(itemView, true); + setHardwareAnimatingLayerType(itemView, ANIMATING); + } + + /** + * The swipe animation has ended. + */ + private void onSwipeAnimationEnd(final ConversationListItemView itemView) { + // Restore interactions. + itemView.setAnimating(false); + ViewCompat.setHasTransientState(itemView, false); + setHardwareAnimatingLayerType(itemView, !ANIMATING); + } + + /** + * Animate the dismissal of the given item. The given velocityX is taken into consideration for + * the animation duration. Whether the item is dismissed to the left or right is dependent on + * the given velocityX. + */ + private void animateDismiss(final ConversationListItemView itemView, final float velocityX) { + Assert.isTrue(velocityX != 0); + final int direction = velocityX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT; + animateDismiss(itemView, direction, velocityX); + } + + /** + * Animate the dismissal of the given item. The velocityX is assumed to be 0. + */ + private void animateDismiss(final ConversationListItemView itemView, final int swipeDirection) { + animateDismiss(itemView, swipeDirection, 0f); + } + + /** + * Animate the dismissal of the given item. + */ + private void animateDismiss(final ConversationListItemView itemView, + final int swipeDirection, final float velocityX) { + Assert.isTrue(swipeDirection != SWIPE_DIRECTION_NONE); + + onSwipeAnimationStart(itemView); + + final float animateTo = (swipeDirection == SWIPE_DIRECTION_RIGHT) ? + mRecyclerView.getWidth() : -mRecyclerView.getWidth(); + final long duration; + if (velocityX != 0) { + final float deltaX = animateTo - itemView.getSwipeTranslationX(); + duration = calculateTranslationDuration(deltaX, velocityX); + } else { + duration = mDefaultDismissAnimationDuration; + } + + final ObjectAnimator animator = getSwipeTranslationXAnimator( + itemView, animateTo, duration, UiUtils.DEFAULT_INTERPOLATOR); + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + onSwipeAnimationEnd(itemView); + } + }); + animator.start(); + } + + /** + * Animate the bounce back of the given item. + */ + private void animateRestore(final ConversationListItemView itemView, + final float velocityX) { + onSwipeAnimationStart(itemView); + + final float translationX = itemView.getSwipeTranslationX(); + final long duration; + if (velocityX != 0 // Has velocity. + && velocityX > 0 != translationX > 0) { // Right direction. + duration = calculateTranslationDuration(translationX, velocityX); + } else { + duration = mDefaultRestoreAnimationDuration; + } + final ObjectAnimator animator = getSwipeTranslationXAnimator( + itemView, 0f, duration, UiUtils.DEFAULT_INTERPOLATOR); + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + onSwipeAnimationEnd(itemView); + } + }); + animator.start(); + } + + /** + * Create and start an animator that animates the given view's translationX + * from its current value to the value given by animateTo. + */ + private ObjectAnimator getSwipeTranslationXAnimator(final ConversationListItemView itemView, + final float animateTo, final long duration, final TimeInterpolator interpolator) { + final ObjectAnimator animator = + ObjectAnimator.ofFloat(itemView, "swipeTranslationX", animateTo); + animator.setDuration(duration); + animator.setInterpolator(interpolator); + return animator; + } + + /** + * Determine if the swipe has enough velocity to be dismissed. + */ + private boolean isTargetSwipedFastEnough() { + final float velocityX = getLastComputedXVelocity(); + final float velocityY = mVelocityTracker.getYVelocity(); + final float minVelocity = mMinimumFlingVelocity; + final float translationX = mListItemView.getSwipeTranslationX(); + final float width = mListItemView.getWidth(); + return (Math.abs(velocityX) > minVelocity) // Fast enough. + && (Math.abs(velocityX) > Math.abs(velocityY)) // Not unintentional. + && (velocityX > 0) == (translationX > 0) // Right direction. + && Math.abs(translationX) > + FLING_PERCENTAGE_OF_WIDTH_TO_DISMISS * width; // Enough movement. + } + + /** + * Only used during a swipe gesture. Determine if the swipe has enough distance to be + * dismissed. + */ + private boolean isTargetSwipedFarEnough() { + final float velocityX = getLastComputedXVelocity(); + + final float translationX = mListItemView.getSwipeTranslationX(); + final float width = mListItemView.getWidth(); + + return (velocityX >= 0) == (translationX > 0) // Right direction. + && Math.abs(translationX) > + PERCENTAGE_OF_WIDTH_TO_DISMISS * width; // Enough movement. + } + + private long calculateTranslationDuration(final float deltaPosition, final float velocity) { + Assert.isTrue(velocity != 0); + final float durationInSeconds = Math.abs(deltaPosition / velocity); + return Math.min((int) (durationInSeconds * UNIT_SECONDS), mMaxTranslationAnimationDuration); + } + + private boolean hasGestureSwipeTarget() { + return mListItemView != null; + } + + private boolean hasValidGestureSwipeTarget() { + return hasGestureSwipeTarget() && mListItemView.getParent() == mRecyclerView; + } + + /** + * Enable a hardware layer for the it view and build that layer. + */ + private void setHardwareAnimatingLayerType(final ConversationListItemView itemView, + final boolean animating) { + if (animating) { + itemView.setLayerType(View.LAYER_TYPE_HARDWARE, null); + if (itemView.getWindowToken() != null) { + itemView.buildLayer(); + } + } else { + itemView.setLayerType(View.LAYER_TYPE_NONE, null); + } + } + + private float getLastComputedXVelocity() { + return mVelocityTracker.getXVelocity(); + } +} \ No newline at end of file diff --git a/src/com/android/messaging/ui/conversationlist/ForwardMessageActivity.java b/src/com/android/messaging/ui/conversationlist/ForwardMessageActivity.java new file mode 100644 index 0000000..61e3640 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ForwardMessageActivity.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversationlist; + +import android.app.Fragment; +import android.os.Bundle; + +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.ui.BaseBugleActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.conversationlist.ConversationListFragment.ConversationListFragmentHost; +import com.android.messaging.util.Assert; + +/** + * An activity that lets the user forward a SMS/MMS message by picking from a conversation in the + * conversation list. + */ +public class ForwardMessageActivity extends BaseBugleActivity + implements ConversationListFragmentHost { + private MessageData mDraftMessage; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final ConversationListFragment fragment = + ConversationListFragment.createForwardMessageConversationListFragment(); + getFragmentManager().beginTransaction().add(android.R.id.content, fragment).commit(); + mDraftMessage = getIntent().getParcelableExtra(UIIntents.UI_INTENT_EXTRA_DRAFT_DATA); + } + + @Override + public void onAttachFragment(final Fragment fragment) { + Assert.isTrue(fragment instanceof ConversationListFragment); + final ConversationListFragment clf = (ConversationListFragment) fragment; + clf.setHost(this); + } + + @Override + public void onConversationClick(final ConversationListData listData, + final ConversationListItemData conversationListItemData, + final boolean isLongClick, final ConversationListItemView converastionView) { + UIIntents.get().launchConversationActivity( + this, conversationListItemData.getConversationId(), mDraftMessage); + } + + @Override + public void onCreateConversationClick() { + UIIntents.get().launchCreateNewConversationActivity(this, mDraftMessage); + } + + @Override + public boolean isConversationSelected(final String conversationId) { + return false; + } + + @Override + public boolean isSwipeAnimatable() { + return false; + } + + @Override + public boolean isSelectionMode() { + return false; + } +} diff --git a/src/com/android/messaging/ui/conversationlist/MultiSelectActionModeCallback.java b/src/com/android/messaging/ui/conversationlist/MultiSelectActionModeCallback.java new file mode 100644 index 0000000..bfeec51 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/MultiSelectActionModeCallback.java @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversationlist; + +import android.support.v4.util.ArrayMap; +import android.text.TextUtils; +import android.view.ActionMode; +import android.view.ActionMode.Callback; +import android.view.Menu; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.util.Assert; + +import java.util.Collection; +import java.util.HashSet; + +public class MultiSelectActionModeCallback implements Callback { + private HashSet mBlockedSet; + + public interface Listener { + void onActionBarDelete(Collection conversations); + void onActionBarArchive(Iterable conversations, + boolean isToArchive); + void onActionBarNotification(Iterable conversations, + boolean isNotificationOn); + void onActionBarAddContact(final SelectedConversation conversation); + void onActionBarBlock(final SelectedConversation conversation); + void onActionBarHome(); + } + + static class SelectedConversation { + public final String conversationId; + public final long timestamp; + public final String icon; + public final String otherParticipantNormalizedDestination; + public final CharSequence participantLookupKey; + public final boolean isGroup; + public final boolean isArchived; + public final boolean notificationEnabled; + public SelectedConversation(ConversationListItemData data) { + conversationId = data.getConversationId(); + timestamp = data.getTimestamp(); + icon = data.getIcon(); + otherParticipantNormalizedDestination = data.getOtherParticipantNormalizedDestination(); + participantLookupKey = data.getParticipantLookupKey(); + isGroup = data.getIsGroup(); + isArchived = data.getIsArchived(); + notificationEnabled = data.getNotificationEnabled(); + } + } + + private final ArrayMap mSelectedConversations; + + private Listener mListener; + private MenuItem mArchiveMenuItem; + private MenuItem mUnarchiveMenuItem; + private MenuItem mAddContactMenuItem; + private MenuItem mBlockMenuItem; + private MenuItem mNotificationOnMenuItem; + private MenuItem mNotificationOffMenuItem; + private boolean mHasInflated; + + public MultiSelectActionModeCallback(final Listener listener) { + mListener = listener; + mSelectedConversations = new ArrayMap<>(); + + } + + @Override + public boolean onCreateActionMode(ActionMode actionMode, Menu menu) { + actionMode.getMenuInflater().inflate(R.menu.conversation_list_fragment_select_menu, menu); + mArchiveMenuItem = menu.findItem(R.id.action_archive); + mUnarchiveMenuItem = menu.findItem(R.id.action_unarchive); + mAddContactMenuItem = menu.findItem(R.id.action_add_contact); + mBlockMenuItem = menu.findItem(R.id.action_block); + mNotificationOffMenuItem = menu.findItem(R.id.action_notification_off); + mNotificationOnMenuItem = menu.findItem(R.id.action_notification_on); + mHasInflated = true; + updateActionIconsVisiblity(); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) { + return true; + } + + @Override + public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) { + switch(menuItem.getItemId()) { + case R.id.action_delete: + mListener.onActionBarDelete(mSelectedConversations.values()); + return true; + case R.id.action_archive: + mListener.onActionBarArchive(mSelectedConversations.values(), true); + return true; + case R.id.action_unarchive: + mListener.onActionBarArchive(mSelectedConversations.values(), false); + return true; + case R.id.action_notification_off: + mListener.onActionBarNotification(mSelectedConversations.values(), false); + return true; + case R.id.action_notification_on: + mListener.onActionBarNotification(mSelectedConversations.values(), true); + return true; + case R.id.action_add_contact: + Assert.isTrue(mSelectedConversations.size() == 1); + mListener.onActionBarAddContact(mSelectedConversations.valueAt(0)); + return true; + case R.id.action_block: + Assert.isTrue(mSelectedConversations.size() == 1); + mListener.onActionBarBlock(mSelectedConversations.valueAt(0)); + return true; + case android.R.id.home: + mListener.onActionBarHome(); + return true; + default: + return false; + } + } + + @Override + public void onDestroyActionMode(ActionMode actionMode) { + mListener = null; + mSelectedConversations.clear(); + mHasInflated = false; + } + + public void toggleSelect(final ConversationListData listData, + final ConversationListItemData conversationListItemData) { + Assert.notNull(conversationListItemData); + mBlockedSet = listData.getBlockedParticipants(); + final String id = conversationListItemData.getConversationId(); + if (mSelectedConversations.containsKey(id)) { + mSelectedConversations.remove(id); + } else { + mSelectedConversations.put(id, new SelectedConversation(conversationListItemData)); + } + + if (mSelectedConversations.isEmpty()) { + mListener.onActionBarHome(); + } else { + updateActionIconsVisiblity(); + } + } + + public boolean isSelected(final String selectedId) { + return mSelectedConversations.containsKey(selectedId); + } + + private void updateActionIconsVisiblity() { + if (!mHasInflated) { + return; + } + + if (mSelectedConversations.size() == 1) { + final SelectedConversation conversation = mSelectedConversations.valueAt(0); + // The look up key is a key given to us by contacts app, so if we have a look up key, + // we know that the participant is already in contacts. + final boolean isInContacts = !TextUtils.isEmpty(conversation.participantLookupKey); + mAddContactMenuItem.setVisible(!conversation.isGroup && !isInContacts); + // ParticipantNormalizedDestination is always null for group conversations. + final String otherParticipant = conversation.otherParticipantNormalizedDestination; + mBlockMenuItem.setVisible(otherParticipant != null + && !mBlockedSet.contains(otherParticipant)); + } else { + mBlockMenuItem.setVisible(false); + mAddContactMenuItem.setVisible(false); + } + + boolean hasCurrentlyArchived = false; + boolean hasCurrentlyUnarchived = false; + boolean hasCurrentlyOnNotification = false; + boolean hasCurrentlyOffNotification = false; + final Iterable conversations = mSelectedConversations.values(); + for (final SelectedConversation conversation : conversations) { + if (conversation.notificationEnabled) { + hasCurrentlyOnNotification = true; + } else { + hasCurrentlyOffNotification = true; + } + + if (conversation.isArchived) { + hasCurrentlyArchived = true; + } else { + hasCurrentlyUnarchived = true; + } + + // If we found at least one of each example we don't need to keep looping. + if (hasCurrentlyOffNotification && hasCurrentlyOnNotification && + hasCurrentlyArchived && hasCurrentlyUnarchived) { + break; + } + } + // If we have notification off conversations we show on button, if we have notification on + // conversation we show off button. We can show both if we have a mixture. + mNotificationOffMenuItem.setVisible(hasCurrentlyOnNotification); + mNotificationOnMenuItem.setVisible(hasCurrentlyOffNotification); + + mArchiveMenuItem.setVisible(hasCurrentlyUnarchived); + mUnarchiveMenuItem.setVisible(hasCurrentlyArchived); + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ShareIntentActivity.java b/src/com/android/messaging/ui/conversationlist/ShareIntentActivity.java new file mode 100644 index 0000000..ef7fcef --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ShareIntentActivity.java @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversationlist; + +import android.app.Fragment; +import android.content.ContentResolver; +import android.content.Intent; +import android.media.MediaMetadataRetriever; +import android.net.Uri; +import android.os.Bundle; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.PendingAttachmentData; +import com.android.messaging.ui.BaseBugleActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.MediaMetadataRetrieverWrapper; + +import java.io.IOException; +import java.util.ArrayList; + +public class ShareIntentActivity extends BaseBugleActivity implements + ShareIntentFragment.HostInterface { + + private MessageData mDraftMessage; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + final Intent intent = getIntent(); + if (Intent.ACTION_SEND.equals(intent.getAction()) && + (!TextUtils.isEmpty(intent.getStringExtra("address")) || + !TextUtils.isEmpty(intent.getStringExtra(Intent.EXTRA_EMAIL)))) { + // This is really more like a SENDTO intent because a destination is supplied. + // It's coming through the SEND intent because that's the intent that is used + // when invoking the chooser with Intent.createChooser(). + final Intent convIntent = UIIntents.get().getLaunchConversationActivityIntent(this); + // Copy the important items from the original intent to the new intent. + convIntent.putExtras(intent); + convIntent.setAction(Intent.ACTION_SENDTO); + convIntent.setDataAndType(intent.getData(), intent.getType()); + // We have to fire off the intent and finish before trying to show the fragment, + // otherwise we get some flashing. + startActivity(convIntent); + finish(); + return; + } + new ShareIntentFragment().show(getFragmentManager(), "ShareIntentFragment"); + } + + @Override + public void onAttachFragment(final Fragment fragment) { + final Intent intent = getIntent(); + final String action = intent.getAction(); + if (Intent.ACTION_SEND.equals(action)) { + final Uri contentUri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM); + final String contentType = extractContentType(contentUri, intent.getType()); + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) { + LogUtil.d(LogUtil.BUGLE_TAG, String.format( + "onAttachFragment: contentUri=%s, intent.getType()=%s, inferredType=%s", + contentUri, intent.getType(), contentType)); + } + if (ContentType.TEXT_PLAIN.equals(contentType)) { + final String sharedText = intent.getStringExtra(Intent.EXTRA_TEXT); + if (sharedText != null) { + mDraftMessage = MessageData.createSharedMessage(sharedText); + } else { + mDraftMessage = null; + } + } else if (ContentType.isImageType(contentType) || + ContentType.isVCardType(contentType) || + ContentType.isAudioType(contentType) || + ContentType.isVideoType(contentType)) { + if (contentUri != null) { + mDraftMessage = MessageData.createSharedMessage(null); + addSharedImagePartToDraft(contentType, contentUri); + } else { + mDraftMessage = null; + } + } else { + // Unsupported content type. + Assert.fail("Unsupported shared content type for " + contentUri + ": " + contentType + + " (" + intent.getType() + ")"); + } + } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { + final String contentType = intent.getType(); + if (ContentType.isImageType(contentType)) { + // Handle sharing multiple images. + final ArrayList imageUris = intent.getParcelableArrayListExtra( + Intent.EXTRA_STREAM); + if (imageUris != null && imageUris.size() > 0) { + mDraftMessage = MessageData.createSharedMessage(null); + for (final Uri imageUri : imageUris) { + final String actualContentType = extractContentType(imageUri, contentType); + addSharedImagePartToDraft(actualContentType, imageUri); + } + } else { + mDraftMessage = null; + } + } else { + // Unsupported content type. + Assert.fail("Unsupported shared content type: " + contentType); + } + } else { + // Unsupported action. + Assert.fail("Unsupported action type for sharing: " + action); + } + } + + private static String extractContentType(final Uri uri, final String contentType) { + if (uri == null) { + return contentType; + } + // First try looking at file extension. This is less reliable in some ways but it's + // recommended by + // https://developer.android.com/training/secure-file-sharing/retrieve-info.html + // Some implementations of MediaMetadataRetriever get things horribly + // wrong for common formats such as jpeg (reports as video/ffmpeg) + final ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver(); + final String typeFromExtension = resolver.getType(uri); + if (typeFromExtension != null) { + return typeFromExtension; + } + final MediaMetadataRetrieverWrapper retriever = new MediaMetadataRetrieverWrapper(); + try { + retriever.setDataSource(uri); + final String extractedType = retriever.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_MIMETYPE); + if (extractedType != null) { + return extractedType; + } + } catch (final IOException e) { + LogUtil.i(LogUtil.BUGLE_TAG, "Could not determine type of " + uri, e); + } finally { + retriever.release(); + } + return contentType; + } + + private void addSharedImagePartToDraft(final String contentType, final Uri imageUri) { + mDraftMessage.addPart(PendingAttachmentData.createPendingAttachmentData(contentType, + imageUri)); + } + + @Override + public void onConversationClick(final ConversationListItemData conversationListItemData) { + UIIntents.get().launchConversationActivity( + this, conversationListItemData.getConversationId(), mDraftMessage); + finish(); + } + + @Override + public void onCreateConversationClick() { + UIIntents.get().launchCreateNewConversationActivity(this, mDraftMessage); + finish(); + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ShareIntentAdapter.java b/src/com/android/messaging/ui/conversationlist/ShareIntentAdapter.java new file mode 100644 index 0000000..e894145 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ShareIntentAdapter.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversationlist; + +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.PersonItemData; +import com.android.messaging.ui.CursorRecyclerAdapter; +import com.android.messaging.ui.PersonItemView; +import com.android.messaging.ui.PersonItemView.PersonItemViewListener; +import com.android.messaging.util.PhoneUtils; + +/** + * Turn conversation rows into PeopleItemViews + */ +public class ShareIntentAdapter + extends CursorRecyclerAdapter { + + public interface HostInterface { + void onConversationClicked(final ConversationListItemData conversationListItemData); + } + + private final HostInterface mHostInterface; + + public ShareIntentAdapter(final Context context, final Cursor cursor, + final HostInterface hostInterface) { + super(context, cursor, 0); + mHostInterface = hostInterface; + setHasStableIds(true); + } + + @Override + public void bindViewHolder(final ShareIntentViewHolder holder, final Context context, + final Cursor cursor) { + holder.bind(cursor); + } + + @Override + public ShareIntentViewHolder createViewHolder(final Context context, + final ViewGroup parent, final int viewType) { + final PersonItemView itemView = (PersonItemView) LayoutInflater.from(context).inflate( + R.layout.people_list_item_view, null); + return new ShareIntentViewHolder(itemView); + } + + /** + * Holds a PersonItemView and keeps it synced with a ConversationListItemData. + */ + public class ShareIntentViewHolder extends RecyclerView.ViewHolder implements + PersonItemView.PersonItemViewListener { + private final ConversationListItemData mData = new ConversationListItemData(); + private final PersonItemData mItemData = new PersonItemData() { + @Override + public Uri getAvatarUri() { + return mData.getIcon() == null ? null : Uri.parse(mData.getIcon()); + } + + @Override + public String getDisplayName() { + return mData.getName(); + } + + @Override + public String getDetails() { + final String conversationName = mData.getName(); + final String conversationPhone = PhoneUtils.getDefault().formatForDisplay( + mData.getOtherParticipantNormalizedDestination()); + if (conversationPhone == null || conversationPhone.equals(conversationName)) { + return null; + } + return conversationPhone; + } + + @Override + public Intent getClickIntent() { + return null; + } + + @Override + public long getContactId() { + return ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED; + } + + @Override + public String getLookupKey() { + return null; + } + + @Override + public String getNormalizedDestination() { + return null; + } + }; + + public ShareIntentViewHolder(final PersonItemView itemView) { + super(itemView); + itemView.setListener(this); + } + + public void bind(Cursor cursor) { + mData.bind(cursor); + ((PersonItemView) itemView).bind(mItemData); + } + + @Override + public void onPersonClicked(PersonItemData data) { + mHostInterface.onConversationClicked(mData); + } + + @Override + public boolean onPersonLongClicked(PersonItemData data) { + return false; + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ShareIntentFragment.java b/src/com/android/messaging/ui/conversationlist/ShareIntentFragment.java new file mode 100644 index 0000000..bc549ea --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ShareIntentFragment.java @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversationlist; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.AlertDialog.Builder; +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.database.Cursor; +import android.os.Bundle; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener; +import com.android.messaging.ui.ListEmptyView; +import com.android.messaging.datamodel.DataModel; + +/** + * Allow user to pick conversation to which an incoming attachment will be shared. + */ +public class ShareIntentFragment extends DialogFragment implements ConversationListDataListener, + ShareIntentAdapter.HostInterface { + public static final String HIDE_NEW_CONVERSATION_BUTTON_KEY = "hide_conv_button_key"; + + public interface HostInterface { + public void onConversationClick(final ConversationListItemData conversationListItemData); + public void onCreateConversationClick(); + } + + private final Binding mListBinding = BindingBase.createBinding(this); + private RecyclerView mRecyclerView; + private ListEmptyView mEmptyListMessageView; + private ShareIntentAdapter mAdapter; + private HostInterface mHost; + private boolean mDismissed; + + /** + * {@inheritDoc} from Fragment + */ + @Override + public Dialog onCreateDialog(final Bundle bundle) { + final Activity activity = getActivity(); + final LayoutInflater inflater = activity.getLayoutInflater(); + View view = inflater.inflate(R.layout.share_intent_conversation_list_view, null); + mEmptyListMessageView = (ListEmptyView) view.findViewById(R.id.no_conversations_view); + mEmptyListMessageView.setImageHint(R.drawable.ic_oobe_conv_list); + // The default behavior for default layout param generation by LinearLayoutManager is to + // provide width and height of WRAP_CONTENT, but this is not desirable for + // ShareIntentFragment; the view in each row should be a width of MATCH_PARENT so that + // the entire row is tappable. + final LinearLayoutManager manager = new LinearLayoutManager(activity) { + @Override + public RecyclerView.LayoutParams generateDefaultLayoutParams() { + return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + } + }; + mListBinding.getData().init(getLoaderManager(), mListBinding); + mAdapter = new ShareIntentAdapter(activity, null, this); + mRecyclerView = (RecyclerView) view.findViewById(android.R.id.list); + mRecyclerView.setLayoutManager(manager); + mRecyclerView.setHasFixedSize(true); + mRecyclerView.setAdapter(mAdapter); + final Builder dialogBuilder = new AlertDialog.Builder(activity) + .setView(view) + .setTitle(R.string.share_intent_activity_label); + + final Bundle arguments = getArguments(); + if (arguments == null || !arguments.getBoolean(HIDE_NEW_CONVERSATION_BUTTON_KEY)) { + dialogBuilder.setPositiveButton(R.string.share_new_message, new OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + mDismissed = true; + mHost.onCreateConversationClick(); + } + }); + } + return dialogBuilder.setNegativeButton(R.string.share_cancel, null) + .create(); + } + + @Override + public void onDismiss(DialogInterface dialog) { + if (!mDismissed) { + final Activity activity = getActivity(); + if (activity != null) { + activity.finish(); + } + } + } + + /** + * {@inheritDoc} from Fragment + */ + @Override + public void onDestroy() { + super.onDestroy(); + mListBinding.unbind(); + } + + @Override + public void onAttach(final Activity activity) { + super.onAttach(activity); + if (activity instanceof HostInterface) { + mHost = (HostInterface) activity; + } + mListBinding.bind(DataModel.get().createConversationListData(activity, this, false)); + } + + @Override + public void onConversationListCursorUpdated(final ConversationListData data, + final Cursor cursor) { + mListBinding.ensureBound(data); + mAdapter.swapCursor(cursor); + updateEmptyListUi(cursor == null || cursor.getCount() == 0); + } + + /** + * {@inheritDoc} from SharIntentItemView.HostInterface + */ + @Override + public void onConversationClicked(final ConversationListItemData conversationListItemData) { + mHost.onConversationClick(conversationListItemData); + } + + // Show and hide empty list UI as needed with appropriate text based on view specifics + private void updateEmptyListUi(final boolean isEmpty) { + if (isEmpty) { + mEmptyListMessageView.setTextHint(R.string.conversation_list_empty_text); + mEmptyListMessageView.setVisibility(View.VISIBLE); + } else { + mEmptyListMessageView.setVisibility(View.GONE); + } + } + + @Override + public void setBlockedParticipantsAvailable(boolean blockedAvailable) { + } +} diff --git a/src/com/android/messaging/ui/conversationsettings/CopyContactDetailDialog.java b/src/com/android/messaging/ui/conversationsettings/CopyContactDetailDialog.java new file mode 100644 index 0000000..d727001 --- /dev/null +++ b/src/com/android/messaging/ui/conversationsettings/CopyContactDetailDialog.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversationsettings; + +import android.app.AlertDialog; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.DialogInterface; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.util.AccessibilityUtil; + +public class CopyContactDetailDialog implements DialogInterface.OnClickListener { + + private final Context mContext; + private final String mContactDetail; + + public CopyContactDetailDialog(final Context context, final String contactDetail) { + mContext = context; + mContactDetail = contactDetail; + } + + public void show() { + new AlertDialog.Builder(mContext) + .setView(createBodyView()) + .setTitle(R.string.copy_to_clipboard_dialog_title) + .setPositiveButton(R.string.copy_to_clipboard, this) + .show(); + } + + @Override + public void onClick(final DialogInterface dialog, final int which) { + final ClipboardManager clipboard = + (ClipboardManager) mContext.getSystemService(Context.CLIPBOARD_SERVICE); + clipboard.setPrimaryClip(ClipData.newPlainText(null /* label */, mContactDetail)); + } + + private View createBodyView() { + LayoutInflater inflater = (LayoutInflater) mContext + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + TextView textView = (TextView) inflater.inflate(R.layout.copy_contact_dialog_view, null, + false); + textView.setText(mContactDetail); + final String vocalizedDisplayName = AccessibilityUtil.getVocalizedPhoneNumber( + mContext.getResources(), mContactDetail); + textView.setContentDescription(vocalizedDisplayName); + return textView; + } +} diff --git a/src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsActivity.java b/src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsActivity.java new file mode 100644 index 0000000..f017328 --- /dev/null +++ b/src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsActivity.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversationsettings; + +import android.app.Fragment; +import android.os.Bundle; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; + +/** + * Shows a list of participants in a conversation. + */ +public class PeopleAndOptionsActivity extends BugleActionBarActivity { + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.people_and_options_activity); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + @Override + public void onAttachFragment(final Fragment fragment) { + if (fragment instanceof PeopleAndOptionsFragment) { + final String conversationId = + getIntent().getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); + Assert.notNull(conversationId); + final PeopleAndOptionsFragment peopleAndOptionsFragment = + (PeopleAndOptionsFragment) fragment; + peopleAndOptionsFragment.setConversationId(conversationId); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + // Treat the home press as back press so that when we go back to + // ConversationActivity, it doesn't lose its original intent (conversation id etc.) + onBackPressed(); + return true; + + default: + return super.onOptionsItemSelected(item); + } + } +} diff --git a/src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsFragment.java b/src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsFragment.java new file mode 100644 index 0000000..b86d952 --- /dev/null +++ b/src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsFragment.java @@ -0,0 +1,329 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversationsettings; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Fragment; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.res.Resources; +import android.database.Cursor; +import android.media.RingtoneManager; +import android.os.Bundle; +import android.os.Parcelable; +import android.provider.Settings; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.BaseAdapter; +import android.widget.ListView; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.ParticipantListItemData; +import com.android.messaging.datamodel.data.PeopleAndOptionsData; +import com.android.messaging.datamodel.data.PeopleAndOptionsData.PeopleAndOptionsDataListener; +import com.android.messaging.datamodel.data.PeopleOptionsItemData; +import com.android.messaging.datamodel.data.PersonItemData; +import com.android.messaging.ui.CompositeAdapter; +import com.android.messaging.ui.PersonItemView; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.conversation.ConversationActivity; +import com.android.messaging.util.Assert; + +import java.util.ArrayList; +import java.util.List; + +/** + * Shows a list of participants of a conversation and displays options. + */ +public class PeopleAndOptionsFragment extends Fragment + implements PeopleAndOptionsDataListener, PeopleOptionsItemView.HostInterface { + private ListView mListView; + private OptionsListAdapter mOptionsListAdapter; + private PeopleListAdapter mPeopleListAdapter; + private final Binding mBinding = + BindingBase.createBinding(this); + + private static final int REQUEST_CODE_RINGTONE_PICKER = 1000; + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mBinding.getData().init(getLoaderManager(), mBinding); + } + + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final View view = inflater.inflate(R.layout.people_and_options_fragment, container, false); + mListView = (ListView) view.findViewById(android.R.id.list); + mPeopleListAdapter = new PeopleListAdapter(getActivity()); + mOptionsListAdapter = new OptionsListAdapter(); + final CompositeAdapter compositeAdapter = new CompositeAdapter(getActivity()); + compositeAdapter.addPartition(new PeopleAndOptionsPartition(mOptionsListAdapter, + R.string.general_settings_title, false)); + compositeAdapter.addPartition(new PeopleAndOptionsPartition(mPeopleListAdapter, + R.string.participant_list_title, true)); + mListView.setAdapter(compositeAdapter); + return view; + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_CODE_RINGTONE_PICKER) { + final Parcelable pick = data.getParcelableExtra( + RingtoneManager.EXTRA_RINGTONE_PICKED_URI); + final String pickedUri = pick == null ? "" : pick.toString(); + mBinding.getData().setConversationNotificationSound(mBinding, pickedUri); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + mBinding.unbind(); + } + + public void setConversationId(final String conversationId) { + Assert.isTrue(getView() == null); + Assert.notNull(conversationId); + mBinding.bind(DataModel.get().createPeopleAndOptionsData(conversationId, getActivity(), + this)); + } + + @Override + public void onOptionsCursorUpdated(final PeopleAndOptionsData data, final Cursor cursor) { + Assert.isTrue(cursor == null || cursor.getCount() == 1); + mBinding.ensureBound(data); + mOptionsListAdapter.swapCursor(cursor); + } + + @Override + public void onParticipantsListLoaded(final PeopleAndOptionsData data, + final List participants) { + mBinding.ensureBound(data); + mPeopleListAdapter.updateParticipants(participants); + final ParticipantData otherParticipant = participants.size() == 1 ? + participants.get(0) : null; + mOptionsListAdapter.setOtherParticipant(otherParticipant); + } + + @Override + public void onOptionsItemViewClicked(final PeopleOptionsItemData item, + final boolean isChecked) { + switch (item.getItemId()) { + case PeopleOptionsItemData.SETTING_NOTIFICATION_ENABLED: + mBinding.getData().enableConversationNotifications(mBinding, isChecked); + break; + + case PeopleOptionsItemData.SETTING_NOTIFICATION_SOUND_URI: + final Intent ringtonePickerIntent = UIIntents.get().getRingtonePickerIntent( + getString(R.string.notification_sound_pref_title), + item.getRingtoneUri(), Settings.System.DEFAULT_NOTIFICATION_URI, + RingtoneManager.TYPE_NOTIFICATION); + startActivityForResult(ringtonePickerIntent, REQUEST_CODE_RINGTONE_PICKER); + break; + + case PeopleOptionsItemData.SETTING_NOTIFICATION_VIBRATION: + mBinding.getData().enableConversationNotificationVibration(mBinding, + isChecked); + break; + + case PeopleOptionsItemData.SETTING_BLOCKED: + if (item.getOtherParticipant().isBlocked()) { + mBinding.getData().setDestinationBlocked(mBinding, false); + break; + } + final Resources res = getResources(); + final Activity activity = getActivity(); + new AlertDialog.Builder(activity) + .setTitle(res.getString(R.string.block_confirmation_title, + item.getOtherParticipant().getDisplayDestination())) + .setMessage(res.getString(R.string.block_confirmation_message)) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface arg0, int arg1) { + mBinding.getData().setDestinationBlocked(mBinding, true); + activity.setResult(ConversationActivity.FINISH_RESULT_CODE); + activity.finish(); + } + }) + .create() + .show(); + break; + } + } + + /** + * A simple adapter that takes a conversation metadata cursor and binds + * PeopleAndOptionsItemViews to individual COLUMNS of the first cursor record. (Note + * that this is not a CursorAdapter because it treats individual columns of the cursor as + * separate options to display for the conversation, e.g. notification settings). + */ + private class OptionsListAdapter extends BaseAdapter { + private Cursor mOptionsCursor; + private ParticipantData mOtherParticipantData; + + public Cursor swapCursor(final Cursor newCursor) { + final Cursor oldCursor = mOptionsCursor; + if (newCursor != oldCursor) { + mOptionsCursor = newCursor; + notifyDataSetChanged(); + } + return oldCursor; + } + + public void setOtherParticipant(final ParticipantData participantData) { + if (mOtherParticipantData != participantData) { + mOtherParticipantData = participantData; + notifyDataSetChanged(); + } + } + + @Override + public int getCount() { + int count = PeopleOptionsItemData.SETTINGS_COUNT; + if (mOtherParticipantData == null) { + count--; + } + return mOptionsCursor == null ? 0 : count; + } + + @Override + public Object getItem(final int position) { + return null; + } + + @Override + public long getItemId(final int position) { + return 0; + } + + @Override + public View getView(final int position, final View convertView, final ViewGroup parent) { + final PeopleOptionsItemView itemView; + if (convertView != null && convertView instanceof PeopleOptionsItemView) { + itemView = (PeopleOptionsItemView) convertView; + } else { + final LayoutInflater inflater = (LayoutInflater) getActivity() + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + itemView = (PeopleOptionsItemView) + inflater.inflate(R.layout.people_options_item_view, parent, false); + } + mOptionsCursor.moveToFirst(); + itemView.bind(mOptionsCursor, position, mOtherParticipantData, + PeopleAndOptionsFragment.this); + return itemView; + } + } + + /** + * An adapter that takes a list of ParticipantData and displays them as a list of + * ParticipantListItemViews. + */ + private class PeopleListAdapter extends ArrayAdapter { + public PeopleListAdapter(final Context context) { + super(context, R.layout.people_list_item_view, new ArrayList()); + } + + public void updateParticipants(final List newList) { + clear(); + addAll(newList); + notifyDataSetChanged(); + } + + @Override + public View getView(final int position, final View convertView, final ViewGroup parent) { + PersonItemView itemView; + final ParticipantData item = getItem(position); + if (convertView != null && convertView instanceof PersonItemView) { + itemView = (PersonItemView) convertView; + } else { + final LayoutInflater inflater = (LayoutInflater) getContext() + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + itemView = (PersonItemView) inflater.inflate(R.layout.people_list_item_view, parent, + false); + } + final ParticipantListItemData itemData = + DataModel.get().createParticipantListItemData(item); + itemView.bind(itemData); + + // Any click on the row should have the same effect as clicking the avatar icon + final PersonItemView itemViewClosure = itemView; + itemView.setListener(new PersonItemView.PersonItemViewListener() { + @Override + public void onPersonClicked(final PersonItemData data) { + itemViewClosure.performClickOnAvatar(); + } + + @Override + public boolean onPersonLongClicked(final PersonItemData data) { + if (mBinding.isBound()) { + final CopyContactDetailDialog dialog = new CopyContactDetailDialog( + getContext(), data.getDetails()); + dialog.show(); + return true; + } + return false; + } + }); + return itemView; + } + } + + /** + * Represents a partition/section in the People & Options list (e.g. "general options" and + * "people in this conversation" sections). + */ + private class PeopleAndOptionsPartition extends CompositeAdapter.Partition { + private final int mHeaderResId; + private final boolean mNeedDivider; + + public PeopleAndOptionsPartition(final BaseAdapter adapter, final int headerResId, + final boolean needDivider) { + super(true /* showIfEmpty */, true /* hasHeader */, adapter); + mHeaderResId = headerResId; + mNeedDivider = needDivider; + } + + @Override + public View getHeaderView(final View convertView, final ViewGroup parentView) { + View view = null; + if (convertView != null && convertView.getId() == R.id.people_and_options_header) { + view = convertView; + } else { + view = LayoutInflater.from(getActivity()).inflate( + R.layout.people_and_options_section_header, parentView, false); + } + final TextView text = (TextView) view.findViewById(R.id.header_text); + final View divider = view.findViewById(R.id.divider); + text.setText(mHeaderResId); + divider.setVisibility(mNeedDivider ? View.VISIBLE : View.GONE); + return view; + } + } +} diff --git a/src/com/android/messaging/ui/conversationsettings/PeopleOptionsItemView.java b/src/com/android/messaging/ui/conversationsettings/PeopleOptionsItemView.java new file mode 100644 index 0000000..42ecfeb --- /dev/null +++ b/src/com/android/messaging/ui/conversationsettings/PeopleOptionsItemView.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversationsettings; + +import android.content.Context; +import android.database.Cursor; +import android.support.v7.widget.SwitchCompat; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.PeopleOptionsItemData; +import com.android.messaging.util.Assert; + +/** + * The view for a single entry in the options section of people & options activity. + */ +public class PeopleOptionsItemView extends LinearLayout { + /** + * Implemented by the host of this view that handles options click event. + */ + public interface HostInterface { + void onOptionsItemViewClicked(PeopleOptionsItemData item, boolean isChecked); + } + + private TextView mTitle; + private TextView mSubtitle; + private SwitchCompat mSwitch; + private final PeopleOptionsItemData mData; + private HostInterface mHostInterface; + + public PeopleOptionsItemView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mData = DataModel.get().createPeopleOptionsItemData(context); + } + + @Override + protected void onFinishInflate () { + mTitle = (TextView) findViewById(R.id.title); + mSubtitle = (TextView) findViewById(R.id.subtitle); + mSwitch = (SwitchCompat) findViewById(R.id.switch_button); + setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View v) { + mHostInterface.onOptionsItemViewClicked(mData, !mData.getChecked()); + } + }); + } + + public void bind(final Cursor cursor, final int columnIndex, ParticipantData otherParticipant, + final HostInterface hostInterface) { + Assert.isTrue(columnIndex < PeopleOptionsItemData.SETTINGS_COUNT && columnIndex >= 0); + mData.bind(cursor, otherParticipant, columnIndex); + mHostInterface = hostInterface; + + mTitle.setText(mData.getTitle()); + final String subtitle = mData.getSubtitle(); + if (TextUtils.isEmpty(subtitle)) { + mSubtitle.setVisibility(GONE); + } else { + mSubtitle.setVisibility(VISIBLE); + mSubtitle.setText(subtitle); + } + + if (mData.getCheckable()) { + mSwitch.setVisibility(VISIBLE); + mSwitch.setChecked(mData.getChecked()); + } else { + mSwitch.setVisibility(GONE); + } + + final boolean enabled = mData.getEnabled(); + if (enabled != isEnabled()) { + mTitle.setEnabled(enabled); + mSubtitle.setEnabled(enabled); + mSwitch.setEnabled(enabled); + setAlpha(enabled ? 1.0f : 0.5f); + setEnabled(enabled); + } + } +} diff --git a/src/com/android/messaging/ui/debug/DebugMmsConfigActivity.java b/src/com/android/messaging/ui/debug/DebugMmsConfigActivity.java new file mode 100644 index 0000000..485dcf7 --- /dev/null +++ b/src/com/android/messaging/ui/debug/DebugMmsConfigActivity.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.debug; + +import android.os.Bundle; + +import com.android.messaging.R; +import com.android.messaging.ui.BaseBugleActivity; + +/** + * Show list of all MmsConfig key/value pairs and allow editing. + */ +public class DebugMmsConfigActivity extends BaseBugleActivity { + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.debug_mmsconfig_activity); + } +} diff --git a/src/com/android/messaging/ui/debug/DebugMmsConfigFragment.java b/src/com/android/messaging/ui/debug/DebugMmsConfigFragment.java new file mode 100644 index 0000000..7c54db5 --- /dev/null +++ b/src/com/android/messaging/ui/debug/DebugMmsConfigFragment.java @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.debug; + +import android.app.Fragment; +import android.content.Context; +import android.os.Bundle; +import android.telephony.SubscriptionInfo; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemSelectedListener; +import android.widget.ArrayAdapter; +import android.widget.BaseAdapter; +import android.widget.ListView; +import android.widget.Spinner; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.ui.debug.DebugMmsConfigItemView.MmsConfigItemListener; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Show list of all MmsConfig key/value pairs and allow editing. + */ +public class DebugMmsConfigFragment extends Fragment { + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final View fragmentView = inflater.inflate(R.layout.mms_config_debug_fragment, container, + false); + final ListView listView = (ListView) fragmentView.findViewById(android.R.id.list); + final Spinner spinner = (Spinner) fragmentView.findViewById(R.id.sim_selector); + final Integer[] subIdArray = getActiveSubIds(); + ArrayAdapter spinnerAdapter = new ArrayAdapter(getActivity(), + android.R.layout.simple_spinner_item, subIdArray); + spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinner.setAdapter(spinnerAdapter); + spinner.setOnItemSelectedListener(new OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + listView.setAdapter(new MmsConfigAdapter(getActivity(), subIdArray[position])); + + final int[] mccmnc = PhoneUtils.get(subIdArray[position]).getMccMnc(); + // Set the title with the mcc/mnc + final TextView title = (TextView) fragmentView.findViewById(R.id.sim_title); + title.setText("(" + mccmnc[0] + "/" + mccmnc[1] + ") " + + getActivity().getString(R.string.debug_sub_id_spinner_text)); + } + + @Override + public void onNothingSelected(AdapterView parent) { + } + }); + + return fragmentView; + } + + public static Integer[] getActiveSubIds() { + if (!OsUtil.isAtLeastL_MR1()) { + return new Integer[] { ParticipantData.DEFAULT_SELF_SUB_ID }; + } + final List subRecords = + PhoneUtils.getDefault().toLMr1().getActiveSubscriptionInfoList(); + if (subRecords == null) { + return new Integer[0]; + } + final Integer[] retArray = new Integer[subRecords.size()]; + for (int i = 0; i < subRecords.size(); i++) { + retArray[i] = subRecords.get(i).getSubscriptionId(); + } + return retArray; + } + + private class MmsConfigAdapter extends BaseAdapter implements + DebugMmsConfigItemView.MmsConfigItemListener { + private final LayoutInflater mInflater; + private final List mKeys; + private final MmsConfig mMmsConfig; + + public MmsConfigAdapter(Context context, int subId) { + mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + mMmsConfig = MmsConfig.get(subId); + mKeys = new ArrayList<>(mMmsConfig.keySet()); + Collections.sort(mKeys); + } + + @Override + public View getView(final int position, final View convertView, final ViewGroup parent) { + final DebugMmsConfigItemView view; + if (convertView != null && convertView instanceof DebugMmsConfigItemView) { + view = (DebugMmsConfigItemView) convertView; + } else { + view = (DebugMmsConfigItemView) mInflater.inflate( + R.layout.debug_mmsconfig_item_view, parent, false); + } + final String key = mKeys.get(position); + view.bind(key, + MmsConfig.getKeyType(key), + String.valueOf(mMmsConfig.getValue(key)), + this); + return view; + } + + @Override + public void onValueChanged(String key, String keyType, String value) { + mMmsConfig.update(key, value, keyType); + notifyDataSetChanged(); + } + + @Override + public int getCount() { + return mKeys.size(); + } + + @Override + public Object getItem(int position) { + return mKeys.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + } +} diff --git a/src/com/android/messaging/ui/debug/DebugMmsConfigItemView.java b/src/com/android/messaging/ui/debug/DebugMmsConfigItemView.java new file mode 100644 index 0000000..7b899c0 --- /dev/null +++ b/src/com/android/messaging/ui/debug/DebugMmsConfigItemView.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.debug; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnShowListener; +import android.text.InputType; +import android.util.AttributeSet; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.inputmethod.InputMethodManager; +import android.widget.CompoundButton; +import android.widget.CompoundButton.OnCheckedChangeListener; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.Switch; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.util.LogUtil; + +public class DebugMmsConfigItemView extends LinearLayout implements OnClickListener, + OnCheckedChangeListener, DialogInterface.OnClickListener { + + public interface MmsConfigItemListener { + void onValueChanged(String key, String keyType, String value); + } + + private TextView mTitle; + private TextView mTextValue; + private Switch mSwitch; + private String mKey; + private String mKeyType; + private MmsConfigItemListener mListener; + private EditText mEditText; + + public DebugMmsConfigItemView(Context context, AttributeSet attributeSet) { + super(context, attributeSet); + } + + @Override + protected void onFinishInflate () { + mTitle = (TextView) findViewById(R.id.title); + mTextValue = (TextView) findViewById(R.id.text_value); + mSwitch = (Switch) findViewById(R.id.switch_button); + setOnClickListener(this); + mSwitch.setOnCheckedChangeListener(this); + } + + public void bind(final String key, final String keyType, final String value, + final MmsConfigItemListener listener) { + mListener = listener; + mKey = key; + mKeyType = keyType; + mTitle.setText(key); + + switch (keyType) { + case MmsConfig.KEY_TYPE_BOOL: + mSwitch.setVisibility(View.VISIBLE); + mTextValue.setVisibility(View.GONE); + mSwitch.setChecked(Boolean.valueOf(value)); + break; + case MmsConfig.KEY_TYPE_STRING: + case MmsConfig.KEY_TYPE_INT: + mTextValue.setVisibility(View.VISIBLE); + mSwitch.setVisibility(View.GONE); + mTextValue.setText(value); + break; + default: + mTextValue.setVisibility(View.GONE); + mSwitch.setVisibility(View.GONE); + LogUtil.e(LogUtil.BUGLE_TAG, "Unexpected keytype: " + keyType); + break; + } + } + + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + mListener.onValueChanged(mKey, mKeyType, String.valueOf(isChecked)); + } + + @Override + public void onClick(View v) { + if (MmsConfig.KEY_TYPE_BOOL.equals(mKeyType)) { + return; + } + final Context context = getContext(); + mEditText = new EditText(context); + mEditText.setText(mTextValue.getText()); + mEditText.setFocusable(true); + if (MmsConfig.KEY_TYPE_INT.equals(mKeyType)) { + mEditText.setInputType(InputType.TYPE_CLASS_PHONE); + } else { + mEditText.setInputType(InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + } + final AlertDialog dialog = new AlertDialog.Builder(context) + .setTitle(mKey) + .setView(mEditText) + .setPositiveButton(android.R.string.ok, this) + .setNegativeButton(android.R.string.cancel, null) + .create(); + dialog.setOnShowListener(new OnShowListener() { + @Override + public void onShow(DialogInterface dialog) { + mEditText.requestFocus(); + mEditText.selectAll(); + ((InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE)) + .toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0); + } + }); + dialog.show(); + } + + @Override + public void onClick(DialogInterface dialog, int which) { + mListener.onValueChanged(mKey, mKeyType, mEditText.getText().toString()); + } +} diff --git a/src/com/android/messaging/ui/debug/DebugSmsMmsFromDumpFileDialogFragment.java b/src/com/android/messaging/ui/debug/DebugSmsMmsFromDumpFileDialogFragment.java new file mode 100644 index 0000000..1aa8be3 --- /dev/null +++ b/src/com/android/messaging/ui/debug/DebugSmsMmsFromDumpFileDialogFragment.java @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.debug; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.telephony.SmsMessage; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.action.ReceiveMmsMessageAction; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.receiver.SmsReceiver; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.LogUtil; + +/** + * Class that displays UI for choosing SMS/MMS dump files for debugging + */ +public class DebugSmsMmsFromDumpFileDialogFragment extends DialogFragment { + public static final String APPLICATION_OCTET_STREAM = "application/octet-stream"; + public static final String KEY_DUMP_FILES = "dump_files"; + public static final String KEY_ACTION = "action"; + + public static final String ACTION_LOAD = "load"; + public static final String ACTION_EMAIL = "email"; + + private String[] mDumpFiles; + private String mAction; + + public static DebugSmsMmsFromDumpFileDialogFragment newInstance(final String[] dumpFiles, + final String action) { + final DebugSmsMmsFromDumpFileDialogFragment frag = + new DebugSmsMmsFromDumpFileDialogFragment(); + final Bundle args = new Bundle(); + args.putSerializable(KEY_DUMP_FILES, dumpFiles); + args.putString(KEY_ACTION, action); + frag.setArguments(args); + return frag; + } + + @Override + public Dialog onCreateDialog(final Bundle savedInstanceState) { + final Bundle args = getArguments(); + mDumpFiles = (String[]) args.getSerializable(KEY_DUMP_FILES); + mAction = args.getString(KEY_ACTION); + + final LayoutInflater inflater = getActivity().getLayoutInflater(); + final View layout = inflater.inflate( + R.layout.debug_sms_mms_from_dump_file_dialog, null/*root*/); + final ListView list = (ListView) layout.findViewById(R.id.dump_file_list); + list.setAdapter(new DumpFileListAdapter(getActivity(), mDumpFiles)); + final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + final Resources resources = getResources(); + if (ACTION_LOAD.equals(mAction)) { + builder.setTitle(resources.getString( + R.string.load_sms_mms_from_dump_file_dialog_title)); + } else if (ACTION_EMAIL.equals(mAction)) { + builder.setTitle(resources.getString( + R.string.email_sms_mms_from_dump_file_dialog_title)); + } + builder.setView(layout); + return builder.create(); + } + + private class DumpFileListAdapter extends ArrayAdapter { + public DumpFileListAdapter(final Context context, final String[] dumpFiles) { + super(context, R.layout.sms_mms_dump_file_list_item, dumpFiles); + } + + @Override + public View getView(final int position, final View view, final ViewGroup parent) { + TextView actionItemView; + if (view == null || !(view instanceof TextView)) { + final LayoutInflater inflater = LayoutInflater.from(getContext()); + actionItemView = (TextView) inflater.inflate( + R.layout.sms_mms_dump_file_list_item, parent, false); + } else { + actionItemView = (TextView) view; + } + + final String file = getItem(position); + actionItemView.setText(file); + actionItemView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View view) { + dismiss(); + if (ACTION_LOAD.equals(mAction)) { + receiveFromDumpFile(file); + } else if (ACTION_EMAIL.equals(mAction)) { + emailDumpFile(file); + } + } + }); + return actionItemView; + } + } + + /** + * Load MMS/SMS from the dump file + */ + private void receiveFromDumpFile(final String dumpFileName) { + if (dumpFileName.startsWith(MmsUtils.SMS_DUMP_PREFIX)) { + final SmsMessage[] messages = DebugUtils.retreiveSmsFromDumpFile(dumpFileName); + if (messages != null) { + SmsReceiver.deliverSmsMessages(getActivity(), ParticipantData.DEFAULT_SELF_SUB_ID, + 0, messages); + } else { + LogUtil.e(LogUtil.BUGLE_TAG, + "receiveFromDumpFile: invalid sms dump file " + dumpFileName); + } + } else if (dumpFileName.startsWith(MmsUtils.MMS_DUMP_PREFIX)) { + final byte[] data = MmsUtils.createDebugNotificationInd(dumpFileName); + if (data != null) { + final ReceiveMmsMessageAction action = new ReceiveMmsMessageAction( + ParticipantData.DEFAULT_SELF_SUB_ID, data); + action.start(); + } else { + LogUtil.e(LogUtil.BUGLE_TAG, + "receiveFromDumpFile: invalid mms dump file " + dumpFileName); + } + } else { + LogUtil.e(LogUtil.BUGLE_TAG, + "receiveFromDumpFile: invalid dump file name " + dumpFileName); + } + } + + /** + * Launch email app to send the dump file + */ + private void emailDumpFile(final String file) { + final Resources resources = getResources(); + final String fileLocation = "file://" + + Environment.getExternalStorageDirectory() + "/" + file; + final Intent sharingIntent = new Intent(Intent.ACTION_SEND); + sharingIntent.setType(APPLICATION_OCTET_STREAM); + sharingIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(fileLocation)); + sharingIntent.putExtra(Intent.EXTRA_SUBJECT, + resources.getString(R.string.email_sms_mms_dump_file_subject)); + getActivity().startActivity(Intent.createChooser(sharingIntent, + resources.getString(R.string.email_sms_mms_dump_file_chooser_title))); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/AudioLevelSource.java b/src/com/android/messaging/ui/mediapicker/AudioLevelSource.java new file mode 100644 index 0000000..a211058 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/AudioLevelSource.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.mediapicker; + +import com.google.common.base.Preconditions; + +import javax.annotation.concurrent.ThreadSafe; + +/** + * Keeps track of the speech level as last observed by the recognition + * engine as microphone data flows through it. Can be polled by the UI to + * animate its views. + */ +@ThreadSafe +public class AudioLevelSource { + private volatile int mSpeechLevel; + private volatile Listener mListener; + + public static final int LEVEL_UNKNOWN = -1; + + public interface Listener { + void onSpeechLevel(int speechLevel); + } + + public void setSpeechLevel(int speechLevel) { + Preconditions.checkArgument(speechLevel >= 0 && speechLevel <= 100 || + speechLevel == LEVEL_UNKNOWN); + mSpeechLevel = speechLevel; + maybeNotify(); + } + + public int getSpeechLevel() { + return mSpeechLevel; + } + + public void reset() { + setSpeechLevel(LEVEL_UNKNOWN); + } + + public boolean isValid() { + return mSpeechLevel > 0; + } + + private void maybeNotify() { + final Listener l = mListener; + if (l != null) { + l.onSpeechLevel(mSpeechLevel); + } + } + + public synchronized void setListener(Listener listener) { + mListener = listener; + } + + public synchronized void clearListener(Listener listener) { + if (mListener == listener) { + mListener = null; + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java b/src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java new file mode 100644 index 0000000..5d79293 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker; + +import android.Manifest; +import android.content.pm.PackageManager; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.util.OsUtil; + +/** + * Chooser which allows the user to record audio + */ +class AudioMediaChooser extends MediaChooser implements + AudioRecordView.HostInterface { + private View mEnabledView; + private View mMissingPermissionView; + + AudioMediaChooser(final MediaPicker mediaPicker) { + super(mediaPicker); + } + + @Override + public int getSupportedMediaTypes() { + return MediaPicker.MEDIA_TYPE_AUDIO; + } + + @Override + public int getIconResource() { + return R.drawable.ic_audio_light; + } + + @Override + public int getIconDescriptionResource() { + return R.string.mediapicker_audioChooserDescription; + } + + @Override + public void onAudioRecorded(final MessagePartData item) { + mMediaPicker.dispatchItemsSelected(item, true); + } + + @Override + public void setThemeColor(final int color) { + if (mView != null) { + ((AudioRecordView) mView).setThemeColor(color); + } + } + + @Override + protected View createView(final ViewGroup container) { + final LayoutInflater inflater = getLayoutInflater(); + final AudioRecordView view = (AudioRecordView) inflater.inflate( + R.layout.mediapicker_audio_chooser, + container /* root */, + false /* attachToRoot */); + view.setHostInterface(this); + view.setThemeColor(mMediaPicker.getConversationThemeColor()); + mEnabledView = view.findViewById(R.id.mediapicker_enabled); + mMissingPermissionView = view.findViewById(R.id.missing_permission_view); + return view; + } + + @Override + int getActionBarTitleResId() { + return R.string.mediapicker_audio_title; + } + + @Override + public boolean isHandlingTouch() { + // Whenever the user is in the process of recording audio, we want to allow the user + // to move the finger within the panel without interpreting that as dragging the media + // picker panel. + return ((AudioRecordView) mView).shouldHandleTouch(); + } + + @Override + public void stopTouchHandling() { + ((AudioRecordView) mView).stopTouchHandling(); + } + + @Override + public void onPause() { + super.onPause(); + if (mView != null) { + ((AudioRecordView) mView).onPause(); + } + } + + @Override + protected void setSelected(final boolean selected) { + super.setSelected(selected); + if (selected && !OsUtil.hasRecordAudioPermission()) { + requestRecordAudioPermission(); + } + } + + private void requestRecordAudioPermission() { + mMediaPicker.requestPermissions(new String[] { Manifest.permission.RECORD_AUDIO }, + MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE); + } + + @Override + protected void onRequestPermissionsResult( + final int requestCode, final String permissions[], final int[] grantResults) { + if (requestCode == MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE) { + final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED; + mEnabledView.setVisibility(permissionGranted ? View.VISIBLE : View.GONE); + mMissingPermissionView.setVisibility(permissionGranted ? View.GONE : View.VISIBLE); + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/AudioRecordView.java b/src/com/android/messaging/ui/mediapicker/AudioRecordView.java new file mode 100644 index 0000000..fba493f --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/AudioRecordView.java @@ -0,0 +1,351 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.mediapicker; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.media.MediaRecorder; +import android.net.Uri; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; +import com.android.messaging.datamodel.data.MediaPickerMessagePartData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.MediaUtil; +import com.android.messaging.util.MediaUtil.OnCompletionListener; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.ThreadUtil; +import com.android.messaging.util.UiUtils; +import com.google.common.annotations.VisibleForTesting; + +/** + * Hosts an audio recorder with tap and hold to record functionality. + */ +public class AudioRecordView extends FrameLayout implements + MediaRecorder.OnErrorListener, + MediaRecorder.OnInfoListener { + /** + * An interface that communicates with the hosted AudioRecordView. + */ + public interface HostInterface extends DraftMessageSubscriptionDataProvider { + void onAudioRecorded(final MessagePartData item); + } + + /** The initial state, the user may press and hold to start recording */ + private static final int MODE_IDLE = 1; + + /** The user has pressed the record button and we are playing the sound indicating the + * start of recording session. Don't record yet since we don't want the beeping sound + * to get into the recording. */ + private static final int MODE_STARTING = 2; + + /** When the user is actively recording */ + private static final int MODE_RECORDING = 3; + + /** When the user has finished recording, we need to record for some additional time. */ + private static final int MODE_STOPPING = 4; + + // Bug: 16020175: The framework's MediaRecorder would cut off the ending portion of the + // recorded audio by about half a second. To mitigate this issue, we continue the recording + // for some extra time before stopping it. + private static final int AUDIO_RECORD_ENDING_BUFFER_MILLIS = 500; + + /** + * The minimum duration of any recording. Below this threshold, it will be treated as if the + * user clicked the record button and inform the user to tap and hold to record. + */ + private static final int AUDIO_RECORD_MINIMUM_DURATION_MILLIS = 300; + + // For accessibility, the touchable record button is bigger than the record button visual. + private ImageView mRecordButtonVisual; + private View mRecordButton; + private SoundLevels mSoundLevels; + private TextView mHintTextView; + private PausableChronometer mTimerTextView; + private LevelTrackingMediaRecorder mMediaRecorder; + private long mAudioRecordStartTimeMillis; + + private int mCurrentMode = MODE_IDLE; + private HostInterface mHostInterface; + private int mThemeColor; + + public AudioRecordView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mMediaRecorder = new LevelTrackingMediaRecorder(); + } + + public void setHostInterface(final HostInterface hostInterface) { + mHostInterface = hostInterface; + } + + @VisibleForTesting + public void testSetMediaRecorder(final LevelTrackingMediaRecorder recorder) { + mMediaRecorder = recorder; + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mSoundLevels = (SoundLevels) findViewById(R.id.sound_levels); + mRecordButtonVisual = (ImageView) findViewById(R.id.record_button_visual); + mRecordButton = findViewById(R.id.record_button); + mHintTextView = (TextView) findViewById(R.id.hint_text); + mTimerTextView = (PausableChronometer) findViewById(R.id.timer_text); + mSoundLevels.setLevelSource(mMediaRecorder.getLevelSource()); + mRecordButton.setOnTouchListener(new OnTouchListener() { + @Override + public boolean onTouch(final View v, final MotionEvent event) { + final int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: + onRecordButtonTouchDown(); + + // Don't let the record button handle the down event to let it fall through + // so that we can handle it for the entire panel in onTouchEvent(). This is + // done so that: 1) the user taps on the record button to start recording + // 2) the entire panel owns the touch event so we'd keep recording even + // if the user moves outside the button region. + return false; + } + return false; + } + }); + } + + @Override + public boolean onTouchEvent(final MotionEvent event) { + final int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: + return shouldHandleTouch(); + + case MotionEvent.ACTION_MOVE: + return true; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + return onRecordButtonTouchUp(); + } + return super.onTouchEvent(event); + } + + public void onPause() { + // The conversation draft cannot take any updates when it's paused. Therefore, forcibly + // stop recording on pause. + stopRecording(); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + stopRecording(); + } + + private boolean isRecording() { + return mMediaRecorder.isRecording() && mCurrentMode == MODE_RECORDING; + } + + public boolean shouldHandleTouch() { + return mCurrentMode != MODE_IDLE; + } + + public void stopTouchHandling() { + setMode(MODE_IDLE); + stopRecording(); + } + + private void setMode(final int mode) { + if (mCurrentMode != mode) { + mCurrentMode = mode; + updateVisualState(); + } + } + + private void updateVisualState() { + switch (mCurrentMode) { + case MODE_IDLE: + mHintTextView.setVisibility(VISIBLE); + mHintTextView.setTypeface(null, Typeface.NORMAL); + mTimerTextView.setVisibility(GONE); + mSoundLevels.setEnabled(false); + mTimerTextView.stop(); + break; + + case MODE_RECORDING: + case MODE_STOPPING: + mHintTextView.setVisibility(GONE); + mTimerTextView.setVisibility(VISIBLE); + mSoundLevels.setEnabled(true); + mTimerTextView.restart(); + break; + + case MODE_STARTING: + break; // No-Op. + + default: + Assert.fail("invalid mode for AudioRecordView!"); + break; + } + updateRecordButtonAppearance(); + } + + public void setThemeColor(final int color) { + mThemeColor = color; + updateRecordButtonAppearance(); + } + + private void updateRecordButtonAppearance() { + final Drawable foregroundDrawable = getResources().getDrawable(R.drawable.ic_mp_audio_mic); + final GradientDrawable backgroundDrawable = ((GradientDrawable) getResources() + .getDrawable(R.drawable.audio_record_control_button_background)); + if (isRecording()) { + foregroundDrawable.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_ATOP); + backgroundDrawable.setColor(mThemeColor); + } else { + foregroundDrawable.setColorFilter(mThemeColor, PorterDuff.Mode.SRC_ATOP); + backgroundDrawable.setColor(Color.WHITE); + } + mRecordButtonVisual.setImageDrawable(foregroundDrawable); + mRecordButtonVisual.setBackground(backgroundDrawable); + } + + @VisibleForTesting + boolean onRecordButtonTouchDown() { + if (!mMediaRecorder.isRecording() && mCurrentMode == MODE_IDLE) { + setMode(MODE_STARTING); + playAudioStartSound(new OnCompletionListener() { + @Override + public void onCompletion() { + // Double-check the current mode before recording since the user may have + // lifted finger from the button before the beeping sound is played through. + final int maxSize = MmsConfig.get(mHostInterface.getConversationSelfSubId()) + .getMaxMessageSize(); + if (mCurrentMode == MODE_STARTING && + mMediaRecorder.startRecording(AudioRecordView.this, + AudioRecordView.this, maxSize)) { + setMode(MODE_RECORDING); + } + } + }); + mAudioRecordStartTimeMillis = System.currentTimeMillis(); + return true; + } + return false; + } + + @VisibleForTesting + boolean onRecordButtonTouchUp() { + if (System.currentTimeMillis() - mAudioRecordStartTimeMillis < + AUDIO_RECORD_MINIMUM_DURATION_MILLIS) { + // The recording is too short, bolden the hint text to instruct the user to + // "tap+hold" to record audio. + final Uri outputUri = stopRecording(); + if (outputUri != null) { + SafeAsyncTask.executeOnThreadPool(new Runnable() { + @Override + public void run() { + Factory.get().getApplicationContext().getContentResolver().delete( + outputUri, null, null); + } + }); + } + setMode(MODE_IDLE); + mHintTextView.setTypeface(null, Typeface.BOLD); + } else if (isRecording()) { + // Record for some extra time to ensure the ending part is saved. + setMode(MODE_STOPPING); + ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() { + @Override + public void run() { + onFinishedRecording(); + } + }, AUDIO_RECORD_ENDING_BUFFER_MILLIS); + } else { + setMode(MODE_IDLE); + } + return true; + } + + private Uri stopRecording() { + if (mMediaRecorder.isRecording()) { + return mMediaRecorder.stopRecording(); + } + return null; + } + + @Override // From MediaRecorder.OnInfoListener + public void onInfo(final MediaRecorder mr, final int what, final int extra) { + if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) { + // Max size reached. Finish recording immediately. + LogUtil.i(LogUtil.BUGLE_TAG, "Max size reached while recording audio"); + onFinishedRecording(); + } else { + // These are unknown errors. + onErrorWhileRecording(what, extra); + } + } + + @Override // From MediaRecorder.OnErrorListener + public void onError(final MediaRecorder mr, final int what, final int extra) { + onErrorWhileRecording(what, extra); + } + + private void onErrorWhileRecording(final int what, final int extra) { + LogUtil.e(LogUtil.BUGLE_TAG, "Error occurred during audio recording what=" + what + + ", extra=" + extra); + UiUtils.showToastAtBottom(R.string.audio_recording_error); + setMode(MODE_IDLE); + stopRecording(); + } + + private void onFinishedRecording() { + final Uri outputUri = stopRecording(); + if (outputUri != null) { + final Rect startRect = new Rect(); + mRecordButtonVisual.getGlobalVisibleRect(startRect); + final MediaPickerMessagePartData audioItem = + new MediaPickerMessagePartData(startRect, + ContentType.AUDIO_3GPP, outputUri, 0, 0); + mHostInterface.onAudioRecorded(audioItem); + } + playAudioEndSound(); + setMode(MODE_IDLE); + } + + private void playAudioStartSound(final OnCompletionListener completionListener) { + MediaUtil.get().playSound(getContext(), R.raw.audio_initiate, completionListener); + } + + private void playAudioEndSound() { + MediaUtil.get().playSound(getContext(), R.raw.audio_end, null); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/CameraManager.java b/src/com/android/messaging/ui/mediapicker/CameraManager.java new file mode 100644 index 0000000..166ebd7 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/CameraManager.java @@ -0,0 +1,1200 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker; + +import android.Manifest; +import android.app.Activity; +import android.content.Context; +import android.content.pm.ActivityInfo; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.hardware.Camera; +import android.hardware.Camera.CameraInfo; +import android.media.MediaRecorder; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Looper; +import android.support.annotation.NonNull; +import android.text.TextUtils; +import android.util.DisplayMetrics; +import android.view.MotionEvent; +import android.view.OrientationEventListener; +import android.view.Surface; +import android.view.View; +import android.view.WindowManager; + +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; +import com.android.messaging.Factory; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.media.ImageRequest; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.ui.mediapicker.camerafocus.FocusOverlayManager; +import com.android.messaging.ui.mediapicker.camerafocus.RenderOverlay; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.UiUtils; +import com.google.common.annotations.VisibleForTesting; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * Class which manages interactions with the camera, but does not do any UI. This class is + * designed to be a singleton to ensure there is one component managing the camera and releasing + * the native resources. + * In order to acquire a camera, a caller must: + *

    + *
  • Call selectCamera to select front or back camera + *
  • Call setSurface to control where the preview is shown + *
  • Call openCamera to request the camera start preview + *
+ * Callers should call onPause and onResume to ensure that the camera is release while the activity + * is not active. + * This class is not thread safe. It should only be called from one thread (the UI thread or test + * thread) + */ +class CameraManager implements FocusOverlayManager.Listener { + /** + * Wrapper around the framework camera API to allow mocking different hardware scenarios while + * unit testing + */ + interface CameraWrapper { + int getNumberOfCameras(); + void getCameraInfo(int index, CameraInfo cameraInfo); + Camera open(int cameraId); + /** Add a wrapper for release because a final method cannot be mocked */ + void release(Camera camera); + } + + /** + * Callbacks for the camera manager listener + */ + interface CameraManagerListener { + void onCameraError(int errorCode, Exception e); + void onCameraChanged(); + } + + /** + * Callback when taking image or video + */ + interface MediaCallback { + static final int MEDIA_CAMERA_CHANGED = 1; + static final int MEDIA_NO_DATA = 2; + + void onMediaReady(Uri uriToMedia, String contentType, int width, int height); + void onMediaFailed(Exception exception); + void onMediaInfo(int what); + } + + // Error codes + static final int ERROR_OPENING_CAMERA = 1; + static final int ERROR_SHOWING_PREVIEW = 2; + static final int ERROR_INITIALIZING_VIDEO = 3; + static final int ERROR_STORAGE_FAILURE = 4; + static final int ERROR_RECORDING_VIDEO = 5; + static final int ERROR_HARDWARE_ACCELERATION_DISABLED = 6; + static final int ERROR_TAKING_PICTURE = 7; + + private static final String TAG = LogUtil.BUGLE_TAG; + private static final int NO_CAMERA_SELECTED = -1; + + private static CameraManager sInstance; + + /** Default camera wrapper which directs calls to the framework APIs */ + private static CameraWrapper sCameraWrapper = new CameraWrapper() { + @Override + public int getNumberOfCameras() { + return Camera.getNumberOfCameras(); + } + + @Override + public void getCameraInfo(final int index, final CameraInfo cameraInfo) { + Camera.getCameraInfo(index, cameraInfo); + } + + @Override + public Camera open(final int cameraId) { + return Camera.open(cameraId); + } + + @Override + public void release(final Camera camera) { + camera.release(); + } + }; + + /** The CameraInfo for the currently selected camera */ + private final CameraInfo mCameraInfo; + + /** + * The index of the selected camera or NO_CAMERA_SELECTED if a camera hasn't been selected yet + */ + private int mCameraIndex; + + /** True if the device has front and back cameras */ + private final boolean mHasFrontAndBackCamera; + + /** True if the camera should be open (may not yet be actually open) */ + private boolean mOpenRequested; + + /** True if the camera is requested to be in video mode */ + private boolean mVideoModeRequested; + + /** The media recorder for video mode */ + private MmsVideoRecorder mMediaRecorder; + + /** Callback to call with video recording updates */ + private MediaCallback mVideoCallback; + + /** The preview view to show the preview on */ + private CameraPreview mCameraPreview; + + /** The helper classs to handle orientation changes */ + private OrientationHandler mOrientationHandler; + + /** Tracks whether the preview has hardware acceleration */ + private boolean mIsHardwareAccelerationSupported; + + /** + * The task for opening the camera, so it doesn't block the UI thread + * Using AsyncTask rather than SafeAsyncTask because the tasks need to be serialized, but don't + * need to be on the UI thread + * TODO: If we have other AyncTasks (not SafeAsyncTasks) this may contend and we may + * need to create a dedicated thread, or synchronize the threads in the thread pool + */ + private AsyncTask mOpenCameraTask; + + /** + * The camera index that is queued to be opened, but not completed yet, or NO_CAMERA_SELECTED if + * no open task is pending + */ + private int mPendingOpenCameraIndex = NO_CAMERA_SELECTED; + + /** The instance of the currently opened camera */ + private Camera mCamera; + + /** The rotation of the screen relative to the camera's natural orientation */ + private int mRotation; + + /** The callback to notify when errors or other events occur */ + private CameraManagerListener mListener; + + /** True if the camera is currently in the process of taking an image */ + private boolean mTakingPicture; + + /** Provides subscription-related data to access per-subscription configurations. */ + private DraftMessageSubscriptionDataProvider mSubscriptionDataProvider; + + /** Manages auto focus visual and behavior */ + private final FocusOverlayManager mFocusOverlayManager; + + private CameraManager() { + mCameraInfo = new CameraInfo(); + mCameraIndex = NO_CAMERA_SELECTED; + + // Check to see if a front and back camera exist + boolean hasFrontCamera = false; + boolean hasBackCamera = false; + final CameraInfo cameraInfo = new CameraInfo(); + final int cameraCount = sCameraWrapper.getNumberOfCameras(); + try { + for (int i = 0; i < cameraCount; i++) { + sCameraWrapper.getCameraInfo(i, cameraInfo); + if (cameraInfo.facing == CameraInfo.CAMERA_FACING_FRONT) { + hasFrontCamera = true; + } else if (cameraInfo.facing == CameraInfo.CAMERA_FACING_BACK) { + hasBackCamera = true; + } + if (hasFrontCamera && hasBackCamera) { + break; + } + } + } catch (final RuntimeException e) { + LogUtil.e(TAG, "Unable to load camera info", e); + } + mHasFrontAndBackCamera = hasFrontCamera && hasBackCamera; + mFocusOverlayManager = new FocusOverlayManager(this, Looper.getMainLooper()); + + // Assume the best until we are proven otherwise + mIsHardwareAccelerationSupported = true; + } + + /** Gets the singleton instance */ + static CameraManager get() { + if (sInstance == null) { + sInstance = new CameraManager(); + } + return sInstance; + } + + /** Allows tests to inject a custom camera wrapper */ + @VisibleForTesting + static void setCameraWrapper(final CameraWrapper cameraWrapper) { + sCameraWrapper = cameraWrapper; + sInstance = null; + } + + /** + * Sets the surface to use to display the preview + * This must only be called AFTER the CameraPreview has a texture ready + * @param preview The preview surface view + */ + void setSurface(final CameraPreview preview) { + if (preview == mCameraPreview) { + return; + } + + if (preview != null) { + Assert.isTrue(preview.isValid()); + preview.setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(final View view, final MotionEvent motionEvent) { + if ((motionEvent.getActionMasked() & MotionEvent.ACTION_UP) == + MotionEvent.ACTION_UP) { + mFocusOverlayManager.setPreviewSize(view.getWidth(), view.getHeight()); + mFocusOverlayManager.onSingleTapUp( + (int) motionEvent.getX() + view.getLeft(), + (int) motionEvent.getY() + view.getTop()); + } + return true; + } + }); + } + mCameraPreview = preview; + tryShowPreview(); + } + + void setRenderOverlay(final RenderOverlay renderOverlay) { + mFocusOverlayManager.setFocusRenderer(renderOverlay != null ? + renderOverlay.getPieRenderer() : null); + } + + /** Convenience function to swap between front and back facing cameras */ + void swapCamera() { + Assert.isTrue(mCameraIndex >= 0); + selectCamera(mCameraInfo.facing == CameraInfo.CAMERA_FACING_FRONT ? + CameraInfo.CAMERA_FACING_BACK : + CameraInfo.CAMERA_FACING_FRONT); + } + + /** + * Selects the first camera facing the desired direction, or the first camera if there is no + * camera in the desired direction + * @param desiredFacing One of the CameraInfo.CAMERA_FACING_* constants + * @return True if a camera was selected, or false if selecting a camera failed + */ + boolean selectCamera(final int desiredFacing) { + try { + // We already selected a camera facing that direction + if (mCameraIndex >= 0 && mCameraInfo.facing == desiredFacing) { + return true; + } + + final int cameraCount = sCameraWrapper.getNumberOfCameras(); + Assert.isTrue(cameraCount > 0); + + mCameraIndex = NO_CAMERA_SELECTED; + setCamera(null); + final CameraInfo cameraInfo = new CameraInfo(); + for (int i = 0; i < cameraCount; i++) { + sCameraWrapper.getCameraInfo(i, cameraInfo); + if (cameraInfo.facing == desiredFacing) { + mCameraIndex = i; + sCameraWrapper.getCameraInfo(i, mCameraInfo); + break; + } + } + + // There's no camera in the desired facing direction, just select the first camera + // regardless of direction + if (mCameraIndex < 0) { + mCameraIndex = 0; + sCameraWrapper.getCameraInfo(0, mCameraInfo); + } + + if (mOpenRequested) { + // The camera is open, so reopen with the newly selected camera + openCamera(); + } + return true; + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.selectCamera", e); + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, e); + } + return false; + } + } + + int getCameraIndex() { + return mCameraIndex; + } + + void selectCameraByIndex(final int cameraIndex) { + if (mCameraIndex == cameraIndex) { + return; + } + + try { + mCameraIndex = cameraIndex; + sCameraWrapper.getCameraInfo(mCameraIndex, mCameraInfo); + if (mOpenRequested) { + openCamera(); + } + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.selectCameraByIndex", e); + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, e); + } + } + } + + @VisibleForTesting + CameraInfo getCameraInfo() { + if (mCameraIndex == NO_CAMERA_SELECTED) { + return null; + } + return mCameraInfo; + } + + /** @return True if this device has camera capabilities */ + boolean hasAnyCamera() { + return sCameraWrapper.getNumberOfCameras() > 0; + } + + /** @return True if the device has both a front and back camera */ + boolean hasFrontAndBackCamera() { + return mHasFrontAndBackCamera; + } + + /** + * Opens the camera on a separate thread and initiates the preview if one is available + */ + void openCamera() { + if (mCameraIndex == NO_CAMERA_SELECTED) { + // Ensure a selected camera if none is currently selected. This may happen if the + // camera chooser is not the default media chooser. + selectCamera(CameraInfo.CAMERA_FACING_BACK); + } + mOpenRequested = true; + // We're already opening the camera or already have the camera handle, nothing more to do + if (mPendingOpenCameraIndex == mCameraIndex || mCamera != null) { + return; + } + + // True if the task to open the camera has to be delayed until the current one completes + boolean delayTask = false; + + // Cancel any previous open camera tasks + if (mOpenCameraTask != null) { + mPendingOpenCameraIndex = NO_CAMERA_SELECTED; + delayTask = true; + } + + mPendingOpenCameraIndex = mCameraIndex; + mOpenCameraTask = new AsyncTask() { + private Exception mException; + + @Override + protected Camera doInBackground(final Integer... params) { + try { + final int cameraIndex = params[0]; + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Opening camera " + mCameraIndex); + } + return sCameraWrapper.open(cameraIndex); + } catch (final Exception e) { + LogUtil.e(TAG, "Exception while opening camera", e); + mException = e; + return null; + } + } + + @Override + protected void onPostExecute(final Camera camera) { + // If we completed, but no longer want this camera, then release the camera + if (mOpenCameraTask != this || !mOpenRequested) { + releaseCamera(camera); + cleanup(); + return; + } + + cleanup(); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Opened camera " + mCameraIndex + " " + (camera != null)); + } + + setCamera(camera); + if (camera == null) { + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, mException); + } + LogUtil.e(TAG, "Error opening camera"); + } + } + + @Override + protected void onCancelled() { + super.onCancelled(); + cleanup(); + } + + private void cleanup() { + mPendingOpenCameraIndex = NO_CAMERA_SELECTED; + if (mOpenCameraTask != null && mOpenCameraTask.getStatus() == Status.PENDING) { + // If there's another task waiting on this one to complete, start it now + mOpenCameraTask.execute(mCameraIndex); + } else { + mOpenCameraTask = null; + } + + } + }; + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Start opening camera " + mCameraIndex); + } + + if (!delayTask) { + mOpenCameraTask.execute(mCameraIndex); + } + } + + boolean isVideoMode() { + return mVideoModeRequested; + } + + boolean isRecording() { + return mVideoModeRequested && mVideoCallback != null; + } + + void setVideoMode(final boolean videoMode) { + if (mVideoModeRequested == videoMode) { + return; + } + mVideoModeRequested = videoMode; + tryInitOrCleanupVideoMode(); + } + + /** Closes the camera releasing the resources it uses */ + void closeCamera() { + mOpenRequested = false; + setCamera(null); + } + + /** Temporarily closes the camera if it is open */ + void onPause() { + setCamera(null); + } + + /** Reopens the camera if it was opened when onPause was called */ + void onResume() { + if (mOpenRequested) { + openCamera(); + } + } + + /** + * Sets the listener which will be notified of errors or other events in the camera + * @param listener The listener to notify + */ + void setListener(final CameraManagerListener listener) { + Assert.isMainThread(); + mListener = listener; + if (!mIsHardwareAccelerationSupported && mListener != null) { + mListener.onCameraError(ERROR_HARDWARE_ACCELERATION_DISABLED, null); + } + } + + void setSubscriptionDataProvider(final DraftMessageSubscriptionDataProvider provider) { + mSubscriptionDataProvider = provider; + } + + void takePicture(final float heightPercent, @NonNull final MediaCallback callback) { + Assert.isTrue(!mVideoModeRequested); + Assert.isTrue(!mTakingPicture); + Assert.notNull(callback); + if (mCamera == null) { + // The caller should have checked isCameraAvailable first, but just in case, protect + // against a null camera by notifying the callback that taking the picture didn't work + callback.onMediaFailed(null); + return; + } + final Camera.PictureCallback jpegCallback = new Camera.PictureCallback() { + @Override + public void onPictureTaken(final byte[] bytes, final Camera camera) { + mTakingPicture = false; + if (mCamera != camera) { + // This may happen if the camera was changed between front/back while the + // picture is being taken. + callback.onMediaInfo(MediaCallback.MEDIA_CAMERA_CHANGED); + return; + } + + if (bytes == null) { + callback.onMediaInfo(MediaCallback.MEDIA_NO_DATA); + return; + } + + final Camera.Size size = camera.getParameters().getPictureSize(); + int width; + int height; + if (mRotation == 90 || mRotation == 270) { + width = size.height; + height = size.width; + } else { + width = size.width; + height = size.height; + } + new ImagePersistTask( + width, height, heightPercent, bytes, mCameraPreview.getContext(), callback) + .executeOnThreadPool(); + } + }; + + mTakingPicture = true; + try { + mCamera.takePicture( + null /* shutter */, + null /* raw */, + null /* postView */, + jpegCallback); + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.takePicture", e); + mTakingPicture = false; + if (mListener != null) { + mListener.onCameraError(ERROR_TAKING_PICTURE, e); + } + } + } + + void startVideo(final MediaCallback callback) { + Assert.notNull(callback); + Assert.isTrue(!isRecording()); + mVideoCallback = callback; + tryStartVideoCapture(); + } + + /** + * Asynchronously releases a camera + * @param camera The camera to release + */ + private void releaseCamera(final Camera camera) { + if (camera == null) { + return; + } + + mFocusOverlayManager.onCameraReleased(); + + new AsyncTask() { + @Override + protected Void doInBackground(final Void... params) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Releasing camera " + mCameraIndex); + } + sCameraWrapper.release(camera); + return null; + } + }.execute(); + } + + private void releaseMediaRecorder(final boolean cleanupFile) { + if (mMediaRecorder == null) { + return; + } + mVideoModeRequested = false; + + if (cleanupFile) { + mMediaRecorder.cleanupTempFile(); + if (mVideoCallback != null) { + final MediaCallback callback = mVideoCallback; + mVideoCallback = null; + // Notify the callback that we've stopped recording + callback.onMediaReady(null /*uri*/, null /*contentType*/, 0 /*width*/, + 0 /*height*/); + } + } + + mMediaRecorder.release(); + mMediaRecorder = null; + + if (mCamera != null) { + try { + mCamera.reconnect(); + } catch (final IOException e) { + LogUtil.e(TAG, "IOException in CameraManager.releaseMediaRecorder", e); + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, e); + } + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.releaseMediaRecorder", e); + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, e); + } + } + } + restoreRequestedOrientation(); + } + + /** Updates the orientation of the camera to match the orientation of the device */ + private void updateCameraOrientation() { + if (mCamera == null || mCameraPreview == null || mTakingPicture) { + return; + } + + final WindowManager windowManager = + (WindowManager) mCameraPreview.getContext().getSystemService( + Context.WINDOW_SERVICE); + + int degrees = 0; + switch (windowManager.getDefaultDisplay().getRotation()) { + case Surface.ROTATION_0: degrees = 0; break; + case Surface.ROTATION_90: degrees = 90; break; + case Surface.ROTATION_180: degrees = 180; break; + case Surface.ROTATION_270: degrees = 270; break; + } + + // The display orientation of the camera (this controls the preview image). + int orientation; + + // The clockwise rotation angle relative to the orientation of the camera. This affects + // pictures returned by the camera in Camera.PictureCallback. + int rotation; + if (mCameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { + orientation = (mCameraInfo.orientation + degrees) % 360; + rotation = orientation; + // compensate the mirror but only for orientation + orientation = (360 - orientation) % 360; + } else { // back-facing + orientation = (mCameraInfo.orientation - degrees + 360) % 360; + rotation = orientation; + } + mRotation = rotation; + if (mMediaRecorder == null) { + try { + mCamera.setDisplayOrientation(orientation); + final Camera.Parameters params = mCamera.getParameters(); + params.setRotation(rotation); + mCamera.setParameters(params); + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.updateCameraOrientation", e); + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, e); + } + } + } + } + + /** Sets the current camera, releasing any previously opened camera */ + private void setCamera(final Camera camera) { + if (mCamera == camera) { + return; + } + + releaseMediaRecorder(true /* cleanupFile */); + releaseCamera(mCamera); + mCamera = camera; + tryShowPreview(); + if (mListener != null) { + mListener.onCameraChanged(); + } + } + + /** Shows the preview if the camera is open and the preview is loaded */ + private void tryShowPreview() { + if (mCameraPreview == null || mCamera == null) { + if (mOrientationHandler != null) { + mOrientationHandler.disable(); + mOrientationHandler = null; + } + releaseMediaRecorder(true /* cleanupFile */); + mFocusOverlayManager.onPreviewStopped(); + return; + } + try { + mCamera.stopPreview(); + updateCameraOrientation(); + + final Camera.Parameters params = mCamera.getParameters(); + final Camera.Size pictureSize = chooseBestPictureSize(); + final Camera.Size previewSize = chooseBestPreviewSize(pictureSize); + params.setPreviewSize(previewSize.width, previewSize.height); + params.setPictureSize(pictureSize.width, pictureSize.height); + logCameraSize("Setting preview size: ", previewSize); + logCameraSize("Setting picture size: ", pictureSize); + mCameraPreview.setSize(previewSize, mCameraInfo.orientation); + for (final String focusMode : params.getSupportedFocusModes()) { + if (TextUtils.equals(focusMode, Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) { + // Use continuous focus if available + params.setFocusMode(focusMode); + break; + } + } + + mCamera.setParameters(params); + mCameraPreview.startPreview(mCamera); + mCamera.startPreview(); + mCamera.setAutoFocusMoveCallback(new Camera.AutoFocusMoveCallback() { + @Override + public void onAutoFocusMoving(final boolean start, final Camera camera) { + mFocusOverlayManager.onAutoFocusMoving(start); + } + }); + mFocusOverlayManager.setParameters(mCamera.getParameters()); + mFocusOverlayManager.setMirror(mCameraInfo.facing == CameraInfo.CAMERA_FACING_BACK); + mFocusOverlayManager.onPreviewStarted(); + tryInitOrCleanupVideoMode(); + if (mOrientationHandler == null) { + mOrientationHandler = new OrientationHandler(mCameraPreview.getContext()); + mOrientationHandler.enable(); + } + } catch (final IOException e) { + LogUtil.e(TAG, "IOException in CameraManager.tryShowPreview", e); + if (mListener != null) { + mListener.onCameraError(ERROR_SHOWING_PREVIEW, e); + } + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.tryShowPreview", e); + if (mListener != null) { + mListener.onCameraError(ERROR_SHOWING_PREVIEW, e); + } + } + } + + private void tryInitOrCleanupVideoMode() { + if (!mVideoModeRequested || mCamera == null || mCameraPreview == null) { + releaseMediaRecorder(true /* cleanupFile */); + return; + } + + if (mMediaRecorder != null) { + return; + } + + try { + mCamera.unlock(); + final int maxMessageSize = getMmsConfig().getMaxMessageSize(); + mMediaRecorder = new MmsVideoRecorder(mCamera, mCameraIndex, mRotation, maxMessageSize); + mMediaRecorder.prepare(); + } catch (final FileNotFoundException e) { + LogUtil.e(TAG, "FileNotFoundException in CameraManager.tryInitOrCleanupVideoMode", e); + if (mListener != null) { + mListener.onCameraError(ERROR_STORAGE_FAILURE, e); + } + setVideoMode(false); + return; + } catch (final IOException e) { + LogUtil.e(TAG, "IOException in CameraManager.tryInitOrCleanupVideoMode", e); + if (mListener != null) { + mListener.onCameraError(ERROR_INITIALIZING_VIDEO, e); + } + setVideoMode(false); + return; + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.tryInitOrCleanupVideoMode", e); + if (mListener != null) { + mListener.onCameraError(ERROR_INITIALIZING_VIDEO, e); + } + setVideoMode(false); + return; + } + + tryStartVideoCapture(); + } + + private void tryStartVideoCapture() { + if (mMediaRecorder == null || mVideoCallback == null) { + return; + } + + mMediaRecorder.setOnErrorListener(new MediaRecorder.OnErrorListener() { + @Override + public void onError(final MediaRecorder mediaRecorder, final int what, + final int extra) { + if (mListener != null) { + mListener.onCameraError(ERROR_RECORDING_VIDEO, null); + } + restoreRequestedOrientation(); + } + }); + + mMediaRecorder.setOnInfoListener(new MediaRecorder.OnInfoListener() { + @Override + public void onInfo(final MediaRecorder mediaRecorder, final int what, final int extra) { + if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED || + what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) { + stopVideo(); + } + } + }); + + try { + mMediaRecorder.start(); + final Activity activity = UiUtils.getActivity(mCameraPreview.getContext()); + activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + lockOrientation(); + } catch (final IllegalStateException e) { + LogUtil.e(TAG, "IllegalStateException in CameraManager.tryStartVideoCapture", e); + if (mListener != null) { + mListener.onCameraError(ERROR_RECORDING_VIDEO, e); + } + setVideoMode(false); + restoreRequestedOrientation(); + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.tryStartVideoCapture", e); + if (mListener != null) { + mListener.onCameraError(ERROR_RECORDING_VIDEO, e); + } + setVideoMode(false); + restoreRequestedOrientation(); + } + } + + void stopVideo() { + int width = ImageRequest.UNSPECIFIED_SIZE; + int height = ImageRequest.UNSPECIFIED_SIZE; + Uri uri = null; + String contentType = null; + try { + final Activity activity = UiUtils.getActivity(mCameraPreview.getContext()); + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + mMediaRecorder.stop(); + width = mMediaRecorder.getVideoWidth(); + height = mMediaRecorder.getVideoHeight(); + uri = mMediaRecorder.getVideoUri(); + contentType = mMediaRecorder.getContentType(); + } catch (final RuntimeException e) { + // MediaRecorder.stop will throw a RuntimeException if the video was too short, let the + // finally clause call the callback with null uri and handle cleanup + LogUtil.e(TAG, "RuntimeException in CameraManager.stopVideo", e); + } finally { + final MediaCallback videoCallback = mVideoCallback; + mVideoCallback = null; + releaseMediaRecorder(false /* cleanupFile */); + if (uri == null) { + tryInitOrCleanupVideoMode(); + } + videoCallback.onMediaReady(uri, contentType, width, height); + } + } + + boolean isCameraAvailable() { + return mCamera != null && !mTakingPicture && mIsHardwareAccelerationSupported; + } + + /** + * External components call into this to report if hardware acceleration is supported. When + * hardware acceleration isn't supported, we need to report an error through the listener + * interface + * @param isHardwareAccelerationSupported True if the preview is rendering in a hardware + * accelerated view. + */ + void reportHardwareAccelerationSupported(final boolean isHardwareAccelerationSupported) { + Assert.isMainThread(); + if (mIsHardwareAccelerationSupported == isHardwareAccelerationSupported) { + // If the value hasn't changed nothing more to do + return; + } + + mIsHardwareAccelerationSupported = isHardwareAccelerationSupported; + if (!isHardwareAccelerationSupported) { + LogUtil.e(TAG, "Software rendering - cannot open camera"); + if (mListener != null) { + mListener.onCameraError(ERROR_HARDWARE_ACCELERATION_DISABLED, null); + } + } + } + + /** Returns the scale factor to scale the width/height to max allowed in MmsConfig */ + private float getScaleFactorForMaxAllowedSize(final int width, final int height, + final int maxWidth, final int maxHeight) { + if (maxWidth <= 0 || maxHeight <= 0) { + // MmsConfig initialization runs asynchronously on application startup, so there's a + // chance (albeit a very slight one) that we don't have it yet. + LogUtil.w(LogUtil.BUGLE_TAG, "Max image size not loaded in MmsConfig"); + return 1.0f; + } + + if (width <= maxWidth && height <= maxHeight) { + // Already meeting requirements. + return 1.0f; + } + + return Math.min(maxWidth * 1.0f / width, maxHeight * 1.0f / height); + } + + private MmsConfig getMmsConfig() { + final int subId = mSubscriptionDataProvider != null ? + mSubscriptionDataProvider.getConversationSelfSubId() : + ParticipantData.DEFAULT_SELF_SUB_ID; + return MmsConfig.get(subId); + } + + /** + * Choose the best picture size by trying to find a size close to the MmsConfig's max size, + * which is closest to the screen aspect ratio + */ + private Camera.Size chooseBestPictureSize() { + final Context context = mCameraPreview.getContext(); + final Resources resources = context.getResources(); + final DisplayMetrics displayMetrics = resources.getDisplayMetrics(); + final int displayOrientation = resources.getConfiguration().orientation; + int cameraOrientation = mCameraInfo.orientation; + + int screenWidth; + int screenHeight; + if (displayOrientation == Configuration.ORIENTATION_LANDSCAPE) { + // Rotate the camera orientation 90 degrees to compensate for the rotated display + // metrics. Direction doesn't matter because we're just using it for width/height + cameraOrientation += 90; + } + + // Check the camera orientation relative to the display. + // For 0, 180, 360, the screen width/height are the display width/height + // For 90, 270, the screen width/height are inverted from the display + if (cameraOrientation % 180 == 0) { + screenWidth = displayMetrics.widthPixels; + screenHeight = displayMetrics.heightPixels; + } else { + screenWidth = displayMetrics.heightPixels; + screenHeight = displayMetrics.widthPixels; + } + + final MmsConfig mmsConfig = getMmsConfig(); + final int maxWidth = mmsConfig.getMaxImageWidth(); + final int maxHeight = mmsConfig.getMaxImageHeight(); + + // Constrain the size within the max width/height defined by MmsConfig. + final float scaleFactor = getScaleFactorForMaxAllowedSize(screenWidth, screenHeight, + maxWidth, maxHeight); + screenWidth *= scaleFactor; + screenHeight *= scaleFactor; + + final float aspectRatio = BugleGservices.get().getFloat( + BugleGservicesKeys.CAMERA_ASPECT_RATIO, + screenWidth / (float) screenHeight); + final List sizes = new ArrayList( + mCamera.getParameters().getSupportedPictureSizes()); + final int maxPixels = maxWidth * maxHeight; + + // Sort the sizes so the best size is first + Collections.sort(sizes, new SizeComparator(maxWidth, maxHeight, aspectRatio, maxPixels)); + + return sizes.get(0); + } + + /** + * Chose the best preview size based on the picture size. Try to find a size with the same + * aspect ratio and size as the picture if possible + */ + private Camera.Size chooseBestPreviewSize(final Camera.Size pictureSize) { + final List sizes = new ArrayList( + mCamera.getParameters().getSupportedPreviewSizes()); + final float aspectRatio = pictureSize.width / (float) pictureSize.height; + final int capturePixels = pictureSize.width * pictureSize.height; + + // Sort the sizes so the best size is first + Collections.sort(sizes, new SizeComparator(Integer.MAX_VALUE, Integer.MAX_VALUE, + aspectRatio, capturePixels)); + + return sizes.get(0); + } + + private class OrientationHandler extends OrientationEventListener { + OrientationHandler(final Context context) { + super(context); + } + + @Override + public void onOrientationChanged(final int orientation) { + updateCameraOrientation(); + } + } + + private static class SizeComparator implements Comparator { + private static final int PREFER_LEFT = -1; + private static final int PREFER_RIGHT = 1; + + // The max width/height for the preferred size. Integer.MAX_VALUE if no size limit + private final int mMaxWidth; + private final int mMaxHeight; + + // The desired aspect ratio + private final float mTargetAspectRatio; + + // The desired size (width x height) to try to match + private final int mTargetPixels; + + public SizeComparator(final int maxWidth, final int maxHeight, + final float targetAspectRatio, final int targetPixels) { + mMaxWidth = maxWidth; + mMaxHeight = maxHeight; + mTargetAspectRatio = targetAspectRatio; + mTargetPixels = targetPixels; + } + + /** + * Returns a negative value if left is a better choice than right, or a positive value if + * right is a better choice is better than left. 0 if they are equal + */ + @Override + public int compare(final Camera.Size left, final Camera.Size right) { + // If one size is less than the max size prefer it over the other + if ((left.width <= mMaxWidth && left.height <= mMaxHeight) != + (right.width <= mMaxWidth && right.height <= mMaxHeight)) { + return left.width <= mMaxWidth ? PREFER_LEFT : PREFER_RIGHT; + } + + // If one is closer to the target aspect ratio, prefer it. + final float leftAspectRatio = left.width / (float) left.height; + final float rightAspectRatio = right.width / (float) right.height; + final float leftAspectRatioDiff = Math.abs(leftAspectRatio - mTargetAspectRatio); + final float rightAspectRatioDiff = Math.abs(rightAspectRatio - mTargetAspectRatio); + if (leftAspectRatioDiff != rightAspectRatioDiff) { + return (leftAspectRatioDiff - rightAspectRatioDiff) < 0 ? + PREFER_LEFT : PREFER_RIGHT; + } + + // At this point they have the same aspect ratio diff and are either both bigger + // than the max size or both smaller than the max size, so prefer the one closest + // to target size + final int leftDiff = Math.abs((left.width * left.height) - mTargetPixels); + final int rightDiff = Math.abs((right.width * right.height) - mTargetPixels); + return leftDiff - rightDiff; + } + } + + @Override // From FocusOverlayManager.Listener + public void autoFocus() { + if (mCamera == null) { + return; + } + + try { + mCamera.autoFocus(new Camera.AutoFocusCallback() { + @Override + public void onAutoFocus(final boolean success, final Camera camera) { + mFocusOverlayManager.onAutoFocus(success, false /* shutterDown */); + } + }); + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.autoFocus", e); + // If autofocus fails, the camera should have called the callback with success=false, + // but some throw an exception here + mFocusOverlayManager.onAutoFocus(false /*success*/, false /*shutterDown*/); + } + } + + @Override // From FocusOverlayManager.Listener + public void cancelAutoFocus() { + if (mCamera == null) { + return; + } + try { + mCamera.cancelAutoFocus(); + } catch (final RuntimeException e) { + // Ignore + LogUtil.e(TAG, "RuntimeException in CameraManager.cancelAutoFocus", e); + } + } + + @Override // From FocusOverlayManager.Listener + public boolean capture() { + return false; + } + + @Override // From FocusOverlayManager.Listener + public void setFocusParameters() { + if (mCamera == null) { + return; + } + try { + final Camera.Parameters parameters = mCamera.getParameters(); + parameters.setFocusMode(mFocusOverlayManager.getFocusMode()); + if (parameters.getMaxNumFocusAreas() > 0) { + // Don't set focus areas (even to null) if focus areas aren't supported, camera may + // crash + parameters.setFocusAreas(mFocusOverlayManager.getFocusAreas()); + } + parameters.setMeteringAreas(mFocusOverlayManager.getMeteringAreas()); + mCamera.setParameters(parameters); + } catch (final RuntimeException e) { + // This occurs when the device is out of space or when the camera is locked + LogUtil.e(TAG, "RuntimeException in CameraManager setFocusParameters"); + } + } + + private void logCameraSize(final String prefix, final Camera.Size size) { + // Log the camera size and aspect ratio for help when examining bug reports for camera + // failures + LogUtil.i(TAG, prefix + size.width + "x" + size.height + + " (" + (size.width / (float) size.height) + ")"); + } + + + private Integer mSavedOrientation = null; + + private void lockOrientation() { + // when we start recording, lock our orientation + final Activity a = UiUtils.getActivity(mCameraPreview.getContext()); + final WindowManager windowManager = + (WindowManager) a.getSystemService(Context.WINDOW_SERVICE); + final int rotation = windowManager.getDefaultDisplay().getRotation(); + + mSavedOrientation = a.getRequestedOrientation(); + switch (rotation) { + case Surface.ROTATION_0: + a.setRequestedOrientation( + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); + break; + case Surface.ROTATION_90: + a.setRequestedOrientation( + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); + break; + case Surface.ROTATION_180: + a.setRequestedOrientation( + ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT); + break; + case Surface.ROTATION_270: + a.setRequestedOrientation( + ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE); + break; + } + + } + + private void restoreRequestedOrientation() { + if (mSavedOrientation != null) { + final Activity a = UiUtils.getActivity(mCameraPreview.getContext()); + if (a != null) { + a.setRequestedOrientation(mSavedOrientation); + } + mSavedOrientation = null; + } + } + + static boolean hasCameraPermission() { + return OsUtil.hasPermission(Manifest.permission.CAMERA); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java b/src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java new file mode 100644 index 0000000..2c7a7f2 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java @@ -0,0 +1,481 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import android.graphics.Rect; +import android.hardware.Camera; +import android.net.Uri; +import android.os.SystemClock; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.widget.Chronometer; +import android.widget.ImageButton; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.MediaPickerMessagePartData; +import com.android.messaging.ui.mediapicker.CameraManager.MediaCallback; +import com.android.messaging.ui.mediapicker.camerafocus.RenderOverlay; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.UiUtils; + +/** + * Chooser which allows the user to take pictures or video without leaving the current app/activity + */ +class CameraMediaChooser extends MediaChooser implements + CameraManager.CameraManagerListener { + private CameraPreview.CameraPreviewHost mCameraPreviewHost; + private ImageButton mFullScreenButton; + private ImageButton mSwapCameraButton; + private ImageButton mSwapModeButton; + private ImageButton mCaptureButton; + private ImageButton mCancelVideoButton; + private Chronometer mVideoCounter; + private boolean mVideoCancelled; + private int mErrorToast; + private View mEnabledView; + private View mMissingPermissionView; + + CameraMediaChooser(final MediaPicker mediaPicker) { + super(mediaPicker); + } + + @Override + public int getSupportedMediaTypes() { + if (CameraManager.get().hasAnyCamera()) { + return MediaPicker.MEDIA_TYPE_IMAGE | MediaPicker.MEDIA_TYPE_VIDEO; + } else { + return MediaPicker.MEDIA_TYPE_NONE; + } + } + + @Override + public View destroyView() { + CameraManager.get().closeCamera(); + CameraManager.get().setListener(null); + CameraManager.get().setSubscriptionDataProvider(null); + return super.destroyView(); + } + + @Override + protected View createView(final ViewGroup container) { + CameraManager.get().setListener(this); + CameraManager.get().setSubscriptionDataProvider(this); + CameraManager.get().setVideoMode(false); + final LayoutInflater inflater = getLayoutInflater(); + final CameraMediaChooserView view = (CameraMediaChooserView) inflater.inflate( + R.layout.mediapicker_camera_chooser, + container /* root */, + false /* attachToRoot */); + mCameraPreviewHost = (CameraPreview.CameraPreviewHost) view.findViewById( + R.id.camera_preview); + mCameraPreviewHost.getView().setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(final View view, final MotionEvent motionEvent) { + if (CameraManager.get().isVideoMode()) { + // Prevent the swipe down in video mode because video is always captured in + // full screen + return true; + } + + return false; + } + }); + + final View shutterVisual = view.findViewById(R.id.camera_shutter_visual); + + mFullScreenButton = (ImageButton) view.findViewById(R.id.camera_fullScreen_button); + mFullScreenButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + mMediaPicker.setFullScreen(true); + } + }); + + mSwapCameraButton = (ImageButton) view.findViewById(R.id.camera_swapCamera_button); + mSwapCameraButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + CameraManager.get().swapCamera(); + } + }); + + mCaptureButton = (ImageButton) view.findViewById(R.id.camera_capture_button); + mCaptureButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View v) { + final float heightPercent = Math.min(mMediaPicker.getViewPager().getHeight() / + (float) mCameraPreviewHost.getView().getHeight(), 1); + + if (CameraManager.get().isRecording()) { + CameraManager.get().stopVideo(); + } else { + final CameraManager.MediaCallback callback = new CameraManager.MediaCallback() { + @Override + public void onMediaReady( + final Uri uriToVideo, final String contentType, + final int width, final int height) { + mVideoCounter.stop(); + if (mVideoCancelled || uriToVideo == null) { + mVideoCancelled = false; + } else { + final Rect startRect = new Rect(); + // It's possible to throw out the chooser while taking the + // picture/video. In that case, still use the attachment, just + // skip the startRect + if (mView != null) { + mView.getGlobalVisibleRect(startRect); + } + mMediaPicker.dispatchItemsSelected( + new MediaPickerMessagePartData(startRect, contentType, + uriToVideo, width, height), + true /* dismissMediaPicker */); + } + updateViewState(); + } + + @Override + public void onMediaFailed(final Exception exception) { + UiUtils.showToastAtBottom(R.string.camera_media_failure); + updateViewState(); + } + + @Override + public void onMediaInfo(final int what) { + if (what == MediaCallback.MEDIA_NO_DATA) { + UiUtils.showToastAtBottom(R.string.camera_media_failure); + } + updateViewState(); + } + }; + if (CameraManager.get().isVideoMode()) { + CameraManager.get().startVideo(callback); + mVideoCounter.setBase(SystemClock.elapsedRealtime()); + mVideoCounter.start(); + updateViewState(); + } else { + showShutterEffect(shutterVisual); + CameraManager.get().takePicture(heightPercent, callback); + updateViewState(); + } + } + } + }); + + mSwapModeButton = (ImageButton) view.findViewById(R.id.camera_swap_mode_button); + mSwapModeButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + final boolean isSwitchingToVideo = !CameraManager.get().isVideoMode(); + if (isSwitchingToVideo && !OsUtil.hasRecordAudioPermission()) { + requestRecordAudioPermission(); + } else { + onSwapMode(); + } + } + }); + + mCancelVideoButton = (ImageButton) view.findViewById(R.id.camera_cancel_button); + mCancelVideoButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + mVideoCancelled = true; + CameraManager.get().stopVideo(); + mMediaPicker.dismiss(true); + } + }); + + mVideoCounter = (Chronometer) view.findViewById(R.id.camera_video_counter); + + CameraManager.get().setRenderOverlay((RenderOverlay) view.findViewById(R.id.focus_visual)); + + mEnabledView = view.findViewById(R.id.mediapicker_enabled); + mMissingPermissionView = view.findViewById(R.id.missing_permission_view); + + // Must set mView before calling updateViewState because it operates on mView + mView = view; + updateViewState(); + updateForPermissionState(CameraManager.hasCameraPermission()); + return view; + } + + @Override + public int getIconResource() { + return R.drawable.ic_camera_light; + } + + @Override + public int getIconDescriptionResource() { + return R.string.mediapicker_cameraChooserDescription; + } + + /** + * Updates the view when entering or leaving full-screen camera mode + * @param fullScreen + */ + @Override + void onFullScreenChanged(final boolean fullScreen) { + super.onFullScreenChanged(fullScreen); + if (!fullScreen && CameraManager.get().isVideoMode()) { + CameraManager.get().setVideoMode(false); + } + updateViewState(); + } + + /** + * Initializes the control to a default state when it is opened / closed + * @param open True if the control is opened + */ + @Override + void onOpenedChanged(final boolean open) { + super.onOpenedChanged(open); + updateViewState(); + } + + @Override + protected void setSelected(final boolean selected) { + super.setSelected(selected); + if (selected) { + if (CameraManager.hasCameraPermission()) { + // If an error occurred before the chooser was selected, show it now + showErrorToastIfNeeded(); + } else { + requestCameraPermission(); + } + } + } + + private void requestCameraPermission() { + mMediaPicker.requestPermissions(new String[] { Manifest.permission.CAMERA }, + MediaPicker.CAMERA_PERMISSION_REQUEST_CODE); + } + + private void requestRecordAudioPermission() { + mMediaPicker.requestPermissions(new String[] { Manifest.permission.RECORD_AUDIO }, + MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE); + } + + @Override + protected void onRequestPermissionsResult( + final int requestCode, final String permissions[], final int[] grantResults) { + if (requestCode == MediaPicker.CAMERA_PERMISSION_REQUEST_CODE) { + final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED; + updateForPermissionState(permissionGranted); + if (permissionGranted) { + mCameraPreviewHost.onCameraPermissionGranted(); + } + } else if (requestCode == MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE) { + Assert.isFalse(CameraManager.get().isVideoMode()); + if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // Switch to video mode + onSwapMode(); + } else { + // Stay in still-photo mode + } + } + } + + private void updateForPermissionState(final boolean granted) { + // onRequestPermissionsResult can sometimes get called before createView(). + if (mEnabledView == null) { + return; + } + + mEnabledView.setVisibility(granted ? View.VISIBLE : View.GONE); + mMissingPermissionView.setVisibility(granted ? View.GONE : View.VISIBLE); + } + + @Override + public boolean canSwipeDown() { + if (CameraManager.get().isVideoMode()) { + return true; + } + return super.canSwipeDown(); + } + + /** + * Handles an error from the camera manager by showing the appropriate error message to the user + * @param errorCode One of the CameraManager.ERROR_* constants + * @param e The exception which caused the error, if any + */ + @Override + public void onCameraError(final int errorCode, final Exception e) { + switch (errorCode) { + case CameraManager.ERROR_OPENING_CAMERA: + case CameraManager.ERROR_SHOWING_PREVIEW: + mErrorToast = R.string.camera_error_opening; + break; + case CameraManager.ERROR_INITIALIZING_VIDEO: + mErrorToast = R.string.camera_error_video_init_fail; + updateViewState(); + break; + case CameraManager.ERROR_STORAGE_FAILURE: + mErrorToast = R.string.camera_error_storage_fail; + updateViewState(); + break; + case CameraManager.ERROR_TAKING_PICTURE: + mErrorToast = R.string.camera_error_failure_taking_picture; + break; + default: + mErrorToast = R.string.camera_error_unknown; + LogUtil.w(LogUtil.BUGLE_TAG, "Unknown camera error:" + errorCode); + break; + } + showErrorToastIfNeeded(); + } + + private void showErrorToastIfNeeded() { + if (mErrorToast != 0 && mSelected) { + UiUtils.showToastAtBottom(mErrorToast); + mErrorToast = 0; + } + } + + @Override + public void onCameraChanged() { + updateViewState(); + } + + private void onSwapMode() { + CameraManager.get().setVideoMode(!CameraManager.get().isVideoMode()); + if (CameraManager.get().isVideoMode()) { + mMediaPicker.setFullScreen(true); + + // For now we start recording immediately + mCaptureButton.performClick(); + } + updateViewState(); + } + + private void showShutterEffect(final View shutterVisual) { + final float maxAlpha = getContext().getResources().getFraction( + R.fraction.camera_shutter_max_alpha, 1 /* base */, 1 /* pBase */); + + // Divide by 2 so each half of the animation adds up to the full duration + final int animationDuration = getContext().getResources().getInteger( + R.integer.camera_shutter_duration) / 2; + + final AnimationSet animation = new AnimationSet(false /* shareInterpolator */); + final Animation alphaInAnimation = new AlphaAnimation(0.0f, maxAlpha); + alphaInAnimation.setDuration(animationDuration); + animation.addAnimation(alphaInAnimation); + + final Animation alphaOutAnimation = new AlphaAnimation(maxAlpha, 0.0f); + alphaOutAnimation.setStartOffset(animationDuration); + alphaOutAnimation.setDuration(animationDuration); + animation.addAnimation(alphaOutAnimation); + + animation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(final Animation animation) { + shutterVisual.setVisibility(View.VISIBLE); + } + + @Override + public void onAnimationEnd(final Animation animation) { + shutterVisual.setVisibility(View.GONE); + } + + @Override + public void onAnimationRepeat(final Animation animation) { + } + }); + shutterVisual.startAnimation(animation); + } + + /** Updates the state of the buttons and overlays based on the current state of the view */ + private void updateViewState() { + if (mView == null) { + return; + } + + final Context context = getContext(); + if (context == null) { + // Context is null if the fragment was already removed from the activity + return; + } + final boolean fullScreen = mMediaPicker.isFullScreen(); + final boolean videoMode = CameraManager.get().isVideoMode(); + final boolean isRecording = CameraManager.get().isRecording(); + final boolean isCameraAvailable = isCameraAvailable(); + final Camera.CameraInfo cameraInfo = CameraManager.get().getCameraInfo(); + final boolean frontCamera = cameraInfo != null && cameraInfo.facing == + Camera.CameraInfo.CAMERA_FACING_FRONT; + + mView.setSystemUiVisibility( + fullScreen ? View.SYSTEM_UI_FLAG_LOW_PROFILE : + View.SYSTEM_UI_FLAG_VISIBLE); + + mFullScreenButton.setVisibility(!fullScreen ? View.VISIBLE : View.GONE); + mFullScreenButton.setEnabled(isCameraAvailable); + mSwapCameraButton.setVisibility( + fullScreen && !isRecording && CameraManager.get().hasFrontAndBackCamera() ? + View.VISIBLE : View.GONE); + mSwapCameraButton.setImageResource(frontCamera ? + R.drawable.ic_camera_front_light : + R.drawable.ic_camera_rear_light); + mSwapCameraButton.setEnabled(isCameraAvailable); + + mCancelVideoButton.setVisibility(isRecording ? View.VISIBLE : View.GONE); + mVideoCounter.setVisibility(isRecording ? View.VISIBLE : View.GONE); + + mSwapModeButton.setImageResource(videoMode ? + R.drawable.ic_mp_camera_small_light : + R.drawable.ic_mp_video_small_light); + mSwapModeButton.setContentDescription(context.getString(videoMode ? + R.string.camera_switch_to_still_mode : R.string.camera_switch_to_video_mode)); + mSwapModeButton.setVisibility(isRecording ? View.GONE : View.VISIBLE); + mSwapModeButton.setEnabled(isCameraAvailable); + + if (isRecording) { + mCaptureButton.setImageResource(R.drawable.ic_mp_capture_stop_large_light); + mCaptureButton.setContentDescription(context.getString( + R.string.camera_stop_recording)); + } else if (videoMode) { + mCaptureButton.setImageResource(R.drawable.ic_mp_video_large_light); + mCaptureButton.setContentDescription(context.getString( + R.string.camera_start_recording)); + } else { + mCaptureButton.setImageResource(R.drawable.ic_checkmark_large_light); + mCaptureButton.setContentDescription(context.getString( + R.string.camera_take_picture)); + } + mCaptureButton.setEnabled(isCameraAvailable); + } + + @Override + int getActionBarTitleResId() { + return 0; + } + + /** + * Returns if the camera is currently ready camera is loaded and not taking a picture. + * otherwise we should avoid taking another picture, swapping camera or recording video. + */ + private boolean isCameraAvailable() { + return CameraManager.get().isCameraAvailable(); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java b/src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java new file mode 100644 index 0000000..64c07b2 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker; + +import android.content.Context; +import android.graphics.Canvas; +import android.hardware.Camera; +import android.os.Bundle; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import com.android.messaging.R; +import com.android.messaging.ui.PersistentInstanceState; +import com.android.messaging.util.ThreadUtil; + +public class CameraMediaChooserView extends FrameLayout implements PersistentInstanceState { + private static final String KEY_CAMERA_INDEX = "camera_index"; + + // True if we have at least queued an update to the view tree to support software rendering + // fallback + private boolean mIsSoftwareFallbackActive; + + public CameraMediaChooserView(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected Parcelable onSaveInstanceState() { + final Bundle bundle = new Bundle(); + bundle.putInt(KEY_CAMERA_INDEX, CameraManager.get().getCameraIndex()); + return bundle; + } + + @Override + protected void onRestoreInstanceState(final Parcelable state) { + if (!(state instanceof Bundle)) { + return; + } + + final Bundle bundle = (Bundle) state; + CameraManager.get().selectCameraByIndex(bundle.getInt(KEY_CAMERA_INDEX)); + } + + @Override + public Parcelable saveState() { + return onSaveInstanceState(); + } + + @Override + public void restoreState(final Parcelable restoredState) { + onRestoreInstanceState(restoredState); + } + + @Override + public void resetState() { + CameraManager.get().selectCamera(Camera.CameraInfo.CAMERA_FACING_BACK); + } + + @Override + protected void onDraw(final Canvas canvas) { + super.onDraw(canvas); + // If the canvas isn't hardware accelerated, we have to replace the HardwareCameraPreview + // with a SoftwareCameraPreview which supports software rendering + if (!canvas.isHardwareAccelerated() && !mIsSoftwareFallbackActive) { + mIsSoftwareFallbackActive = true; + // Post modifying the tree since we can't modify the view tree during a draw pass + ThreadUtil.getMainThreadHandler().post(new Runnable() { + @Override + public void run() { + final HardwareCameraPreview cameraPreview = + (HardwareCameraPreview) findViewById(R.id.camera_preview); + if (cameraPreview == null) { + return; + } + final ViewGroup parent = ((ViewGroup) cameraPreview.getParent()); + final int index = parent.indexOfChild(cameraPreview); + final SoftwareCameraPreview softwareCameraPreview = + new SoftwareCameraPreview(getContext()); + // Be sure to remove the hardware view before adding the software view to + // prevent having 2 camera previews active at the same time + parent.removeView(cameraPreview); + parent.addView(softwareCameraPreview, index); + } + }); + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/CameraPreview.java b/src/com/android/messaging/ui/mediapicker/CameraPreview.java new file mode 100644 index 0000000..ecac978 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/CameraPreview.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker; + +import android.content.Context; +import android.content.res.Configuration; +import android.hardware.Camera; +import android.view.View; +import android.view.View.MeasureSpec; +import com.android.messaging.util.Assert; + +import java.io.IOException; + +/** + * Contains shared code for SoftwareCameraPreview and HardwareCameraPreview, cannot use inheritance + * because those classes must inherit from separate Views, so those classes delegate calls to this + * helper class. Specifics for each implementation are in CameraPreviewHost + */ +public class CameraPreview { + public interface CameraPreviewHost { + View getView(); + boolean isValid(); + void startPreview(final Camera camera) throws IOException; + void onCameraPermissionGranted(); + + } + + private int mCameraWidth = -1; + private int mCameraHeight = -1; + + private final CameraPreviewHost mHost; + + public CameraPreview(final CameraPreviewHost host) { + Assert.notNull(host); + Assert.notNull(host.getView()); + mHost = host; + } + + public void setSize(final Camera.Size size, final int orientation) { + switch (orientation) { + case 0: + case 180: + mCameraWidth = size.width; + mCameraHeight = size.height; + break; + case 90: + case 270: + default: + mCameraWidth = size.height; + mCameraHeight = size.width; + } + mHost.getView().requestLayout(); + } + + public int getWidthMeasureSpec(final int widthMeasureSpec, final int heightMeasureSpec) { + if (mCameraHeight >= 0) { + final int width = View.MeasureSpec.getSize(widthMeasureSpec); + return MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY); + } else { + return widthMeasureSpec; + } + } + + public int getHeightMeasureSpec(final int widthMeasureSpec, final int heightMeasureSpec) { + if (mCameraHeight >= 0) { + final int orientation = getContext().getResources().getConfiguration().orientation; + final int width = View.MeasureSpec.getSize(widthMeasureSpec); + final float aspectRatio = (float) mCameraWidth / (float) mCameraHeight; + int height; + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + height = (int) (width * aspectRatio); + } else { + height = (int) (width / aspectRatio); + } + return View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY); + } else { + return heightMeasureSpec; + } + } + + public void onVisibilityChanged(final int visibility) { + if (CameraManager.hasCameraPermission()) { + if (visibility == View.VISIBLE) { + CameraManager.get().openCamera(); + } else { + CameraManager.get().closeCamera(); + } + } + } + + public Context getContext() { + return mHost.getView().getContext(); + } + + public void setOnTouchListener(final View.OnTouchListener listener) { + mHost.getView().setOnTouchListener(listener); + } + + public int getHeight() { + return mHost.getView().getHeight(); + } + + public void onAttachedToWindow() { + if (CameraManager.hasCameraPermission()) { + CameraManager.get().openCamera(); + } + } + + public void onDetachedFromWindow() { + CameraManager.get().closeCamera(); + } + + public void onRestoreInstanceState() { + if (CameraManager.hasCameraPermission()) { + CameraManager.get().openCamera(); + } + } + + public void onCameraPermissionGranted() { + CameraManager.get().openCamera(); + } + + /** + * @return True if the view is valid and prepared for the camera to start showing the preview + */ + public boolean isValid() { + return mHost.isValid(); + } + + /** + * Starts the camera preview on the current surface. Abstracts out the differences in API + * from the CameraManager + * @throws IOException Which is caught by the CameraManager to display an error + */ + public void startPreview(final Camera camera) throws IOException { + mHost.startPreview(camera); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java b/src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java new file mode 100644 index 0000000..2c36752 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.mediapicker; + +import android.app.Activity; +import android.app.Fragment; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.data.PendingAttachmentData; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.SafeAsyncTask; + +/** + * Wraps around the functionalities to allow the user to pick images from the document + * picker. Instances of this class must be tied to a Fragment which is able to delegate activity + * result callbacks. + */ +public class DocumentImagePicker { + + /** + * An interface for a listener that listens for when a document has been picked. + */ + public interface SelectionListener { + /** + * Called when an document is selected from picker. At this point, the file hasn't been + * actually loaded and staged in the temp directory, so we are passing in a pending + * MessagePartData, which the consumer should use to display a placeholder image. + * @param pendingItem a temporary attachment data for showing the placeholder state. + */ + void onDocumentSelected(PendingAttachmentData pendingItem); + } + + // The owning fragment. + private final Fragment mFragment; + + // The listener on the picker events. + private final SelectionListener mListener; + + private static final String EXTRA_PHOTO_URL = "photo_url"; + + /** + * Creates a new instance of DocumentImagePicker. + * @param activity The activity that owns the picker, or the activity that hosts the owning + * fragment. + */ + public DocumentImagePicker(final Fragment fragment, + final SelectionListener listener) { + mFragment = fragment; + mListener = listener; + } + + /** + * Intent out to open an image/video from document picker. + */ + public void launchPicker() { + UIIntents.get().launchDocumentImagePicker(mFragment); + } + + /** + * Must be called from the fragment/activity's onActivityResult(). + */ + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + if (requestCode == UIIntents.REQUEST_PICK_IMAGE_FROM_DOCUMENT_PICKER && + resultCode == Activity.RESULT_OK) { + // Sometimes called after media item has been picked from the document picker. + String url = data.getStringExtra(EXTRA_PHOTO_URL); + if (url == null) { + // we're using the builtin photo picker which supplies the return + // url as it's "data" + url = data.getDataString(); + if (url == null) { + final Bundle extras = data.getExtras(); + if (extras != null) { + final Uri uri = (Uri) extras.getParcelable(Intent.EXTRA_STREAM); + if (uri != null) { + url = uri.toString(); + } + } + } + } + + // Guard against null uri cases for when the activity returns a null/invalid intent. + if (url != null) { + final Uri uri = Uri.parse(url); + prepareDocumentForAttachment(uri); + } + } + } + + private void prepareDocumentForAttachment(final Uri documentUri) { + // Notify our listener with a PendingAttachmentData containing the metadata. + // Asynchronously get the content type for the picked image since + // ImageUtils.getContentType() potentially involves I/O and can be expensive. + new SafeAsyncTask() { + @Override + protected String doInBackgroundTimed(final Void... params) { + return ImageUtils.getContentType( + Factory.get().getApplicationContext().getContentResolver(), documentUri); + } + + @Override + protected void onPostExecute(final String contentType) { + // Ask the listener to create a temporary placeholder item to show the progress. + final PendingAttachmentData pendingItem = + PendingAttachmentData.createPendingAttachmentData(contentType, + documentUri); + mListener.onDocumentSelected(pendingItem); + } + }.executeOnThreadPool(); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java b/src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java new file mode 100644 index 0000000..fda3b19 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker; + +import android.content.Context; +import android.database.Cursor; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CursorAdapter; + +import com.android.messaging.R; +import com.android.messaging.ui.mediapicker.GalleryGridItemView.HostInterface; +import com.android.messaging.util.Assert; + +/** + * Bridges between the image cursor loaded by GalleryBoundCursorLoader and the GalleryGridView. + */ +public class GalleryGridAdapter extends CursorAdapter { + private GalleryGridItemView.HostInterface mGgivHostInterface; + + public GalleryGridAdapter(final Context context, final Cursor cursor) { + super(context, cursor, 0); + } + + public void setHostInterface(final HostInterface ggivHostInterface) { + mGgivHostInterface = ggivHostInterface; + } + + /** + * {@inheritDoc} + */ + @Override + public void bindView(final View view, final Context context, final Cursor cursor) { + Assert.isTrue(view instanceof GalleryGridItemView); + final GalleryGridItemView galleryImageView = (GalleryGridItemView) view; + galleryImageView.bind(cursor, mGgivHostInterface); + } + + /** + * {@inheritDoc} + */ + @Override + public View newView(final Context context, final Cursor cursor, final ViewGroup parent) { + final LayoutInflater layoutInflater = LayoutInflater.from(context); + return layoutInflater.inflate(R.layout.gallery_grid_item_view, parent, false); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java b/src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java new file mode 100644 index 0000000..3d71fe6 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.mediapicker; + +import android.content.Context; +import android.database.Cursor; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.TouchDelegate; +import android.view.View; +import android.widget.CheckBox; +import android.widget.FrameLayout; +import android.widget.ImageView.ScaleType; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.data.GalleryGridItemData; +import com.android.messaging.ui.AsyncImageView; +import com.android.messaging.ui.ConversationDrawables; +import com.google.common.annotations.VisibleForTesting; + +import java.util.concurrent.TimeUnit; + +/** + * Shows an item in the gallery picker grid view. Hosts an FileImageView with a checkbox. + */ +public class GalleryGridItemView extends FrameLayout { + /** + * Implemented by the owner of this GalleryGridItemView instance to communicate on media + * picking and selection events. + */ + public interface HostInterface { + void onItemClicked(View view, GalleryGridItemData data, boolean longClick); + boolean isItemSelected(GalleryGridItemData data); + boolean isMultiSelectEnabled(); + } + + @VisibleForTesting + GalleryGridItemData mData; + private AsyncImageView mImageView; + private CheckBox mCheckBox; + private HostInterface mHostInterface; + private final OnClickListener mOnClickListener = new OnClickListener() { + @Override + public void onClick(final View v) { + mHostInterface.onItemClicked(GalleryGridItemView.this, mData, false /*longClick*/); + } + }; + + public GalleryGridItemView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mData = DataModel.get().createGalleryGridItemData(); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mImageView = (AsyncImageView) findViewById(R.id.image); + mCheckBox = (CheckBox) findViewById(R.id.checkbox); + mCheckBox.setOnClickListener(mOnClickListener); + setOnClickListener(mOnClickListener); + final OnLongClickListener longClickListener = new OnLongClickListener() { + @Override + public boolean onLongClick(final View v) { + mHostInterface.onItemClicked(v, mData, true /* longClick */); + return true; + } + }; + setOnLongClickListener(longClickListener); + mCheckBox.setOnLongClickListener(longClickListener); + addOnLayoutChangeListener(new OnLayoutChangeListener() { + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, + int oldLeft, int oldTop, int oldRight, int oldBottom) { + // Enlarge the clickable region for the checkbox to fill the entire view. + final Rect region = new Rect(0, 0, getWidth(), getHeight()); + setTouchDelegate(new TouchDelegate(region, mCheckBox) { + @Override + public boolean onTouchEvent(MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + setPressed(true); + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + setPressed(false); + break; + } + return super.onTouchEvent(event); + } + }); + } + }); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + // The grid view auto-fit the columns, so we want to let the height match the width + // to make the image square. + super.onMeasure(widthMeasureSpec, widthMeasureSpec); + } + + public void bind(final Cursor cursor, final HostInterface hostInterface) { + final int desiredSize = getResources() + .getDimensionPixelSize(R.dimen.gallery_image_cell_size); + mData.bind(cursor, desiredSize, desiredSize); + mHostInterface = hostInterface; + updateViewState(); + } + + private void updateViewState() { + updateImageView(); + if (mHostInterface.isMultiSelectEnabled() && !mData.isDocumentPickerItem()) { + mCheckBox.setVisibility(VISIBLE); + mCheckBox.setClickable(true); + mCheckBox.setChecked(mHostInterface.isItemSelected(mData)); + } else { + mCheckBox.setVisibility(GONE); + mCheckBox.setClickable(false); + } + } + + private void updateImageView() { + if (mData.isDocumentPickerItem()) { + mImageView.setScaleType(ScaleType.CENTER); + setBackgroundColor(ConversationDrawables.get().getConversationThemeColor()); + mImageView.setImageResourceId(null); + mImageView.setImageResource(R.drawable.ic_photo_library_light); + mImageView.setContentDescription(getResources().getString( + R.string.pick_image_from_document_library_content_description)); + } else { + mImageView.setScaleType(ScaleType.CENTER_CROP); + setBackgroundColor(getResources().getColor(R.color.gallery_image_default_background)); + mImageView.setImageResourceId(mData.getImageRequestDescriptor()); + final long dateSeconds = mData.getDateSeconds(); + final boolean isValidDate = (dateSeconds > 0); + final int templateId = isValidDate ? + R.string.mediapicker_gallery_image_item_description : + R.string.mediapicker_gallery_image_item_description_no_date; + String contentDescription = String.format(getResources().getString(templateId), + dateSeconds * TimeUnit.SECONDS.toMillis(1)); + mImageView.setContentDescription(contentDescription); + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryGridView.java b/src/com/android/messaging/ui/mediapicker/GalleryGridView.java new file mode 100644 index 0000000..a5a7dad --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/GalleryGridView.java @@ -0,0 +1,315 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.mediapicker; + +import android.content.Context; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.v4.util.ArrayMap; +import android.util.AttributeSet; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; + +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.binding.ImmutableBindingRef; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.GalleryGridItemData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener; +import com.android.messaging.ui.PersistentInstanceState; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; + +import java.util.Iterator; +import java.util.Map; + +/** + * Shows a list of galley images from external storage in a GridView with multi-select + * capabilities, and with the option to intent out to a standalone image picker. + */ +public class GalleryGridView extends MediaPickerGridView implements + GalleryGridItemView.HostInterface, + PersistentInstanceState, + DraftMessageDataListener { + /** + * Implemented by the owner of this GalleryGridView instance to communicate on image + * picking and multi-image selection events. + */ + public interface GalleryGridViewListener { + void onDocumentPickerItemClicked(); + void onItemSelected(MessagePartData item); + void onItemUnselected(MessagePartData item); + void onConfirmSelection(); + void onUpdate(); + } + + private GalleryGridViewListener mListener; + + // TODO: Consider putting this into the data model object if we add more states. + private final ArrayMap mSelectedImages; + private boolean mIsMultiSelectMode = false; + private ImmutableBindingRef mDraftMessageDataModel; + + public GalleryGridView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mSelectedImages = new ArrayMap(); + } + + public void setHostInterface(final GalleryGridViewListener hostInterface) { + mListener = hostInterface; + } + + public void setDraftMessageDataModel(final BindingBase dataModel) { + mDraftMessageDataModel = BindingBase.createBindingReference(dataModel); + mDraftMessageDataModel.getData().addListener(this); + } + + @Override + public void onItemClicked(final View view, final GalleryGridItemData data, + final boolean longClick) { + if (data.isDocumentPickerItem()) { + mListener.onDocumentPickerItemClicked(); + } else if (ContentType.isMediaType(data.getContentType())) { + if (longClick) { + // Turn on multi-select mode when an item is long-pressed. + setMultiSelectEnabled(true); + } + + final Rect startRect = new Rect(); + view.getGlobalVisibleRect(startRect); + if (isMultiSelectEnabled()) { + toggleItemSelection(startRect, data); + } else { + mListener.onItemSelected(data.constructMessagePartData(startRect)); + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, + "Selected item has invalid contentType " + data.getContentType()); + } + } + + @Override + public boolean isItemSelected(final GalleryGridItemData data) { + return mSelectedImages.containsKey(data.getImageUri()); + } + + int getSelectionCount() { + return mSelectedImages.size(); + } + + @Override + public boolean isMultiSelectEnabled() { + return mIsMultiSelectMode; + } + + private void toggleItemSelection(final Rect startRect, final GalleryGridItemData data) { + Assert.isTrue(isMultiSelectEnabled()); + if (isItemSelected(data)) { + final MessagePartData item = mSelectedImages.remove(data.getImageUri()); + mListener.onItemUnselected(item); + if (mSelectedImages.size() == 0) { + // No image is selected any more, turn off multi-select mode. + setMultiSelectEnabled(false); + } + } else { + final MessagePartData item = data.constructMessagePartData(startRect); + mSelectedImages.put(data.getImageUri(), item); + mListener.onItemSelected(item); + } + invalidateViews(); + } + + private void toggleMultiSelect() { + mIsMultiSelectMode = !mIsMultiSelectMode; + invalidateViews(); + } + + private void setMultiSelectEnabled(final boolean enabled) { + if (mIsMultiSelectMode != enabled) { + toggleMultiSelect(); + } + } + + private boolean canToggleMultiSelect() { + // We allow the user to toggle multi-select mode only when nothing has selected. If + // something has been selected, we show a confirm button instead. + return mSelectedImages.size() == 0; + } + + public void onCreateOptionsMenu(final MenuInflater inflater, final Menu menu) { + inflater.inflate(R.menu.gallery_picker_menu, menu); + final MenuItem toggleMultiSelect = menu.findItem(R.id.action_multiselect); + final MenuItem confirmMultiSelect = menu.findItem(R.id.action_confirm_multiselect); + final boolean canToggleMultiSelect = canToggleMultiSelect(); + toggleMultiSelect.setVisible(canToggleMultiSelect); + confirmMultiSelect.setVisible(!canToggleMultiSelect); + } + + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case R.id.action_multiselect: + Assert.isTrue(canToggleMultiSelect()); + toggleMultiSelect(); + return true; + + case R.id.action_confirm_multiselect: + Assert.isTrue(!canToggleMultiSelect()); + mListener.onConfirmSelection(); + return true; + } + return false; + } + + + @Override + public void onDraftChanged(final DraftMessageData data, final int changeFlags) { + mDraftMessageDataModel.ensureBound(data); + // Whenever attachment changed, refresh selection state to remove those that are not + // selected. + if ((changeFlags & DraftMessageData.ATTACHMENTS_CHANGED) == + DraftMessageData.ATTACHMENTS_CHANGED) { + refreshImageSelectionStateOnAttachmentChange(); + } + } + + @Override + public void onDraftAttachmentLimitReached(final DraftMessageData data) { + mDraftMessageDataModel.ensureBound(data); + // Whenever draft attachment limit is reach, refresh selection state to remove those + // not actually added to draft. + refreshImageSelectionStateOnAttachmentChange(); + } + + @Override + public void onDraftAttachmentLoadFailed() { + // Nothing to do since the failed attachment gets removed automatically. + } + + private void refreshImageSelectionStateOnAttachmentChange() { + boolean changed = false; + final Iterator> iterator = + mSelectedImages.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (!mDraftMessageDataModel.getData().containsAttachment(entry.getKey())) { + iterator.remove(); + changed = true; + } + } + + if (changed) { + mListener.onUpdate(); + invalidateViews(); + } + } + + @Override // PersistentInstanceState + public Parcelable saveState() { + return onSaveInstanceState(); + } + + @Override // PersistentInstanceState + public void restoreState(final Parcelable restoredState) { + onRestoreInstanceState(restoredState); + invalidateViews(); + } + + @Override + public Parcelable onSaveInstanceState() { + final Parcelable superState = super.onSaveInstanceState(); + final SavedState savedState = new SavedState(superState); + savedState.isMultiSelectMode = mIsMultiSelectMode; + savedState.selectedImages = mSelectedImages.values() + .toArray(new MessagePartData[mSelectedImages.size()]); + return savedState; + } + + @Override + public void onRestoreInstanceState(final Parcelable state) { + if (!(state instanceof SavedState)) { + super.onRestoreInstanceState(state); + return; + } + + final SavedState savedState = (SavedState) state; + super.onRestoreInstanceState(savedState.getSuperState()); + mIsMultiSelectMode = savedState.isMultiSelectMode; + mSelectedImages.clear(); + for (int i = 0; i < savedState.selectedImages.length; i++) { + final MessagePartData selectedImage = savedState.selectedImages[i]; + mSelectedImages.put(selectedImage.getContentUri(), selectedImage); + } + } + + @Override // PersistentInstanceState + public void resetState() { + mSelectedImages.clear(); + mIsMultiSelectMode = false; + invalidateViews(); + } + + public static class SavedState extends BaseSavedState { + boolean isMultiSelectMode; + MessagePartData[] selectedImages; + + SavedState(final Parcelable superState) { + super(superState); + } + + private SavedState(final Parcel in) { + super(in); + isMultiSelectMode = in.readInt() == 1 ? true : false; + + // Read parts + final int partCount = in.readInt(); + selectedImages = new MessagePartData[partCount]; + for (int i = 0; i < partCount; i++) { + selectedImages[i] = ((MessagePartData) in.readParcelable( + MessagePartData.class.getClassLoader())); + } + } + + @Override + public void writeToParcel(final Parcel out, final int flags) { + super.writeToParcel(out, flags); + out.writeInt(isMultiSelectMode ? 1 : 0); + + // Write parts + out.writeInt(selectedImages.length); + for (final MessagePartData image : selectedImages) { + out.writeParcelable(image, flags); + } + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + @Override + public SavedState createFromParcel(final Parcel in) { + return new SavedState(in); + } + @Override + public SavedState[] newArray(final int size) { + return new SavedState[size]; + } + }; + } +} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java b/src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java new file mode 100644 index 0000000..9422386 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java @@ -0,0 +1,230 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker; + +import android.Manifest; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.MergeCursor; +import android.support.v7.app.ActionBar; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.GalleryGridItemData; +import com.android.messaging.datamodel.data.MediaPickerData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.MediaPickerData.MediaPickerDataListener; +import com.android.messaging.util.Assert; +import com.android.messaging.util.OsUtil; + +/** + * Chooser which allows the user to select one or more existing images or videos + */ +class GalleryMediaChooser extends MediaChooser implements + GalleryGridView.GalleryGridViewListener, MediaPickerDataListener { + private final GalleryGridAdapter mAdapter; + private GalleryGridView mGalleryGridView; + private View mMissingPermissionView; + + GalleryMediaChooser(final MediaPicker mediaPicker) { + super(mediaPicker); + mAdapter = new GalleryGridAdapter(Factory.get().getApplicationContext(), null); + } + + @Override + public int getSupportedMediaTypes() { + return MediaPicker.MEDIA_TYPE_IMAGE | MediaPicker.MEDIA_TYPE_VIDEO; + } + + @Override + public View destroyView() { + mGalleryGridView.setAdapter(null); + mAdapter.setHostInterface(null); + // The loader is started only if startMediaPickerDataLoader() is called + if (OsUtil.hasStoragePermission()) { + mBindingRef.getData().destroyLoader(MediaPickerData.GALLERY_IMAGE_LOADER); + } + return super.destroyView(); + } + + @Override + public int getIconResource() { + return R.drawable.ic_image_light; + } + + @Override + public int getIconDescriptionResource() { + return R.string.mediapicker_galleryChooserDescription; + } + + @Override + public boolean canSwipeDown() { + return mGalleryGridView.canSwipeDown(); + } + + @Override + public void onItemSelected(final MessagePartData item) { + mMediaPicker.dispatchItemsSelected(item, !mGalleryGridView.isMultiSelectEnabled()); + } + + @Override + public void onItemUnselected(final MessagePartData item) { + mMediaPicker.dispatchItemUnselected(item); + } + + @Override + public void onConfirmSelection() { + // The user may only confirm if multiselect is enabled. + Assert.isTrue(mGalleryGridView.isMultiSelectEnabled()); + mMediaPicker.dispatchConfirmItemSelection(); + } + + @Override + public void onUpdate() { + mMediaPicker.invalidateOptionsMenu(); + } + + @Override + public void onCreateOptionsMenu(final MenuInflater inflater, final Menu menu) { + if (mView != null) { + mGalleryGridView.onCreateOptionsMenu(inflater, menu); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + return (mView != null) ? mGalleryGridView.onOptionsItemSelected(item) : false; + } + + @Override + protected View createView(final ViewGroup container) { + final LayoutInflater inflater = getLayoutInflater(); + final View view = inflater.inflate( + R.layout.mediapicker_image_chooser, + container /* root */, + false /* attachToRoot */); + + mGalleryGridView = (GalleryGridView) view.findViewById(R.id.gallery_grid_view); + mAdapter.setHostInterface(mGalleryGridView); + mGalleryGridView.setAdapter(mAdapter); + mGalleryGridView.setHostInterface(this); + mGalleryGridView.setDraftMessageDataModel(mMediaPicker.getDraftMessageDataModel()); + if (OsUtil.hasStoragePermission()) { + startMediaPickerDataLoader(); + } + + mMissingPermissionView = view.findViewById(R.id.missing_permission_view); + updateForPermissionState(OsUtil.hasStoragePermission()); + return view; + } + + @Override + int getActionBarTitleResId() { + return R.string.mediapicker_gallery_title; + } + + @Override + public void onDocumentPickerItemClicked() { + mMediaPicker.launchDocumentPicker(); + } + + @Override + void updateActionBar(final ActionBar actionBar) { + super.updateActionBar(actionBar); + if (mGalleryGridView == null) { + return; + } + final int selectionCount = mGalleryGridView.getSelectionCount(); + if (selectionCount > 0 && mGalleryGridView.isMultiSelectEnabled()) { + actionBar.setTitle(getContext().getResources().getString( + R.string.mediapicker_gallery_title_selection, + selectionCount)); + } + } + + @Override + public void onMediaPickerDataUpdated(final MediaPickerData mediaPickerData, final Object data, + final int loaderId) { + mBindingRef.ensureBound(mediaPickerData); + Assert.equals(MediaPickerData.GALLERY_IMAGE_LOADER, loaderId); + Cursor rawCursor = null; + if (data instanceof Cursor) { + rawCursor = (Cursor) data; + } + // Before delivering the cursor, wrap around the local gallery cursor + // with an extra item for document picker integration in the front. + final MatrixCursor specialItemsCursor = + new MatrixCursor(GalleryGridItemData.SPECIAL_ITEM_COLUMNS); + specialItemsCursor.addRow(new Object[] { GalleryGridItemData.ID_DOCUMENT_PICKER_ITEM }); + final MergeCursor cursor = + new MergeCursor(new Cursor[] { specialItemsCursor, rawCursor }); + mAdapter.swapCursor(cursor); + } + + @Override + public void onResume() { + if (OsUtil.hasStoragePermission()) { + // Work around a bug in MediaStore where cursors querying the Files provider don't get + // updated for changes to Images.Media or Video.Media. + startMediaPickerDataLoader(); + } + } + + @Override + protected void setSelected(final boolean selected) { + super.setSelected(selected); + if (selected && !OsUtil.hasStoragePermission()) { + mMediaPicker.requestPermissions( + new String[] { Manifest.permission.READ_EXTERNAL_STORAGE }, + MediaPicker.GALLERY_PERMISSION_REQUEST_CODE); + } + } + + private void startMediaPickerDataLoader() { + mBindingRef.getData().startLoader(MediaPickerData.GALLERY_IMAGE_LOADER, mBindingRef, null, + this); + } + + @Override + protected void onRequestPermissionsResult( + final int requestCode, final String permissions[], final int[] grantResults) { + if (requestCode == MediaPicker.GALLERY_PERMISSION_REQUEST_CODE) { + final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED; + if (permissionGranted) { + startMediaPickerDataLoader(); + } + updateForPermissionState(permissionGranted); + } + } + + private void updateForPermissionState(final boolean granted) { + // onRequestPermissionsResult can sometimes get called before createView(). + if (mGalleryGridView == null) { + return; + } + + mGalleryGridView.setVisibility(granted ? View.VISIBLE : View.GONE); + mMissingPermissionView.setVisibility(granted ? View.GONE : View.VISIBLE); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java b/src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java new file mode 100644 index 0000000..45d9579 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker; + +import android.content.Context; +import android.graphics.SurfaceTexture; +import android.hardware.Camera; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.view.TextureView; +import android.view.View; + +import java.io.IOException; + +/** + * A hardware accelerated preview texture for the camera. This is the preferred CameraPreview + * because it animates smoother. When hardware acceleration isn't available, SoftwareCameraPreview + * is used. + * + * There is a significant amount of duplication between HardwareCameraPreview and + * SoftwareCameraPreview which we can't easily share due to a lack of multiple inheritance, The + * implementations of the shared methods are delegated to CameraPreview + */ +public class HardwareCameraPreview extends TextureView implements CameraPreview.CameraPreviewHost { + private CameraPreview mPreview; + + public HardwareCameraPreview(final Context context, final AttributeSet attrs) { + super(context, attrs); + mPreview = new CameraPreview(this); + setSurfaceTextureListener(new SurfaceTextureListener() { + @Override + public void onSurfaceTextureAvailable(final SurfaceTexture surfaceTexture, final int i, final int i2) { + CameraManager.get().setSurface(mPreview); + } + + @Override + public void onSurfaceTextureSizeChanged(final SurfaceTexture surfaceTexture, final int i, final int i2) { + CameraManager.get().setSurface(mPreview); + } + + @Override + public boolean onSurfaceTextureDestroyed(final SurfaceTexture surfaceTexture) { + CameraManager.get().setSurface(null); + return true; + } + + @Override + public void onSurfaceTextureUpdated(final SurfaceTexture surfaceTexture) { + CameraManager.get().setSurface(mPreview); + } + }); + } + + @Override + protected void onVisibilityChanged(final View changedView, final int visibility) { + super.onVisibilityChanged(changedView, visibility); + mPreview.onVisibilityChanged(visibility); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mPreview.onDetachedFromWindow(); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + mPreview.onAttachedToWindow(); + } + + @Override + protected void onRestoreInstanceState(final Parcelable state) { + super.onRestoreInstanceState(state); + mPreview.onRestoreInstanceState(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + widthMeasureSpec = mPreview.getWidthMeasureSpec(widthMeasureSpec, heightMeasureSpec); + heightMeasureSpec = mPreview.getHeightMeasureSpec(widthMeasureSpec, heightMeasureSpec); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + @Override + public View getView() { + return this; + } + + @Override + public boolean isValid() { + return getSurfaceTexture() != null; + } + + @Override + public void startPreview(final Camera camera) throws IOException { + camera.setPreviewTexture(getSurfaceTexture()); + } + + @Override + public void onCameraPermissionGranted() { + mPreview.onCameraPermissionGranted(); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/ImagePersistTask.java b/src/com/android/messaging/ui/mediapicker/ImagePersistTask.java new file mode 100644 index 0000000..637eb84 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/ImagePersistTask.java @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.net.Uri; + +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.exif.ExifInterface; +import com.android.messaging.util.exif.ExifTag; + +import java.io.IOException; +import java.io.OutputStream; + +public class ImagePersistTask extends SafeAsyncTask { + private static final String JPEG_EXTENSION = "jpg"; + private static final String TAG = LogUtil.BUGLE_TAG; + + private int mWidth; + private int mHeight; + private final float mHeightPercent; + private final byte[] mBytes; + private final Context mContext; + private final CameraManager.MediaCallback mCallback; + private Uri mOutputUri; + private Exception mException; + + public ImagePersistTask( + final int width, + final int height, + final float heightPercent, + final byte[] bytes, + final Context context, + final CameraManager.MediaCallback callback) { + Assert.isTrue(heightPercent >= 0 && heightPercent <= 1); + Assert.notNull(bytes); + Assert.notNull(context); + Assert.notNull(callback); + mWidth = width; + mHeight = height; + mHeightPercent = heightPercent; + mBytes = bytes; + mContext = context; + mCallback = callback; + // TODO: We probably want to store directly in MMS storage to prevent this + // intermediate step + mOutputUri = MediaScratchFileProvider.buildMediaScratchSpaceUri(JPEG_EXTENSION); + } + + @Override + protected Void doInBackgroundTimed(final Void... params) { + OutputStream outputStream = null; + Bitmap bitmap = null; + Bitmap clippedBitmap = null; + try { + outputStream = + mContext.getContentResolver().openOutputStream(mOutputUri); + if (mHeightPercent != 1.0f) { + int orientation = android.media.ExifInterface.ORIENTATION_UNDEFINED; + final ExifInterface exifInterface = new ExifInterface(); + try { + exifInterface.readExif(mBytes); + final Integer orientationValue = + exifInterface.getTagIntValue(ExifInterface.TAG_ORIENTATION); + if (orientationValue != null) { + orientation = orientationValue.intValue(); + } + // The thumbnail is of the full image, but we're cropping it, so just clear + // the thumbnail + exifInterface.setCompressedThumbnail((byte[]) null); + } catch (IOException e) { + // Couldn't get exif tags, not the end of the world + } + bitmap = BitmapFactory.decodeByteArray(mBytes, 0, mBytes.length); + final int clippedWidth; + final int clippedHeight; + if (ExifInterface.getOrientationParams(orientation).invertDimensions) { + Assert.equals(mWidth, bitmap.getHeight()); + Assert.equals(mHeight, bitmap.getWidth()); + clippedWidth = (int) (mHeight * mHeightPercent); + clippedHeight = mWidth; + } else { + Assert.equals(mWidth, bitmap.getWidth()); + Assert.equals(mHeight, bitmap.getHeight()); + clippedWidth = mWidth; + clippedHeight = (int) (mHeight * mHeightPercent); + } + final int offsetTop = (bitmap.getHeight() - clippedHeight) / 2; + final int offsetLeft = (bitmap.getWidth() - clippedWidth) / 2; + mWidth = clippedWidth; + mHeight = clippedHeight; + clippedBitmap = Bitmap.createBitmap(clippedWidth, clippedHeight, + Bitmap.Config.ARGB_8888); + clippedBitmap.setDensity(bitmap.getDensity()); + final Canvas clippedBitmapCanvas = new Canvas(clippedBitmap); + final Matrix matrix = new Matrix(); + matrix.postTranslate(-offsetLeft, -offsetTop); + clippedBitmapCanvas.drawBitmap(bitmap, matrix, null /* paint */); + clippedBitmapCanvas.save(); + // EXIF data can take a big chunk of the file size and is often cleared by the + // carrier, only store orientation since that's critical + ExifTag orientationTag = exifInterface.getTag(ExifInterface.TAG_ORIENTATION); + exifInterface.clearExif(); + exifInterface.setTag(orientationTag); + exifInterface.writeExif(clippedBitmap, outputStream); + } else { + outputStream.write(mBytes); + } + } catch (final IOException e) { + mOutputUri = null; + mException = e; + LogUtil.e(TAG, "Unable to persist image to temp storage " + e); + } finally { + if (bitmap != null) { + bitmap.recycle(); + } + + if (clippedBitmap != null) { + clippedBitmap.recycle(); + } + + if (outputStream != null) { + try { + outputStream.flush(); + } catch (final IOException e) { + mOutputUri = null; + mException = e; + LogUtil.e(TAG, "error trying to flush and close the outputStream" + e); + } finally { + try { + outputStream.close(); + } catch (final IOException e) { + // Do nothing. + } + } + } + } + return null; + } + + @Override + protected void onPostExecute(final Void aVoid) { + if (mOutputUri != null) { + mCallback.onMediaReady(mOutputUri, ContentType.IMAGE_JPEG, mWidth, mHeight); + } else { + Assert.notNull(mException); + mCallback.onMediaFailed(mException); + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java b/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java new file mode 100644 index 0000000..06730a3 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java @@ -0,0 +1,223 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.mediapicker; + +import android.media.MediaRecorder; +import android.net.Uri; +import android.os.ParcelFileDescriptor; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.UiUtils; + +import java.io.IOException; + +/** + * Wraps around the functionalities of MediaRecorder, performs routine setup for audio recording + * and updates the audio level to be displayed in UI. + * + * During the start and end of a recording session, we kick off a thread that polls for audio + * levels, and updates the thread-safe AudioLevelSource instance. Consumers may bind to the + * sound level by either polling from the level source, or register for a level change callback + * on the level source object. In Bugle, the UI element (SoundLevels) polls for the sound level + * on the UI thread by using animation ticks and invalidating itself. + * + * Aside from tracking sound levels, this also encapsulates the functionality to save the file + * to the scratch space. The saved file is returned by calling stopRecording(). + */ +public class LevelTrackingMediaRecorder { + // We refresh sound level every 100ms during a recording session. + private static final int REFRESH_INTERVAL_MILLIS = 100; + + // The native amplitude returned from MediaRecorder ranges from 0~32768 (unfortunately, this + // is not a constant that's defined anywhere, but the framework's Recorder app is using the + // same hard-coded number). Therefore, a constant is needed in order to make it 0~100. + private static final int MAX_AMPLITUDE_FACTOR = 32768 / 100; + + // We want to limit the max audio file size by the max message size allowed by MmsConfig, + // plus multiplied by this fudge ratio to guarantee that we don't go over limit. + private static final float MAX_SIZE_RATIO = 0.8f; + + // Default recorder settings for Bugle. + // TODO: Do we want these to be tweakable? + private static final int MEDIA_RECORDER_AUDIO_SOURCE = MediaRecorder.AudioSource.MIC; + private static final int MEDIA_RECORDER_OUTPUT_FORMAT = MediaRecorder.OutputFormat.THREE_GPP; + private static final int MEDIA_RECORDER_AUDIO_ENCODER = MediaRecorder.AudioEncoder.AMR_NB; + + private final AudioLevelSource mLevelSource; + private Thread mRefreshLevelThread; + private MediaRecorder mRecorder; + private Uri mOutputUri; + private ParcelFileDescriptor mOutputFD; + + public LevelTrackingMediaRecorder() { + mLevelSource = new AudioLevelSource(); + } + + public AudioLevelSource getLevelSource() { + return mLevelSource; + } + + /** + * @return if we are currently in a recording session. + */ + public boolean isRecording() { + return mRecorder != null; + } + + /** + * Start a new recording session. + * @return true if a session is successfully started; false if something went wrong or if + * we are already recording. + */ + public boolean startRecording(final MediaRecorder.OnErrorListener errorListener, + final MediaRecorder.OnInfoListener infoListener, int maxSize) { + synchronized (LevelTrackingMediaRecorder.class) { + if (mRecorder == null) { + mOutputUri = MediaScratchFileProvider.buildMediaScratchSpaceUri( + ContentType.THREE_GPP_EXTENSION); + mRecorder = new MediaRecorder(); + try { + // The scratch space file is a Uri, however MediaRecorder + // API only accepts absolute FD's. Therefore, get the + // FileDescriptor from the content resolver to ensure the + // directory is created and get the file path to output the + // audio to. + maxSize *= MAX_SIZE_RATIO; + mOutputFD = Factory.get().getApplicationContext() + .getContentResolver().openFileDescriptor(mOutputUri, "w"); + mRecorder.setAudioSource(MEDIA_RECORDER_AUDIO_SOURCE); + mRecorder.setOutputFormat(MEDIA_RECORDER_OUTPUT_FORMAT); + mRecorder.setAudioEncoder(MEDIA_RECORDER_AUDIO_ENCODER); + mRecorder.setOutputFile(mOutputFD.getFileDescriptor()); + mRecorder.setMaxFileSize(maxSize); + mRecorder.setOnErrorListener(errorListener); + mRecorder.setOnInfoListener(infoListener); + mRecorder.prepare(); + mRecorder.start(); + startTrackingSoundLevel(); + return true; + } catch (final Exception e) { + // There may be a device failure or I/O failure, record the error but + // don't fail. + LogUtil.e(LogUtil.BUGLE_TAG, "Something went wrong when starting " + + "media recorder. " + e); + UiUtils.showToastAtBottom(R.string.audio_recording_start_failed); + stopRecording(); + } + } else { + Assert.fail("Trying to start a new recording session while already recording!"); + } + return false; + } + } + + /** + * Stop the current recording session. + * @return the Uri of the output file, or null if not currently recording. + */ + public Uri stopRecording() { + synchronized (LevelTrackingMediaRecorder.class) { + if (mRecorder != null) { + try { + mRecorder.stop(); + } catch (final RuntimeException ex) { + // This may happen when the recording is too short, so just drop the recording + // in this case. + LogUtil.w(LogUtil.BUGLE_TAG, "Something went wrong when stopping " + + "media recorder. " + ex); + if (mOutputUri != null) { + final Uri outputUri = mOutputUri; + SafeAsyncTask.executeOnThreadPool(new Runnable() { + @Override + public void run() { + Factory.get().getApplicationContext().getContentResolver().delete( + outputUri, null, null); + } + }); + mOutputUri = null; + } + } finally { + mRecorder.release(); + mRecorder = null; + } + } else { + Assert.fail("Not currently recording!"); + return null; + } + } + + if (mOutputFD != null) { + try { + mOutputFD.close(); + } catch (final IOException e) { + // Nothing to do + } + mOutputFD = null; + } + + stopTrackingSoundLevel(); + return mOutputUri; + } + + private int getAmplitude() { + synchronized (LevelTrackingMediaRecorder.class) { + if (mRecorder != null) { + final int maxAmplitude = mRecorder.getMaxAmplitude() / MAX_AMPLITUDE_FACTOR; + return Math.min(maxAmplitude, 100); + } else { + return 0; + } + } + } + + private void startTrackingSoundLevel() { + stopTrackingSoundLevel(); + mRefreshLevelThread = new Thread() { + @Override + public void run() { + try { + while (true) { + synchronized (LevelTrackingMediaRecorder.class) { + if (mRecorder != null) { + mLevelSource.setSpeechLevel(getAmplitude()); + } else { + // The recording session is over, finish the thread. + return; + } + } + Thread.sleep(REFRESH_INTERVAL_MILLIS); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }; + mRefreshLevelThread.start(); + } + + private void stopTrackingSoundLevel() { + if (mRefreshLevelThread != null && mRefreshLevelThread.isAlive()) { + mRefreshLevelThread.interrupt(); + mRefreshLevelThread = null; + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/MediaChooser.java b/src/com/android/messaging/ui/mediapicker/MediaChooser.java new file mode 100644 index 0000000..9ac0d1b --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/MediaChooser.java @@ -0,0 +1,216 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker; + +import android.app.FragmentManager; +import android.content.Context; +import android.support.v7.app.ActionBar; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; + +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.ImmutableBindingRef; +import com.android.messaging.datamodel.data.MediaPickerData; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; +import com.android.messaging.ui.BasePagerViewHolder; +import com.android.messaging.util.Assert; +import com.android.messaging.util.OsUtil; + +abstract class MediaChooser extends BasePagerViewHolder + implements DraftMessageSubscriptionDataProvider { + /** The media picker that the chooser is hosted in */ + protected final MediaPicker mMediaPicker; + + /** Referencing the main media picker binding to perform data loading */ + protected final ImmutableBindingRef mBindingRef; + + /** True if this is the selected chooser */ + protected boolean mSelected; + + /** True if this chooser is open */ + protected boolean mOpen; + + /** The button to show in the tab strip */ + private ImageButton mTabButton; + + /** Used by subclasses to indicate that no loader is required from the data model in order for + * this chooser to function. + */ + public static final int NO_LOADER_REQUIRED = -1; + + /** + * Initializes a new instance of the Chooser class + * @param mediaPicker The media picker that the chooser is hosted in + */ + MediaChooser(final MediaPicker mediaPicker) { + Assert.notNull(mediaPicker); + mMediaPicker = mediaPicker; + mBindingRef = mediaPicker.getMediaPickerDataBinding(); + mSelected = false; + } + + protected void setSelected(final boolean selected) { + mSelected = selected; + if (selected) { + // If we're selected, it must be open + mOpen = true; + } + if (mTabButton != null) { + mTabButton.setSelected(selected); + mTabButton.setAlpha(selected ? 1 : 0.5f); + } + } + + ImageButton getTabButton() { + return mTabButton; + } + + void onCreateTabButton(final LayoutInflater inflater, final ViewGroup parent) { + mTabButton = (ImageButton) inflater.inflate( + R.layout.mediapicker_tab_button, + parent, + false /* addToParent */); + mTabButton.setImageResource(getIconResource()); + mTabButton.setContentDescription( + inflater.getContext().getResources().getString(getIconDescriptionResource())); + setSelected(mSelected); + mTabButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + mMediaPicker.selectChooser(MediaChooser.this); + } + }); + } + + protected Context getContext() { + return mMediaPicker.getActivity(); + } + + protected FragmentManager getFragmentManager() { + return OsUtil.isAtLeastJB_MR1() ? mMediaPicker.getChildFragmentManager() : + mMediaPicker.getFragmentManager(); + } + protected LayoutInflater getLayoutInflater() { + return LayoutInflater.from(getContext()); + } + + /** Allows the chooser to handle full screen change */ + void onFullScreenChanged(final boolean fullScreen) {} + + /** Allows the chooser to handle the chooser being opened or closed */ + void onOpenedChanged(final boolean open) { + mOpen = open; + } + + /** @return The bit field of media types that this chooser can pick */ + public abstract int getSupportedMediaTypes(); + + /** @return The resource id of the icon for the chooser */ + abstract int getIconResource(); + + /** @return The resource id of the string to use for the accessibility text of the icon */ + abstract int getIconDescriptionResource(); + + /** + * Sets up the action bar to show the current state of the full-screen chooser + * @param actionBar The action bar to populate + */ + void updateActionBar(final ActionBar actionBar) { + final int actionBarTitleResId = getActionBarTitleResId(); + if (actionBarTitleResId == 0) { + actionBar.hide(); + } else { + actionBar.setCustomView(null); + actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_TITLE); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.show(); + // Use X instead of <- in the action bar + actionBar.setHomeAsUpIndicator(R.drawable.ic_remove_small_light); + actionBar.setTitle(actionBarTitleResId); + } + } + + /** + * Returns the resource Id used for the action bar title. + */ + abstract int getActionBarTitleResId(); + + /** + * Throws an exception if the media chooser object doesn't require data support. + */ + public void onDataUpdated(final Object data, final int loaderId) { + throw new IllegalStateException(); + } + + /** + * Called by the MediaPicker to determine whether this panel can be swiped down further. If + * not, then a swipe down gestured will be captured by the MediaPickerPanel to shrink the + * entire panel. + */ + public boolean canSwipeDown() { + return false; + } + + /** + * Typically the media picker is closed when the IME is opened, but this allows the chooser to + * specify that showing the IME is okay while the chooser is up + */ + public boolean canShowIme() { + return false; + } + + public boolean onBackPressed() { + return false; + } + + public void onCreateOptionsMenu(final MenuInflater inflater, final Menu menu) { + } + + public boolean onOptionsItemSelected(final MenuItem item) { + return false; + } + + public void setThemeColor(final int color) { + } + + /** + * Returns true if the chooser is owning any incoming touch events, so that the media picker + * panel won't process it and slide the panel. + */ + public boolean isHandlingTouch() { + return false; + } + + public void stopTouchHandling() { + } + + @Override + public int getConversationSelfSubId() { + return mMediaPicker.getConversationSelfSubId(); + } + + /** Optional activity life-cycle methods to be overridden by subclasses */ + public void onPause() { } + public void onResume() { } + protected void onRequestPermissionsResult( + final int requestCode, final String permissions[], final int[] grantResults) { } +} diff --git a/src/com/android/messaging/ui/mediapicker/MediaPicker.java b/src/com/android/messaging/ui/mediapicker/MediaPicker.java new file mode 100644 index 0000000..f441d09 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/MediaPicker.java @@ -0,0 +1,736 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker; + + +import android.app.Activity; +import android.app.Fragment; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import android.support.v4.view.PagerAdapter; +import android.support.v4.view.ViewPager; +import android.support.v7.app.ActionBar; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.LinearLayout; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.binding.ImmutableBindingRef; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.MediaPickerData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.PendingAttachmentData; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.FixedViewPagerAdapter; +import com.android.messaging.ui.mediapicker.DocumentImagePicker.SelectionListener; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.Assert; +import com.android.messaging.util.UiUtils; +import com.google.common.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * Fragment used to select or capture media to be added to the message + */ +public class MediaPicker extends Fragment implements DraftMessageSubscriptionDataProvider { + /** The listener interface for events from the media picker */ + public interface MediaPickerListener { + /** Called when the media picker is opened so the host can accommodate the UI */ + void onOpened(); + + /** + * Called when the media picker goes into or leaves full screen mode so the host can + * accommodate the fullscreen UI + */ + void onFullScreenChanged(boolean fullScreen); + + /** + * Called when the user selects one or more items + * @param items The list of items which were selected + */ + void onItemsSelected(Collection items, boolean dismissMediaPicker); + + /** + * Called when the user unselects one item. + */ + void onItemUnselected(MessagePartData item); + + /** + * Called when the media picker is closed. Always called immediately after onItemsSelected + */ + void onDismissed(); + + /** + * Called when media item selection is confirmed in a multi-select action. + */ + void onConfirmItemSelection(); + + /** + * Called when a pending attachment is added. + * @param pendingItem the pending attachment data being loaded. + */ + void onPendingItemAdded(PendingAttachmentData pendingItem); + + /** + * Called when a new media chooser is selected. + */ + void onChooserSelected(final int chooserIndex); + } + + /** The tag used when registering and finding this fragment */ + public static final String FRAGMENT_TAG = "mediapicker"; + + // Media type constants that the media picker supports + public static final int MEDIA_TYPE_DEFAULT = 0x0000; + public static final int MEDIA_TYPE_NONE = 0x0000; + public static final int MEDIA_TYPE_IMAGE = 0x0001; + public static final int MEDIA_TYPE_VIDEO = 0x0002; + public static final int MEDIA_TYPE_AUDIO = 0x0004; + public static final int MEDIA_TYPE_VCARD = 0x0008; + public static final int MEDIA_TYPE_LOCATION = 0x0010; + private static final int MEDA_TYPE_INVALID = 0x0020; + public static final int MEDIA_TYPE_ALL = 0xFFFF; + + /** The listener to call when events occur */ + private MediaPickerListener mListener; + + /** The handler used to dispatch calls to the listener */ + private Handler mListenerHandler; + + /** The bit flags of media types supported */ + private int mSupportedMediaTypes; + + /** The list of choosers which could be within the media picker */ + private final MediaChooser[] mChoosers; + + /** The list of currently enabled choosers */ + private final ArrayList mEnabledChoosers; + + /** The currently selected chooser */ + private MediaChooser mSelectedChooser; + + /** The main panel that controls the custom layout */ + private MediaPickerPanel mMediaPickerPanel; + + /** The linear layout that holds the icons to select individual chooser tabs */ + private LinearLayout mTabStrip; + + /** The view pager to swap between choosers */ + private ViewPager mViewPager; + + /** The current pager adapter for the view pager */ + private FixedViewPagerAdapter mPagerAdapter; + + /** True if the media picker is visible */ + private boolean mOpen; + + /** The theme color to use to make the media picker match the rest of the UI */ + private int mThemeColor; + + @VisibleForTesting + final Binding mBinding = BindingBase.createBinding(this); + + /** Handles picking image from the document picker */ + private DocumentImagePicker mDocumentImagePicker; + + /** Provides subscription-related data to access per-subscription configurations. */ + private DraftMessageSubscriptionDataProvider mSubscriptionDataProvider; + + /** Provides access to DraftMessageData associated with the current conversation */ + private ImmutableBindingRef mDraftMessageDataModel; + + public MediaPicker() { + this(Factory.get().getApplicationContext()); + } + + public MediaPicker(final Context context) { + mBinding.bind(DataModel.get().createMediaPickerData(context)); + mEnabledChoosers = new ArrayList(); + mChoosers = new MediaChooser[] { + new CameraMediaChooser(this), + new GalleryMediaChooser(this), + new AudioMediaChooser(this), + }; + + mOpen = false; + setSupportedMediaTypes(MEDIA_TYPE_ALL); + } + + private boolean mIsAttached; + private int mStartingMediaTypeOnAttach = MEDA_TYPE_INVALID; + private boolean mAnimateOnAttach; + + @Override + public void onAttach (final Activity activity) { + super.onAttach(activity); + mIsAttached = true; + if (mStartingMediaTypeOnAttach != MEDA_TYPE_INVALID) { + // open() was previously called. Do the pending open now. + doOpen(mStartingMediaTypeOnAttach, mAnimateOnAttach); + } + } + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mBinding.getData().init(getLoaderManager()); + mDocumentImagePicker = new DocumentImagePicker(this, + new SelectionListener() { + @Override + public void onDocumentSelected(final PendingAttachmentData data) { + if (mBinding.isBound()) { + dispatchPendingItemAdded(data); + } + } + }); + } + + @Override + public View onCreateView( + final LayoutInflater inflater, + final ViewGroup container, + final Bundle savedInstanceState) { + mMediaPickerPanel = (MediaPickerPanel) inflater.inflate( + R.layout.mediapicker_fragment, + container, + false); + mMediaPickerPanel.setMediaPicker(this); + mTabStrip = (LinearLayout) mMediaPickerPanel.findViewById(R.id.mediapicker_tabstrip); + mTabStrip.setBackgroundColor(mThemeColor); + for (final MediaChooser chooser : mChoosers) { + chooser.onCreateTabButton(inflater, mTabStrip); + final boolean enabled = (chooser.getSupportedMediaTypes() & mSupportedMediaTypes) != + MEDIA_TYPE_NONE; + final ImageButton tabButton = chooser.getTabButton(); + if (tabButton != null) { + tabButton.setVisibility(enabled ? View.VISIBLE : View.GONE); + mTabStrip.addView(tabButton); + } + } + + mViewPager = (ViewPager) mMediaPickerPanel.findViewById(R.id.mediapicker_view_pager); + mViewPager.setOnPageChangeListener(new ViewPager.OnPageChangeListener() { + @Override + public void onPageScrolled( + final int position, + final float positionOffset, + final int positionOffsetPixels) { + } + + @Override + public void onPageSelected(int position) { + // The position returned is relative to if we are in RtL mode. This class never + // switches the indices of the elements if we are in RtL mode so we need to + // translate the index back. For example, if the user clicked the item most to the + // right in RtL mode we would want the index to appear as 0 here, however the + // position returned would the last possible index. + if (UiUtils.isRtlMode()) { + position = mEnabledChoosers.size() - 1 - position; + } + selectChooser(mEnabledChoosers.get(position)); + } + + @Override + public void onPageScrollStateChanged(final int state) { + } + }); + // Camera initialization is expensive, so don't realize offscreen pages if not needed. + mViewPager.setOffscreenPageLimit(0); + mViewPager.setAdapter(mPagerAdapter); + final boolean isTouchExplorationEnabled = AccessibilityUtil.isTouchExplorationEnabled( + getActivity()); + mMediaPickerPanel.setFullScreenOnly(isTouchExplorationEnabled); + mMediaPickerPanel.setExpanded(mOpen, true, mEnabledChoosers.indexOf(mSelectedChooser)); + return mMediaPickerPanel; + } + + @Override + public void onPause() { + super.onPause(); + CameraManager.get().onPause(); + for (final MediaChooser chooser : mEnabledChoosers) { + chooser.onPause(); + } + } + + @Override + public void onResume() { + super.onResume(); + CameraManager.get().onResume(); + + for (final MediaChooser chooser : mEnabledChoosers) { + chooser.onResume(); + } + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + mDocumentImagePicker.onActivityResult(requestCode, resultCode, data); + } + + @Override + public void onDestroy() { + super.onDestroy(); + mBinding.unbind(); + } + + /** + * Sets the theme color to make the media picker match the surrounding UI + * @param themeColor The new theme color + */ + public void setConversationThemeColor(final int themeColor) { + mThemeColor = themeColor; + if (mTabStrip != null) { + mTabStrip.setBackgroundColor(mThemeColor); + } + + for (final MediaChooser chooser : mEnabledChoosers) { + chooser.setThemeColor(mThemeColor); + } + } + + /** + * Gets the current conversation theme color. + */ + public int getConversationThemeColor() { + return mThemeColor; + } + + public void setDraftMessageDataModel(final BindingBase draftBinding) { + mDraftMessageDataModel = Binding.createBindingReference(draftBinding); + } + + public ImmutableBindingRef getDraftMessageDataModel() { + return mDraftMessageDataModel; + } + + public void setSubscriptionDataProvider(final DraftMessageSubscriptionDataProvider provider) { + mSubscriptionDataProvider = provider; + } + + @Override + public int getConversationSelfSubId() { + return mSubscriptionDataProvider.getConversationSelfSubId(); + } + + /** + * Opens the media picker and optionally shows the chooser for the supplied media type + * @param startingMediaType The media type of the chooser to open if {@link #MEDIA_TYPE_DEFAULT} + * is used, then the default chooser from saved shared prefs is opened + */ + public void open(final int startingMediaType, final boolean animate) { + mOpen = true; + if (mIsAttached) { + doOpen(startingMediaType, animate); + } else { + // open() can get called immediately after the MediaPicker is created. In that case, + // we defer doing work as it may require an attached fragment (eg. calling + // Fragment#requestPermission) + mStartingMediaTypeOnAttach = startingMediaType; + mAnimateOnAttach = animate; + } + } + + private void doOpen(int startingMediaType, final boolean animate) { + final boolean isTouchExplorationEnabled = AccessibilityUtil.isTouchExplorationEnabled( + // getActivity() will be null at this point + Factory.get().getApplicationContext()); + + // If no specific starting type is specified (i.e. MEDIA_TYPE_DEFAULT), try to get the + // last opened chooser index from shared prefs. + if (startingMediaType == MEDIA_TYPE_DEFAULT) { + final int selectedChooserIndex = mBinding.getData().getSelectedChooserIndex(); + if (selectedChooserIndex >= 0 && selectedChooserIndex < mEnabledChoosers.size()) { + selectChooser(mEnabledChoosers.get(selectedChooserIndex)); + } else { + // This is the first time the picker is being used + if (isTouchExplorationEnabled) { + // Accessibility defaults to audio attachment mode. + startingMediaType = MEDIA_TYPE_AUDIO; + } + } + } + + if (mSelectedChooser == null) { + for (final MediaChooser chooser : mEnabledChoosers) { + if (startingMediaType == MEDIA_TYPE_DEFAULT || + (startingMediaType & chooser.getSupportedMediaTypes()) != MEDIA_TYPE_NONE) { + selectChooser(chooser); + break; + } + } + } + + if (mSelectedChooser == null) { + // Fall back to the first chooser. + selectChooser(mEnabledChoosers.get(0)); + } + + if (mMediaPickerPanel != null) { + mMediaPickerPanel.setFullScreenOnly(isTouchExplorationEnabled); + mMediaPickerPanel.setExpanded(true, animate, + mEnabledChoosers.indexOf(mSelectedChooser)); + } + } + + /** @return True if the media picker is open */ + public boolean isOpen() { + return mOpen; + } + + /** + * Sets the list of media types to allow the user to select + * @param mediaTypes The bit flags of media types to allow. Can be any combination of the + * MEDIA_TYPE_* values + */ + void setSupportedMediaTypes(final int mediaTypes) { + mSupportedMediaTypes = mediaTypes; + mEnabledChoosers.clear(); + boolean selectNextChooser = false; + for (final MediaChooser chooser : mChoosers) { + final boolean enabled = (chooser.getSupportedMediaTypes() & mSupportedMediaTypes) != + MEDIA_TYPE_NONE; + if (enabled) { + // TODO Add a way to inform the chooser which media types are supported + mEnabledChoosers.add(chooser); + if (selectNextChooser) { + selectChooser(chooser); + selectNextChooser = false; + } + } else if (mSelectedChooser == chooser) { + selectNextChooser = true; + } + final ImageButton tabButton = chooser.getTabButton(); + if (tabButton != null) { + tabButton.setVisibility(enabled ? View.VISIBLE : View.GONE); + } + } + + if (selectNextChooser && mEnabledChoosers.size() > 0) { + selectChooser(mEnabledChoosers.get(0)); + } + final MediaChooser[] enabledChoosers = new MediaChooser[mEnabledChoosers.size()]; + mEnabledChoosers.toArray(enabledChoosers); + mPagerAdapter = new FixedViewPagerAdapter(enabledChoosers); + if (mViewPager != null) { + mViewPager.setAdapter(mPagerAdapter); + } + + // Only rebind data if we are currently bound. Otherwise, we must have not + // bound to any data yet and should wait until onCreate() to bind data. + if (mBinding.isBound() && getActivity() != null) { + mBinding.unbind(); + mBinding.bind(DataModel.get().createMediaPickerData(getActivity())); + mBinding.getData().init(getLoaderManager()); + } + } + + ViewPager getViewPager() { + return mViewPager; + } + + /** Hides the media picker, and frees up any resources it’s using */ + public void dismiss(final boolean animate) { + mOpen = false; + if (mMediaPickerPanel != null) { + mMediaPickerPanel.setExpanded(false, animate, MediaPickerPanel.PAGE_NOT_SET); + } + mSelectedChooser = null; + } + + /** + * Sets the listener for the media picker events + * @param listener The listener which will receive events + */ + public void setListener(final MediaPickerListener listener) { + Assert.isMainThread(); + mListener = listener; + mListenerHandler = listener != null ? new Handler() : null; + } + + /** @return True if the media picker is in full-screen mode */ + public boolean isFullScreen() { + return mMediaPickerPanel != null && mMediaPickerPanel.isFullScreen(); + } + + public void setFullScreen(final boolean fullScreen) { + mMediaPickerPanel.setFullScreenView(fullScreen, true); + } + + public void updateActionBar(final ActionBar actionBar) { + if (getActivity() == null) { + return; + } + if (isFullScreen() && mSelectedChooser != null) { + mSelectedChooser.updateActionBar(actionBar); + } else { + actionBar.hide(); + } + } + + /** + * Selects a new chooser + * @param newSelectedChooser The newly selected chooser + */ + void selectChooser(final MediaChooser newSelectedChooser) { + if (mSelectedChooser == newSelectedChooser) { + return; + } + + if (mSelectedChooser != null) { + mSelectedChooser.setSelected(false); + } + mSelectedChooser = newSelectedChooser; + if (mSelectedChooser != null) { + mSelectedChooser.setSelected(true); + } + + final int chooserIndex = mEnabledChoosers.indexOf(mSelectedChooser); + if (mViewPager != null) { + mViewPager.setCurrentItem(chooserIndex, true /* smoothScroll */); + } + + if (isFullScreen()) { + invalidateOptionsMenu(); + } + + // Save the newly selected chooser's index so we may directly switch to it the + // next time user opens the media picker. + mBinding.getData().saveSelectedChooserIndex(chooserIndex); + if (mMediaPickerPanel != null) { + mMediaPickerPanel.onChooserChanged(); + } + dispatchChooserSelected(chooserIndex); + } + + public boolean canShowIme() { + if (mSelectedChooser != null) { + return mSelectedChooser.canShowIme(); + } + return false; + } + + public boolean onBackPressed() { + return mSelectedChooser != null && mSelectedChooser.onBackPressed(); + } + + void invalidateOptionsMenu() { + ((BugleActionBarActivity) getActivity()).supportInvalidateOptionsMenu(); + } + + void dispatchOpened() { + setHasOptionsMenu(false); + mOpen = true; + mPagerAdapter.notifyDataSetChanged(); + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onOpened(); + } + }); + } + if (mSelectedChooser != null) { + mSelectedChooser.onFullScreenChanged(false); + mSelectedChooser.onOpenedChanged(true); + } + } + + void dispatchDismissed() { + setHasOptionsMenu(false); + mOpen = false; + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onDismissed(); + } + }); + } + if (mSelectedChooser != null) { + mSelectedChooser.onOpenedChanged(false); + } + } + + void dispatchFullScreen(final boolean fullScreen) { + setHasOptionsMenu(fullScreen); + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onFullScreenChanged(fullScreen); + } + }); + } + if (mSelectedChooser != null) { + mSelectedChooser.onFullScreenChanged(fullScreen); + } + } + + void dispatchItemsSelected(final MessagePartData item, final boolean dismissMediaPicker) { + final List items = new ArrayList(1); + items.add(item); + dispatchItemsSelected(items, dismissMediaPicker); + } + + void dispatchItemsSelected(final Collection items, + final boolean dismissMediaPicker) { + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onItemsSelected(items, dismissMediaPicker); + } + }); + } + + if (isFullScreen() && !dismissMediaPicker) { + invalidateOptionsMenu(); + } + } + + void dispatchItemUnselected(final MessagePartData item) { + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onItemUnselected(item); + } + }); + } + + if (isFullScreen()) { + invalidateOptionsMenu(); + } + } + + void dispatchConfirmItemSelection() { + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onConfirmItemSelection(); + } + }); + } + } + + void dispatchPendingItemAdded(final PendingAttachmentData pendingItem) { + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onPendingItemAdded(pendingItem); + } + }); + } + + if (isFullScreen()) { + invalidateOptionsMenu(); + } + } + + void dispatchChooserSelected(final int chooserIndex) { + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onChooserSelected(chooserIndex); + } + }); + } + } + + public boolean canSwipeDownChooser() { + return mSelectedChooser == null ? false : mSelectedChooser.canSwipeDown(); + } + + public boolean isChooserHandlingTouch() { + return mSelectedChooser == null ? false : mSelectedChooser.isHandlingTouch(); + } + + public void stopChooserTouchHandling() { + if (mSelectedChooser != null) { + mSelectedChooser.stopTouchHandling(); + } + } + + boolean getChooserShowsActionBarInFullScreen() { + return mSelectedChooser == null ? false : mSelectedChooser.getActionBarTitleResId() != 0; + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + if (mSelectedChooser != null) { + mSelectedChooser.onCreateOptionsMenu(inflater, menu); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + return (mSelectedChooser != null && mSelectedChooser.onOptionsItemSelected(item)) || + super.onOptionsItemSelected(item); + } + + PagerAdapter getPagerAdapter() { + return mPagerAdapter; + } + + public void resetViewHolderState() { + mPagerAdapter.resetState(); + } + + /** + * Launch an external picker to pick item from document picker as attachment. + */ + public void launchDocumentPicker() { + mDocumentImagePicker.launchPicker(); + } + + public ImmutableBindingRef getMediaPickerDataBinding() { + return BindingBase.createBindingReference(mBinding); + } + + protected static final int CAMERA_PERMISSION_REQUEST_CODE = 1; + protected static final int LOCATION_PERMISSION_REQUEST_CODE = 2; + protected static final int RECORD_AUDIO_PERMISSION_REQUEST_CODE = 3; + protected static final int GALLERY_PERMISSION_REQUEST_CODE = 4; + + @Override + public void onRequestPermissionsResult( + final int requestCode, final String permissions[], final int[] grantResults) { + if (mSelectedChooser != null) { + mSelectedChooser.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java b/src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java new file mode 100644 index 0000000..cc3a4a1 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.GridView; + +public class MediaPickerGridView extends GridView { + + public MediaPickerGridView(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + /** + * Returns if the grid view can be swiped down further. It cannot be swiped down + * if there's no item or if we are already at the top. + */ + public boolean canSwipeDown() { + if (getAdapter() == null || getAdapter().getCount() == 0 || getChildCount() == 0) { + return false; + } + + final int firstVisiblePosition = getFirstVisiblePosition(); + if (firstVisiblePosition == 0 && getChildAt(0).getTop() >= 0) { + return false; + } + return true; + } +} diff --git a/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java b/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java new file mode 100644 index 0000000..56b0a03 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java @@ -0,0 +1,563 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker; + +import android.content.Context; +import android.content.res.Resources; +import android.os.Handler; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.Transformation; +import android.widget.LinearLayout; + +import com.android.messaging.R; +import com.android.messaging.ui.PagingAwareViewPager; +import com.android.messaging.util.Assert; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.UiUtils; + +/** + * Custom layout panel which makes the MediaPicker animations seamless and synchronized + * Designed to be very specific to the MediaPicker's usage + */ +public class MediaPickerPanel extends ViewGroup { + /** + * The window of time in which we might to decide to reinterpret the intent of a gesture. + */ + private static final long TOUCH_RECAPTURE_WINDOW_MS = 500L; + + // The two view components to layout + private LinearLayout mTabStrip; + private boolean mFullScreenOnly; + private PagingAwareViewPager mViewPager; + + /** + * True if the MediaPicker is full screen or animating into it + */ + private boolean mFullScreen; + + /** + * True if the MediaPicker is open at all + */ + private boolean mExpanded; + + /** + * The current desired height of the MediaPicker. This property may be animated and the + * measure pass uses it to determine what size the components are. + */ + private int mCurrentDesiredHeight; + + private final Handler mHandler = new Handler(); + + /** + * The media picker for dispatching events to the MediaPicker's listener + */ + private MediaPicker mMediaPicker; + + /** + * The computed default "half-screen" height of the view pager in px + */ + private final int mDefaultViewPagerHeight; + + /** + * The action bar height used to compute the padding on the view pager when it's full screen. + */ + private final int mActionBarHeight; + + private TouchHandler mTouchHandler; + + static final int PAGE_NOT_SET = -1; + + public MediaPickerPanel(final Context context, final AttributeSet attrs) { + super(context, attrs); + // Cache the computed dimension + mDefaultViewPagerHeight = getResources().getDimensionPixelSize( + R.dimen.mediapicker_default_chooser_height); + mActionBarHeight = getResources().getDimensionPixelSize(R.dimen.action_bar_height); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mTabStrip = (LinearLayout) findViewById(R.id.mediapicker_tabstrip); + mViewPager = (PagingAwareViewPager) findViewById(R.id.mediapicker_view_pager); + mTouchHandler = new TouchHandler(); + setOnTouchListener(mTouchHandler); + mViewPager.setOnTouchListener(mTouchHandler); + + // Make sure full screen mode is updated in landscape mode change when the panel is open. + addOnLayoutChangeListener(new OnLayoutChangeListener() { + private boolean mLandscapeMode = UiUtils.isLandscapeMode(); + + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, + int oldLeft, int oldTop, int oldRight, int oldBottom) { + final boolean newLandscapeMode = UiUtils.isLandscapeMode(); + if (mLandscapeMode != newLandscapeMode) { + mLandscapeMode = newLandscapeMode; + if (mExpanded) { + setExpanded(mExpanded, false /* animate */, mViewPager.getCurrentItem(), + true /* force */); + } + } + } + }); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + int requestedHeight = MeasureSpec.getSize(heightMeasureSpec); + if (mMediaPicker.getChooserShowsActionBarInFullScreen()) { + requestedHeight -= mActionBarHeight; + } + int desiredHeight = Math.min(mCurrentDesiredHeight, requestedHeight); + if (mExpanded && desiredHeight == 0) { + // If we want to be shown, we have to have a non-0 height. Returning a height of 0 will + // cause the framework to abort the animation from 0, so we must always have some + // height once we start expanding + desiredHeight = 1; + } else if (!mExpanded && desiredHeight == 0) { + mViewPager.setVisibility(View.GONE); + mViewPager.setAdapter(null); + } + + measureChild(mTabStrip, widthMeasureSpec, heightMeasureSpec); + + int tabStripHeight; + if (requiresFullScreen()) { + // Ensure that the tab strip is always visible, even in full screen. + tabStripHeight = mTabStrip.getMeasuredHeight(); + } else { + // Slide out the tab strip at the end of the animation to full screen. + tabStripHeight = Math.min(mTabStrip.getMeasuredHeight(), + requestedHeight - desiredHeight); + } + + // If we are animating and have an interim desired height, use the default height. We can't + // take the max here as on some devices the mDefaultViewPagerHeight may be too big in + // landscape mode after animation. + final int tabAdjustedDesiredHeight = desiredHeight - tabStripHeight; + final int viewPagerHeight = + tabAdjustedDesiredHeight <= 1 ? mDefaultViewPagerHeight : tabAdjustedDesiredHeight; + + int viewPagerHeightMeasureSpec = MeasureSpec.makeMeasureSpec( + viewPagerHeight, MeasureSpec.EXACTLY); + measureChild(mViewPager, widthMeasureSpec, viewPagerHeightMeasureSpec); + setMeasuredDimension(mViewPager.getMeasuredWidth(), desiredHeight); + } + + @Override + protected void onLayout(final boolean changed, final int left, final int top, final int right, + final int bottom) { + int y = top; + final int width = right - left; + + final int viewPagerHeight = mViewPager.getMeasuredHeight(); + mViewPager.layout(0, y, width, y + viewPagerHeight); + y += viewPagerHeight; + + mTabStrip.layout(0, y, width, y + mTabStrip.getMeasuredHeight()); + } + + void onChooserChanged() { + if (mFullScreen) { + setDesiredHeight(getDesiredHeight(), true); + } + } + + void setFullScreenOnly(boolean fullScreenOnly) { + mFullScreenOnly = fullScreenOnly; + } + + boolean isFullScreen() { + return mFullScreen; + } + + void setMediaPicker(final MediaPicker mediaPicker) { + mMediaPicker = mediaPicker; + } + + /** + * Get the desired height of the media picker panel for when the panel is not in motion (i.e. + * not being dragged by the user). + */ + private int getDesiredHeight() { + if (mFullScreen) { + int fullHeight = getContext().getResources().getDisplayMetrics().heightPixels; + if (OsUtil.isAtLeastKLP() && isAttachedToWindow()) { + // When we're attached to the window, we can get an accurate height, not necessary + // on older API level devices because they don't include the action bar height + View composeContainer = + getRootView().findViewById(R.id.conversation_and_compose_container); + if (composeContainer != null) { + // protect against composeContainer having been unloaded already + fullHeight -= UiUtils.getMeasuredBoundsOnScreen(composeContainer).top; + } + } + if (mMediaPicker.getChooserShowsActionBarInFullScreen()) { + return fullHeight - mActionBarHeight; + } else { + return fullHeight; + } + } else if (mExpanded) { + return LayoutParams.WRAP_CONTENT; + } else { + return 0; + } + } + + private void setupViewPager(final int startingPage) { + mViewPager.setVisibility(View.VISIBLE); + if (startingPage >= 0 && startingPage < mMediaPicker.getPagerAdapter().getCount()) { + mViewPager.setAdapter(mMediaPicker.getPagerAdapter()); + mViewPager.setCurrentItem(startingPage); + } + updateViewPager(); + } + + /** + * Expand the media picker panel. Since we always set the pager adapter to null when the panel + * is collapsed, we need to restore the adapter and the starting page. + * @param expanded expanded or collapsed + * @param animate need animation + * @param startingPage the desired selected page to start + */ + void setExpanded(final boolean expanded, final boolean animate, final int startingPage) { + setExpanded(expanded, animate, startingPage, false /* force */); + } + + private void setExpanded(final boolean expanded, final boolean animate, final int startingPage, + final boolean force) { + if (expanded == mExpanded && !force) { + return; + } + mFullScreen = false; + mExpanded = expanded; + mHandler.post(new Runnable() { + @Override + public void run() { + setDesiredHeight(getDesiredHeight(), animate); + } + }); + if (expanded) { + setupViewPager(startingPage); + mMediaPicker.dispatchOpened(); + } else { + mMediaPicker.dispatchDismissed(); + } + + // Call setFullScreenView() when we are in landscape mode so it can go full screen as + // soon as it is expanded. + if (expanded && requiresFullScreen()) { + setFullScreenView(true, animate); + } + } + + private boolean requiresFullScreen() { + return mFullScreenOnly || UiUtils.isLandscapeMode(); + } + + private void setDesiredHeight(int height, final boolean animate) { + final int startHeight = mCurrentDesiredHeight; + if (height == LayoutParams.WRAP_CONTENT) { + height = measureHeight(); + } + clearAnimation(); + if (animate) { + final int deltaHeight = height - startHeight; + final Animation animation = new Animation() { + @Override + protected void applyTransformation(final float interpolatedTime, + final Transformation t) { + mCurrentDesiredHeight = (int) (startHeight + deltaHeight * interpolatedTime); + requestLayout(); + } + + @Override + public boolean willChangeBounds() { + return true; + } + }; + animation.setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION); + animation.setInterpolator(UiUtils.EASE_OUT_INTERPOLATOR); + startAnimation(animation); + } else { + mCurrentDesiredHeight = height; + } + requestLayout(); + } + + /** + * @return The minimum total height of the view + */ + private int measureHeight() { + final int measureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE, MeasureSpec.AT_MOST); + measureChild(mTabStrip, measureSpec, measureSpec); + return mDefaultViewPagerHeight + mTabStrip.getMeasuredHeight(); + } + + /** + * Enters or leaves full screen view + * + * @param fullScreen True to enter full screen view, false to leave + * @param animate True to animate the transition + */ + void setFullScreenView(final boolean fullScreen, final boolean animate) { + if (fullScreen == mFullScreen) { + return; + } + + if (requiresFullScreen() && !fullScreen) { + setExpanded(false /* expanded */, true /* animate */, PAGE_NOT_SET); + return; + } + mFullScreen = fullScreen; + setDesiredHeight(getDesiredHeight(), animate); + mMediaPicker.dispatchFullScreen(mFullScreen); + updateViewPager(); + } + + /** + * ViewPager should have its paging disabled when in full screen mode. + */ + private void updateViewPager() { + mViewPager.setPagingEnabled(!mFullScreen); + } + + @Override + public boolean onInterceptTouchEvent(final MotionEvent ev) { + return mTouchHandler.onInterceptTouchEvent(ev) || super.onInterceptTouchEvent(ev); + } + + /** + * Helper class to handle touch events and swipe gestures + */ + private class TouchHandler implements OnTouchListener { + /** + * The height of the view when the touch press started + */ + private int mDownHeight = -1; + + /** + * True if the panel moved at all (changed height) during the drag + */ + private boolean mMoved = false; + + // The threshold constants converted from DP to px + private final float mFlingThresholdPx; + private final float mBigFlingThresholdPx; + + // The system defined pixel size to determine when a movement is considered a drag. + private final int mTouchSlop; + + /** + * A copy of the MotionEvent that started the drag/swipe gesture + */ + private MotionEvent mDownEvent; + + /** + * Whether we are currently moving down. We may not be able to move down in full screen + * mode when the child view can swipe down (such as a list view). + */ + private boolean mMovedDown = false; + + /** + * Indicates whether the child view contained in the panel can swipe down at the beginning + * of the drag event (i.e. the initial down). The MediaPanel can contain + * scrollable children such as a list view / grid view. If the child view can swipe down, + * We want to let the child view handle the scroll first instead of handling it ourselves. + */ + private boolean mCanChildViewSwipeDown = false; + + /** + * Necessary direction ratio for a fling to be considered in one direction this prevents + * horizontal swipes with small vertical components from triggering vertical swipe actions + */ + private static final float DIRECTION_RATIO = 1.1f; + + TouchHandler() { + final Resources resources = getContext().getResources(); + final ViewConfiguration configuration = ViewConfiguration.get(getContext()); + mFlingThresholdPx = resources.getDimensionPixelSize( + R.dimen.mediapicker_fling_threshold); + mBigFlingThresholdPx = resources.getDimensionPixelSize( + R.dimen.mediapicker_big_fling_threshold); + mTouchSlop = configuration.getScaledTouchSlop(); + } + + /** + * The media picker panel may contain scrollable children such as a GridView, which eats + * all touch events before we get to it. Therefore, we'd like to intercept these events + * before the children to determine if we should handle swiping down in full screen mode. + * In non-full screen mode, we should handle all vertical scrolling events and leave + * horizontal scrolling to the view pager. + */ + public boolean onInterceptTouchEvent(final MotionEvent ev) { + switch (ev.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + // Never capture the initial down, so that the children may handle it + // as well. Let the touch handler know about the down event as well. + mTouchHandler.onTouch(MediaPickerPanel.this, ev); + + // Ask the MediaPicker whether the contained view can be swiped down. + // We record the value at the start of the drag to decide the swiping mode + // for the entire motion. + mCanChildViewSwipeDown = mMediaPicker.canSwipeDownChooser(); + return false; + + case MotionEvent.ACTION_MOVE: { + if (mMediaPicker.isChooserHandlingTouch()) { + if (shouldAllowRecaptureTouch(ev)) { + mMediaPicker.stopChooserTouchHandling(); + mViewPager.setPagingEnabled(true); + return false; + } + // If the chooser is claiming ownership on all touch events, then we + // shouldn't try to handle them (neither should the view pager). + mViewPager.setPagingEnabled(false); + return false; + } else if (mCanChildViewSwipeDown) { + // Never capture event if the child view can swipe down. + return false; + } else if (!mFullScreen && mMoved) { + // When we are not fullscreen, we own any vertical drag motion. + return true; + } else if (mMovedDown) { + // We are currently handling the down swipe ourselves, so always + // capture this event. + return true; + } else { + // The current interaction mode is undetermined, so always let the + // touch handler know about this event. However, don't capture this + // event so the child may handle it as well. + mTouchHandler.onTouch(MediaPickerPanel.this, ev); + + // Capture the touch event from now on if we are handling the drag. + return mFullScreen ? mMovedDown : mMoved; + } + } + } + return false; + } + + /** + * Determine whether we think the user is actually trying to expand or slide despite the + * fact that they touched first on a chooser that captured the input. + */ + private boolean shouldAllowRecaptureTouch(MotionEvent ev) { + final long elapsedMs = ev.getEventTime() - ev.getDownTime(); + if (mDownEvent == null || elapsedMs == 0 || elapsedMs > TOUCH_RECAPTURE_WINDOW_MS) { + // Either we don't have info to decide or it's been long enough that we no longer + // want to reinterpret user intent. + return false; + } + final float dx = ev.getRawX() - mDownEvent.getRawX(); + final float dy = ev.getRawY() - mDownEvent.getRawY(); + final float dt = elapsedMs / 1000.0f; + final float maxAbsDelta = Math.max(Math.abs(dx), Math.abs(dy)); + final float velocity = maxAbsDelta / dt; + return velocity > mFlingThresholdPx; + } + + @Override + public boolean onTouch(final View view, final MotionEvent motionEvent) { + switch (motionEvent.getAction()) { + case MotionEvent.ACTION_UP: { + if (!mMoved || mDownEvent == null) { + return false; + } + final float dx = motionEvent.getRawX() - mDownEvent.getRawX(); + final float dy = motionEvent.getRawY() - mDownEvent.getRawY(); + + final float dt = + (motionEvent.getEventTime() - mDownEvent.getEventTime()) / 1000.0f; + final float yVelocity = dy / dt; + + boolean handled = false; + + // Vertical swipe occurred if the direction is as least mostly in the y + // component and has the required velocity (px/sec) + if ((dx == 0 || (Math.abs(dy) / Math.abs(dx)) > DIRECTION_RATIO) && + Math.abs(yVelocity) > mFlingThresholdPx) { + if (yVelocity < 0 && mExpanded) { + setFullScreenView(true, true); + handled = true; + } else if (yVelocity > 0) { + if (mFullScreen && yVelocity < mBigFlingThresholdPx) { + setFullScreenView(false, true); + } else { + setExpanded(false, true, PAGE_NOT_SET); + } + handled = true; + } + } + + if (!handled) { + // If they didn't swipe enough, animate back to resting state + setDesiredHeight(getDesiredHeight(), true); + } + resetState(); + break; + } + case MotionEvent.ACTION_DOWN: { + mDownHeight = getHeight(); + mDownEvent = MotionEvent.obtain(motionEvent); + // If we are here and care about the return value (i.e. this is not called + // from onInterceptTouchEvent), then presumably no children view in the panel + // handles the down event. We'd like to handle future ACTION_MOVE events, so + // always claim ownership on this event so it doesn't fall through and gets + // cancelled by the framework. + return true; + } + case MotionEvent.ACTION_MOVE: { + if (mDownEvent == null) { + return mMoved; + } + + final float dx = mDownEvent.getRawX() - motionEvent.getRawX(); + final float dy = mDownEvent.getRawY() - motionEvent.getRawY(); + // Don't act if the move is mostly horizontal + if (Math.abs(dy) > mTouchSlop && + (Math.abs(dy) / Math.abs(dx)) > DIRECTION_RATIO) { + setDesiredHeight((int) (mDownHeight + dy), false); + mMoved = true; + if (dy < -mTouchSlop) { + mMovedDown = true; + } + } + return mMoved; + } + + } + return mMoved; + } + + private void resetState() { + mDownEvent = null; + mDownHeight = -1; + mMoved = false; + mMovedDown = false; + mCanChildViewSwipeDown = false; + updateViewPager(); + } + } +} + diff --git a/src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java b/src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java new file mode 100644 index 0000000..7ac7871 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker; + +import android.hardware.Camera; +import android.media.CamcorderProfile; +import android.media.MediaRecorder; +import android.net.Uri; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.SafeAsyncTask; + +import java.io.FileNotFoundException; + +class MmsVideoRecorder extends MediaRecorder { + private static final float VIDEO_OVERSHOOT_SLOP = .85F; + + private static final int BITS_PER_BYTE = 8; + + // We think user will expect to be able to record videos at least this long + private static final long MIN_DURATION_LIMIT_SECONDS = 25; + + /** The uri where video is being recorded to */ + private Uri mTempVideoUri; + + /** The settings used for video recording */ + private final CamcorderProfile mCamcorderProfile; + + public MmsVideoRecorder(final Camera camera, final int cameraIndex, final int orientation, + final int maxMessageSize) + throws FileNotFoundException { + mCamcorderProfile = + CamcorderProfile.get(cameraIndex, CamcorderProfile.QUALITY_LOW); + mTempVideoUri = MediaScratchFileProvider.buildMediaScratchSpaceUri( + ContentType.getExtension(getContentType())); + + // The video recorder can sometimes return a file that's larger than the max we + // say we can handle. Try to handle that overshoot by specifying an 85% limit. + final long sizeLimit = (long) (maxMessageSize * VIDEO_OVERSHOOT_SLOP); + + // The QUALITY_LOW profile might not be low enough to allow for video of a reasonable + // minimum duration. Adjust a/v bitrates to allow at least MIN_DURATION_LIMIT video + // to be recorded. + int audioBitRate = mCamcorderProfile.audioBitRate; + int videoBitRate = mCamcorderProfile.videoBitRate; + final double initialDurationLimit = sizeLimit * BITS_PER_BYTE + / (double) (audioBitRate + videoBitRate); + if (initialDurationLimit < MIN_DURATION_LIMIT_SECONDS) { + // Reduce the suggested bitrates. These bitrates are only requests, if implementation + // can't actually hit these goals it will still record video at higher rate and stop when + // it hits the size limit. + final double bitRateAdjustmentFactor = initialDurationLimit / MIN_DURATION_LIMIT_SECONDS; + audioBitRate *= bitRateAdjustmentFactor; + videoBitRate *= bitRateAdjustmentFactor; + } + + setCamera(camera); + setOrientationHint(orientation); + setAudioSource(MediaRecorder.AudioSource.CAMCORDER); + setVideoSource(MediaRecorder.VideoSource.CAMERA); + setOutputFormat(mCamcorderProfile.fileFormat); + setOutputFile( + Factory.get().getApplicationContext().getContentResolver().openFileDescriptor( + mTempVideoUri, "w").getFileDescriptor()); + + // Copy settings from CamcorderProfile to MediaRecorder + setAudioEncodingBitRate(audioBitRate); + setAudioChannels(mCamcorderProfile.audioChannels); + setAudioEncoder(mCamcorderProfile.audioCodec); + setAudioSamplingRate(mCamcorderProfile.audioSampleRate); + setVideoEncodingBitRate(videoBitRate); + setVideoEncoder(mCamcorderProfile.videoCodec); + setVideoFrameRate(mCamcorderProfile.videoFrameRate); + setVideoSize( + mCamcorderProfile.videoFrameWidth, mCamcorderProfile.videoFrameHeight); + setMaxFileSize(sizeLimit); + } + + Uri getVideoUri() { + return mTempVideoUri; + } + + int getVideoWidth() { + return mCamcorderProfile.videoFrameWidth; + } + + int getVideoHeight() { + return mCamcorderProfile.videoFrameHeight; + } + + void cleanupTempFile() { + final Uri tempUri = mTempVideoUri; + SafeAsyncTask.executeOnThreadPool(new Runnable() { + @Override + public void run() { + Factory.get().getApplicationContext().getContentResolver().delete( + tempUri, null, null); + } + }); + mTempVideoUri = null; + } + + String getContentType() { + if (mCamcorderProfile.fileFormat == OutputFormat.MPEG_4) { + return ContentType.VIDEO_MP4; + } else { + // 3GPP is the only other video format with a constant in OutputFormat + return ContentType.VIDEO_3GPP; + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/PausableChronometer.java b/src/com/android/messaging/ui/mediapicker/PausableChronometer.java new file mode 100644 index 0000000..dc8f90b --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/PausableChronometer.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.mediapicker; + +import android.content.Context; +import android.os.SystemClock; +import android.util.AttributeSet; +import android.widget.Chronometer; + +import com.android.messaging.ui.PlaybackStateView; + +/** + * A pausable Chronometer implementation. The default Chronometer in Android only stops the UI + * from updating when you call stop(), but doesn't actually pause it. This implementation adds an + * additional timestamp that tracks the timespan for the pause and compensate for that. + */ +public class PausableChronometer extends Chronometer implements PlaybackStateView { + // Keeps track of how far long the Chronometer has been tracking when it's paused. We'd like + // to start from this time the next time it's resumed. + private long mTimeWhenPaused = 0; + + public PausableChronometer(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + /** + * Reset the timer and start counting from zero. + */ + @Override + public void restart() { + reset(); + start(); + } + + /** + * Reset the timer to zero, but don't start it. + */ + @Override + public void reset() { + stop(); + setBase(SystemClock.elapsedRealtime()); + mTimeWhenPaused = 0; + } + + /** + * Resume the timer after a previous pause. + */ + @Override + public void resume() { + setBase(SystemClock.elapsedRealtime() - mTimeWhenPaused); + start(); + } + + /** + * Pause the timer. + */ + @Override + public void pause() { + stop(); + mTimeWhenPaused = SystemClock.elapsedRealtime() - getBase(); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java b/src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java new file mode 100644 index 0000000..5dc3185 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker; + +import android.content.Context; +import android.hardware.Camera; +import android.os.Parcelable; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.View; + +import java.io.IOException; + +/** + * A software rendered preview surface for the camera. This renders slower and causes more jank, so + * HardwareCameraPreview is preferred if possible. + * + * There is a significant amount of duplication between HardwareCameraPreview and + * SoftwareCameraPreview which we can't easily share due to a lack of multiple inheritance, The + * implementations of the shared methods are delegated to CameraPreview + */ +public class SoftwareCameraPreview extends SurfaceView implements CameraPreview.CameraPreviewHost { + private final CameraPreview mPreview; + + public SoftwareCameraPreview(final Context context) { + super(context); + mPreview = new CameraPreview(this); + getHolder().addCallback(new SurfaceHolder.Callback() { + @Override + public void surfaceCreated(final SurfaceHolder surfaceHolder) { + CameraManager.get().setSurface(mPreview); + } + + @Override + public void surfaceChanged(final SurfaceHolder surfaceHolder, final int format, final int width, + final int height) { + CameraManager.get().setSurface(mPreview); + } + + @Override + public void surfaceDestroyed(final SurfaceHolder surfaceHolder) { + CameraManager.get().setSurface(null); + } + }); + } + + + @Override + protected void onVisibilityChanged(final View changedView, final int visibility) { + super.onVisibilityChanged(changedView, visibility); + mPreview.onVisibilityChanged(visibility); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mPreview.onDetachedFromWindow(); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + mPreview.onAttachedToWindow(); + } + + @Override + protected void onRestoreInstanceState(final Parcelable state) { + super.onRestoreInstanceState(state); + mPreview.onRestoreInstanceState(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + widthMeasureSpec = mPreview.getWidthMeasureSpec(widthMeasureSpec, heightMeasureSpec); + heightMeasureSpec = mPreview.getHeightMeasureSpec(widthMeasureSpec, heightMeasureSpec); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + @Override + public View getView() { + return this; + } + + @Override + public boolean isValid() { + return getHolder() != null; + } + + @Override + public void startPreview(final Camera camera) throws IOException { + camera.setPreviewDisplay(getHolder()); + } + + @Override + public void onCameraPermissionGranted() { + mPreview.onCameraPermissionGranted(); + } +} + + diff --git a/src/com/android/messaging/ui/mediapicker/SoundLevels.java b/src/com/android/messaging/ui/mediapicker/SoundLevels.java new file mode 100644 index 0000000..6f4dca6 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/SoundLevels.java @@ -0,0 +1,212 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.mediapicker; + +import android.animation.ObjectAnimator; +import android.animation.TimeAnimator; +import android.animation.TimeAnimator.TimeListener; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Paint.Style; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.view.accessibility.AccessibilityNodeInfo; + +import com.android.messaging.R; +import com.android.messaging.util.LogUtil; + +/** + * This view draws circular sound levels. By default the sound levels are black, unless + * otherwise defined via {@link #mPrimaryLevelPaint}. + */ +public class SoundLevels extends View { + private static final String TAG = LogUtil.BUGLE_TAG; + private static final boolean DEBUG = false; + + private boolean mCenterDefined; + private int mCenterX; + private int mCenterY; + + // Paint for the main level meter, most closely follows the mic. + private final Paint mPrimaryLevelPaint; + + // The minimum size of the levels as a percentage of the max, that is the size when volume is 0. + private final float mMinimumLevel; + + // The minimum size of the levels, that is the size when volume is 0. + private final float mMinimumLevelSize; + + // The maximum size of the levels, that is the size when volume is 100. + private final float mMaximumLevelSize; + + // Generates clock ticks for the animation using the global animation loop. + private final TimeAnimator mSpeechLevelsAnimator; + + private float mCurrentVolume; + + // Indicates whether we should be animating the sound level. + private boolean mIsEnabled; + + // Input level is pulled from here. + private AudioLevelSource mLevelSource; + + public SoundLevels(final Context context) { + this(context, null); + } + + public SoundLevels(final Context context, final AttributeSet attrs) { + this(context, attrs, 0); + } + + public SoundLevels(final Context context, final AttributeSet attrs, final int defStyle) { + super(context, attrs, defStyle); + + // Safe source, replaced with system one when attached. + mLevelSource = new AudioLevelSource(); + mLevelSource.setSpeechLevel(0); + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SoundLevels, + defStyle, 0); + + mMaximumLevelSize = a.getDimensionPixelOffset( + R.styleable.SoundLevels_maxLevelRadius, 0); + mMinimumLevelSize = a.getDimensionPixelOffset( + R.styleable.SoundLevels_minLevelRadius, 0); + mMinimumLevel = mMinimumLevelSize / mMaximumLevelSize; + + mPrimaryLevelPaint = new Paint(); + mPrimaryLevelPaint.setColor( + a.getColor(R.styleable.SoundLevels_primaryColor, Color.BLACK)); + mPrimaryLevelPaint.setFlags(Paint.ANTI_ALIAS_FLAG); + + a.recycle(); + + // This animator generates ticks that invalidate the + // view so that the animation is synced with the global animation loop. + // TODO: We could probably remove this in favor of using postInvalidateOnAnimation + // which might improve things further. + mSpeechLevelsAnimator = new TimeAnimator(); + mSpeechLevelsAnimator.setRepeatCount(ObjectAnimator.INFINITE); + mSpeechLevelsAnimator.setTimeListener(new TimeListener() { + @Override + public void onTimeUpdate(final TimeAnimator animation, final long totalTime, + final long deltaTime) { + invalidate(); + } + }); + } + + @Override + protected void onDraw(final Canvas canvas) { + if (!mIsEnabled) { + return; + } + + if (!mCenterDefined) { + // One time computation here, because we can't rely on getWidth() to be computed at + // constructor time or in onFinishInflate :(. + mCenterX = getWidth() / 2; + mCenterY = getWidth() / 2; + mCenterDefined = true; + } + + final int level = mLevelSource.getSpeechLevel(); + // Either ease towards the target level, or decay away from it depending on whether + // its higher or lower than the current. + if (level > mCurrentVolume) { + mCurrentVolume = mCurrentVolume + ((level - mCurrentVolume) / 4); + } else { + mCurrentVolume = mCurrentVolume * 0.95f; + } + + final float radius = mMinimumLevel + (1f - mMinimumLevel) * mCurrentVolume / 100; + mPrimaryLevelPaint.setStyle(Style.FILL); + canvas.drawCircle(mCenterX, mCenterY, radius * mMaximumLevelSize, mPrimaryLevelPaint); + } + + public void setLevelSource(final AudioLevelSource source) { + if (DEBUG) { + Log.d(TAG, "Speech source set."); + } + mLevelSource = source; + } + + private void startSpeechLevelsAnimator() { + if (DEBUG) { + Log.d(TAG, "startAnimator()"); + } + if (!mSpeechLevelsAnimator.isStarted()) { + mSpeechLevelsAnimator.start(); + } + } + + private void stopSpeechLevelsAnimator() { + if (DEBUG) { + Log.d(TAG, "stopAnimator()"); + } + if (mSpeechLevelsAnimator.isStarted()) { + mSpeechLevelsAnimator.end(); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + stopSpeechLevelsAnimator(); + } + + @Override + public void setEnabled(final boolean enabled) { + if (enabled == mIsEnabled) { + return; + } + if (DEBUG) { + Log.d("TAG", "setEnabled: " + enabled); + } + super.setEnabled(enabled); + mIsEnabled = enabled; + setKeepScreenOn(enabled); + updateSpeechLevelsAnimatorState(); + } + + private void updateSpeechLevelsAnimatorState() { + if (mIsEnabled) { + startSpeechLevelsAnimator(); + } else { + stopSpeechLevelsAnimator(); + } + } + + /** + * This is required to make the View findable by uiautomator + */ + @Override + public void onInitializeAccessibilityNodeInfo(final AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); + info.setClassName(SoundLevels.class.getCanonicalName()); + } + + /** + * Set the alpha level of the sound circles. + */ + public void setPrimaryColorAlpha(final int alpha) { + mPrimaryLevelPaint.setAlpha(alpha); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java new file mode 100644 index 0000000..92ed3c1 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker.camerafocus; + +public interface FocusIndicator { + public void showStart(); + public void showSuccess(boolean timeout); + public void showFail(boolean timeout); + public void clear(); +} \ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java new file mode 100644 index 0000000..e620fc2 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java @@ -0,0 +1,589 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker.camerafocus; + +import android.graphics.Matrix; +import android.graphics.Rect; +import android.graphics.RectF; +import android.hardware.Camera.Area; +import android.hardware.Camera.Parameters; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +import java.util.ArrayList; +import java.util.List; + +/* A class that handles everything about focus in still picture mode. + * This also handles the metering area because it is the same as focus area. + * + * The test cases: + * (1) The camera has continuous autofocus. Move the camera. Take a picture when + * CAF is not in progress. + * (2) The camera has continuous autofocus. Move the camera. Take a picture when + * CAF is in progress. + * (3) The camera has face detection. Point the camera at some faces. Hold the + * shutter. Release to take a picture. + * (4) The camera has face detection. Point the camera at some faces. Single tap + * the shutter to take a picture. + * (5) The camera has autofocus. Single tap the shutter to take a picture. + * (6) The camera has autofocus. Hold the shutter. Release to take a picture. + * (7) The camera has no autofocus. Single tap the shutter and take a picture. + * (8) The camera has autofocus and supports focus area. Touch the screen to + * trigger autofocus. Take a picture. + * (9) The camera has autofocus and supports focus area. Touch the screen to + * trigger autofocus. Wait until it times out. + * (10) The camera has no autofocus and supports metering area. Touch the screen + * to change metering area. + */ +public class FocusOverlayManager { + private static final String TAG = LogUtil.BUGLE_TAG; + private static final String TRUE = "true"; + private static final String AUTO_EXPOSURE_LOCK_SUPPORTED = "auto-exposure-lock-supported"; + private static final String AUTO_WHITE_BALANCE_LOCK_SUPPORTED = + "auto-whitebalance-lock-supported"; + + private static final int RESET_TOUCH_FOCUS = 0; + private static final int RESET_TOUCH_FOCUS_DELAY = 3000; + + private int mState = STATE_IDLE; + private static final int STATE_IDLE = 0; // Focus is not active. + private static final int STATE_FOCUSING = 1; // Focus is in progress. + // Focus is in progress and the camera should take a picture after focus finishes. + private static final int STATE_FOCUSING_SNAP_ON_FINISH = 2; + private static final int STATE_SUCCESS = 3; // Focus finishes and succeeds. + private static final int STATE_FAIL = 4; // Focus finishes and fails. + + private boolean mInitialized; + private boolean mFocusAreaSupported; + private boolean mMeteringAreaSupported; + private boolean mLockAeAwbNeeded; + private boolean mAeAwbLock; + private Matrix mMatrix; + + private PieRenderer mPieRenderer; + + private int mPreviewWidth; // The width of the preview frame layout. + private int mPreviewHeight; // The height of the preview frame layout. + private boolean mMirror; // true if the camera is front-facing. + private int mDisplayOrientation; + private List mFocusArea; // focus area in driver format + private List mMeteringArea; // metering area in driver format + private String mFocusMode; + private String mOverrideFocusMode; + private Parameters mParameters; + private Handler mHandler; + Listener mListener; + + public interface Listener { + public void autoFocus(); + public void cancelAutoFocus(); + public boolean capture(); + public void setFocusParameters(); + } + + private class MainHandler extends Handler { + public MainHandler(Looper looper) { + super(looper); + } + + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case RESET_TOUCH_FOCUS: { + cancelAutoFocus(); + break; + } + } + } + } + + public FocusOverlayManager(Listener listener, Looper looper) { + mHandler = new MainHandler(looper); + mMatrix = new Matrix(); + mListener = listener; + } + + public void setFocusRenderer(PieRenderer renderer) { + mPieRenderer = renderer; + mInitialized = (mMatrix != null); + } + + public void setParameters(Parameters parameters) { + // parameters can only be null when onConfigurationChanged is called + // before camera is open. We will just return in this case, because + // parameters will be set again later with the right parameters after + // camera is open. + if (parameters == null) { + return; + } + mParameters = parameters; + mFocusAreaSupported = isFocusAreaSupported(parameters); + mMeteringAreaSupported = isMeteringAreaSupported(parameters); + mLockAeAwbNeeded = (isAutoExposureLockSupported(mParameters) || + isAutoWhiteBalanceLockSupported(mParameters)); + } + + public void setPreviewSize(int previewWidth, int previewHeight) { + if (mPreviewWidth != previewWidth || mPreviewHeight != previewHeight) { + mPreviewWidth = previewWidth; + mPreviewHeight = previewHeight; + setMatrix(); + } + } + + public void setMirror(boolean mirror) { + mMirror = mirror; + setMatrix(); + } + + public void setDisplayOrientation(int displayOrientation) { + mDisplayOrientation = displayOrientation; + setMatrix(); + } + + private void setMatrix() { + if (mPreviewWidth != 0 && mPreviewHeight != 0) { + Matrix matrix = new Matrix(); + prepareMatrix(matrix, mMirror, mDisplayOrientation, + mPreviewWidth, mPreviewHeight); + // In face detection, the matrix converts the driver coordinates to UI + // coordinates. In tap focus, the inverted matrix converts the UI + // coordinates to driver coordinates. + matrix.invert(mMatrix); + mInitialized = (mPieRenderer != null); + } + } + + private void lockAeAwbIfNeeded() { + if (mLockAeAwbNeeded && !mAeAwbLock) { + mAeAwbLock = true; + mListener.setFocusParameters(); + } + } + + private void unlockAeAwbIfNeeded() { + if (mLockAeAwbNeeded && mAeAwbLock && (mState != STATE_FOCUSING_SNAP_ON_FINISH)) { + mAeAwbLock = false; + mListener.setFocusParameters(); + } + } + + public void onShutterDown() { + if (!mInitialized) { + return; + } + + boolean autoFocusCalled = false; + if (needAutoFocusCall()) { + // Do not focus if touch focus has been triggered. + if (mState != STATE_SUCCESS && mState != STATE_FAIL) { + autoFocus(); + autoFocusCalled = true; + } + } + + if (!autoFocusCalled) { + lockAeAwbIfNeeded(); + } + } + + public void onShutterUp() { + if (!mInitialized) { + return; + } + + if (needAutoFocusCall()) { + // User releases half-pressed focus key. + if (mState == STATE_FOCUSING || mState == STATE_SUCCESS + || mState == STATE_FAIL) { + cancelAutoFocus(); + } + } + + // Unlock AE and AWB after cancelAutoFocus. Camera API does not + // guarantee setParameters can be called during autofocus. + unlockAeAwbIfNeeded(); + } + + public void doSnap() { + if (!mInitialized) { + return; + } + + // If the user has half-pressed the shutter and focus is completed, we + // can take the photo right away. If the focus mode is infinity, we can + // also take the photo. + if (!needAutoFocusCall() || (mState == STATE_SUCCESS || mState == STATE_FAIL)) { + capture(); + } else if (mState == STATE_FOCUSING) { + // Half pressing the shutter (i.e. the focus button event) will + // already have requested AF for us, so just request capture on + // focus here. + mState = STATE_FOCUSING_SNAP_ON_FINISH; + } else if (mState == STATE_IDLE) { + // We didn't do focus. This can happen if the user press focus key + // while the snapshot is still in progress. The user probably wants + // the next snapshot as soon as possible, so we just do a snapshot + // without focusing again. + capture(); + } + } + + public void onAutoFocus(boolean focused, boolean shutterButtonPressed) { + if (mState == STATE_FOCUSING_SNAP_ON_FINISH) { + // Take the picture no matter focus succeeds or fails. No need + // to play the AF sound if we're about to play the shutter + // sound. + if (focused) { + mState = STATE_SUCCESS; + } else { + mState = STATE_FAIL; + } + updateFocusUI(); + capture(); + } else if (mState == STATE_FOCUSING) { + // This happens when (1) user is half-pressing the focus key or + // (2) touch focus is triggered. Play the focus tone. Do not + // take the picture now. + if (focused) { + mState = STATE_SUCCESS; + } else { + mState = STATE_FAIL; + } + updateFocusUI(); + // If this is triggered by touch focus, cancel focus after a + // while. + if (mFocusArea != null) { + mHandler.sendEmptyMessageDelayed(RESET_TOUCH_FOCUS, RESET_TOUCH_FOCUS_DELAY); + } + if (shutterButtonPressed) { + // Lock AE & AWB so users can half-press shutter and recompose. + lockAeAwbIfNeeded(); + } + } else if (mState == STATE_IDLE) { + // User has released the focus key before focus completes. + // Do nothing. + } + } + + public void onAutoFocusMoving(boolean moving) { + if (!mInitialized) { + return; + } + + // Ignore if we have requested autofocus. This method only handles + // continuous autofocus. + if (mState != STATE_IDLE) { + return; + } + + if (moving) { + mPieRenderer.showStart(); + } else { + mPieRenderer.showSuccess(true); + } + } + + private void initializeFocusAreas(int focusWidth, int focusHeight, + int x, int y, int previewWidth, int previewHeight) { + if (mFocusArea == null) { + mFocusArea = new ArrayList(); + mFocusArea.add(new Area(new Rect(), 1)); + } + + // Convert the coordinates to driver format. + calculateTapArea(focusWidth, focusHeight, 1f, x, y, previewWidth, previewHeight, + ((Area) mFocusArea.get(0)).rect); + } + + private void initializeMeteringAreas(int focusWidth, int focusHeight, + int x, int y, int previewWidth, int previewHeight) { + if (mMeteringArea == null) { + mMeteringArea = new ArrayList(); + mMeteringArea.add(new Area(new Rect(), 1)); + } + + // Convert the coordinates to driver format. + // AE area is bigger because exposure is sensitive and + // easy to over- or underexposure if area is too small. + calculateTapArea(focusWidth, focusHeight, 1.5f, x, y, previewWidth, previewHeight, + ((Area) mMeteringArea.get(0)).rect); + } + + public void onSingleTapUp(int x, int y) { + if (!mInitialized || mState == STATE_FOCUSING_SNAP_ON_FINISH) { + return; + } + + // Let users be able to cancel previous touch focus. + if ((mFocusArea != null) && (mState == STATE_FOCUSING || + mState == STATE_SUCCESS || mState == STATE_FAIL)) { + cancelAutoFocus(); + } + // Initialize variables. + int focusWidth = mPieRenderer.getSize(); + int focusHeight = mPieRenderer.getSize(); + if (focusWidth == 0 || mPieRenderer.getWidth() == 0 || mPieRenderer.getHeight() == 0) { + return; + } + int previewWidth = mPreviewWidth; + int previewHeight = mPreviewHeight; + // Initialize mFocusArea. + if (mFocusAreaSupported) { + initializeFocusAreas(focusWidth, focusHeight, x, y, previewWidth, previewHeight); + } + // Initialize mMeteringArea. + if (mMeteringAreaSupported) { + initializeMeteringAreas(focusWidth, focusHeight, x, y, previewWidth, previewHeight); + } + + // Use margin to set the focus indicator to the touched area. + mPieRenderer.setFocus(x, y); + + // Set the focus area and metering area. + mListener.setFocusParameters(); + if (mFocusAreaSupported) { + autoFocus(); + } else { // Just show the indicator in all other cases. + updateFocusUI(); + // Reset the metering area in 3 seconds. + mHandler.removeMessages(RESET_TOUCH_FOCUS); + mHandler.sendEmptyMessageDelayed(RESET_TOUCH_FOCUS, RESET_TOUCH_FOCUS_DELAY); + } + } + + public void onPreviewStarted() { + mState = STATE_IDLE; + } + + public void onPreviewStopped() { + // If auto focus was in progress, it would have been stopped. + mState = STATE_IDLE; + resetTouchFocus(); + updateFocusUI(); + } + + public void onCameraReleased() { + onPreviewStopped(); + } + + private void autoFocus() { + LogUtil.v(TAG, "Start autofocus."); + mListener.autoFocus(); + mState = STATE_FOCUSING; + updateFocusUI(); + mHandler.removeMessages(RESET_TOUCH_FOCUS); + } + + private void cancelAutoFocus() { + LogUtil.v(TAG, "Cancel autofocus."); + + // Reset the tap area before calling mListener.cancelAutofocus. + // Otherwise, focus mode stays at auto and the tap area passed to the + // driver is not reset. + resetTouchFocus(); + mListener.cancelAutoFocus(); + mState = STATE_IDLE; + updateFocusUI(); + mHandler.removeMessages(RESET_TOUCH_FOCUS); + } + + private void capture() { + if (mListener.capture()) { + mState = STATE_IDLE; + mHandler.removeMessages(RESET_TOUCH_FOCUS); + } + } + + public String getFocusMode() { + if (mOverrideFocusMode != null) { + return mOverrideFocusMode; + } + List supportedFocusModes = mParameters.getSupportedFocusModes(); + + if (mFocusAreaSupported && mFocusArea != null) { + // Always use autofocus in tap-to-focus. + mFocusMode = Parameters.FOCUS_MODE_AUTO; + } else { + mFocusMode = Parameters.FOCUS_MODE_CONTINUOUS_PICTURE; + } + + if (!isSupported(mFocusMode, supportedFocusModes)) { + // For some reasons, the driver does not support the current + // focus mode. Fall back to auto. + if (isSupported(Parameters.FOCUS_MODE_AUTO, + mParameters.getSupportedFocusModes())) { + mFocusMode = Parameters.FOCUS_MODE_AUTO; + } else { + mFocusMode = mParameters.getFocusMode(); + } + } + return mFocusMode; + } + + public List getFocusAreas() { + return mFocusArea; + } + + public List getMeteringAreas() { + return mMeteringArea; + } + + public void updateFocusUI() { + if (!mInitialized) { + return; + } + FocusIndicator focusIndicator = mPieRenderer; + + if (mState == STATE_IDLE) { + if (mFocusArea == null) { + focusIndicator.clear(); + } else { + // Users touch on the preview and the indicator represents the + // metering area. Either focus area is not supported or + // autoFocus call is not required. + focusIndicator.showStart(); + } + } else if (mState == STATE_FOCUSING || mState == STATE_FOCUSING_SNAP_ON_FINISH) { + focusIndicator.showStart(); + } else { + if (Parameters.FOCUS_MODE_CONTINUOUS_PICTURE.equals(mFocusMode)) { + // TODO: check HAL behavior and decide if this can be removed. + focusIndicator.showSuccess(false); + } else if (mState == STATE_SUCCESS) { + focusIndicator.showSuccess(false); + } else if (mState == STATE_FAIL) { + focusIndicator.showFail(false); + } + } + } + + public void resetTouchFocus() { + if (!mInitialized) { + return; + } + + // Put focus indicator to the center. clear reset position + mPieRenderer.clear(); + + mFocusArea = null; + mMeteringArea = null; + } + + private void calculateTapArea(int focusWidth, int focusHeight, float areaMultiple, + int x, int y, int previewWidth, int previewHeight, Rect rect) { + int areaWidth = (int) (focusWidth * areaMultiple); + int areaHeight = (int) (focusHeight * areaMultiple); + int left = clamp(x - areaWidth / 2, 0, previewWidth - areaWidth); + int top = clamp(y - areaHeight / 2, 0, previewHeight - areaHeight); + + RectF rectF = new RectF(left, top, left + areaWidth, top + areaHeight); + mMatrix.mapRect(rectF); + rectFToRect(rectF, rect); + } + + /* package */ int getFocusState() { + return mState; + } + + public boolean isFocusCompleted() { + return mState == STATE_SUCCESS || mState == STATE_FAIL; + } + + public boolean isFocusingSnapOnFinish() { + return mState == STATE_FOCUSING_SNAP_ON_FINISH; + } + + public void removeMessages() { + mHandler.removeMessages(RESET_TOUCH_FOCUS); + } + + public void overrideFocusMode(String focusMode) { + mOverrideFocusMode = focusMode; + } + + public void setAeAwbLock(boolean lock) { + mAeAwbLock = lock; + } + + public boolean getAeAwbLock() { + return mAeAwbLock; + } + + private boolean needAutoFocusCall() { + String focusMode = getFocusMode(); + return !(focusMode.equals(Parameters.FOCUS_MODE_INFINITY) + || focusMode.equals(Parameters.FOCUS_MODE_FIXED) + || focusMode.equals(Parameters.FOCUS_MODE_EDOF)); + } + + public static boolean isAutoExposureLockSupported(Parameters params) { + return TRUE.equals(params.get(AUTO_EXPOSURE_LOCK_SUPPORTED)); + } + + public static boolean isAutoWhiteBalanceLockSupported(Parameters params) { + return TRUE.equals(params.get(AUTO_WHITE_BALANCE_LOCK_SUPPORTED)); + } + + public static boolean isSupported(String value, List supported) { + return supported != null && supported.indexOf(value) >= 0; + } + + public static boolean isMeteringAreaSupported(Parameters params) { + return params.getMaxNumMeteringAreas() > 0; + } + + public static boolean isFocusAreaSupported(Parameters params) { + return (params.getMaxNumFocusAreas() > 0 + && isSupported(Parameters.FOCUS_MODE_AUTO, + params.getSupportedFocusModes())); + } + + public static void prepareMatrix(Matrix matrix, boolean mirror, int displayOrientation, + int viewWidth, int viewHeight) { + // Need mirror for front camera. + matrix.setScale(mirror ? -1 : 1, 1); + // This is the value for android.hardware.Camera.setDisplayOrientation. + matrix.postRotate(displayOrientation); + // Camera driver coordinates range from (-1000, -1000) to (1000, 1000). + // UI coordinates range from (0, 0) to (width, height). + matrix.postScale(viewWidth / 2000f, viewHeight / 2000f); + matrix.postTranslate(viewWidth / 2f, viewHeight / 2f); + } + + public static int clamp(int x, int min, int max) { + Assert.isTrue(max >= min); + if (x > max) { + return max; + } + if (x < min) { + return min; + } + return x; + } + + public static void rectFToRect(RectF rectF, Rect rect) { + rect.left = Math.round(rectF.left); + rect.top = Math.round(rectF.top); + rect.right = Math.round(rectF.right); + rect.bottom = Math.round(rectF.bottom); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java b/src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java new file mode 100644 index 0000000..df6734f --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker.camerafocus; + +import android.content.Context; +import android.graphics.Canvas; +import android.view.MotionEvent; + +public abstract class OverlayRenderer implements RenderOverlay.Renderer { + + private static final String TAG = "CAM OverlayRenderer"; + protected RenderOverlay mOverlay; + + protected int mLeft, mTop, mRight, mBottom; + + protected boolean mVisible; + + public void setVisible(boolean vis) { + mVisible = vis; + update(); + } + + public boolean isVisible() { + return mVisible; + } + + // default does not handle touch + @Override + public boolean handlesTouch() { + return false; + } + + @Override + public boolean onTouchEvent(MotionEvent evt) { + return false; + } + + public abstract void onDraw(Canvas canvas); + + public void draw(Canvas canvas) { + if (mVisible) { + onDraw(canvas); + } + } + + @Override + public void setOverlay(RenderOverlay overlay) { + mOverlay = overlay; + } + + @Override + public void layout(int left, int top, int right, int bottom) { + mLeft = left; + mRight = right; + mTop = top; + mBottom = bottom; + } + + protected Context getContext() { + if (mOverlay != null) { + return mOverlay.getContext(); + } else { + return null; + } + } + + public int getWidth() { + return mRight - mLeft; + } + + public int getHeight() { + return mBottom - mTop; + } + + protected void update() { + if (mOverlay != null) { + mOverlay.update(); + } + } + +} \ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java b/src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java new file mode 100644 index 0000000..c602852 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker.camerafocus; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Path; +import android.graphics.drawable.Drawable; + +import java.util.ArrayList; +import java.util.List; + +/** + * Pie menu item + */ +public class PieItem { + + public static interface OnClickListener { + void onClick(PieItem item); + } + + private Drawable mDrawable; + private int level; + private float mCenter; + private float start; + private float sweep; + private float animate; + private int inner; + private int outer; + private boolean mSelected; + private boolean mEnabled; + private List mItems; + private Path mPath; + private OnClickListener mOnClickListener; + private float mAlpha; + + // Gray out the view when disabled + private static final float ENABLED_ALPHA = 1; + private static final float DISABLED_ALPHA = (float) 0.3; + private boolean mChangeAlphaWhenDisabled = true; + + public PieItem(Drawable drawable, int level) { + mDrawable = drawable; + this.level = level; + setAlpha(1f); + mEnabled = true; + setAnimationAngle(getAnimationAngle()); + start = -1; + mCenter = -1; + } + + public boolean hasItems() { + return mItems != null; + } + + public List getItems() { + return mItems; + } + + public void addItem(PieItem item) { + if (mItems == null) { + mItems = new ArrayList(); + } + mItems.add(item); + } + + public void setPath(Path p) { + mPath = p; + } + + public Path getPath() { + return mPath; + } + + public void setChangeAlphaWhenDisabled (boolean enable) { + mChangeAlphaWhenDisabled = enable; + } + + public void setAlpha(float alpha) { + mAlpha = alpha; + mDrawable.setAlpha((int) (255 * alpha)); + } + + public void setAnimationAngle(float a) { + animate = a; + } + + public float getAnimationAngle() { + return animate; + } + + public void setEnabled(boolean enabled) { + mEnabled = enabled; + if (mChangeAlphaWhenDisabled) { + if (mEnabled) { + setAlpha(ENABLED_ALPHA); + } else { + setAlpha(DISABLED_ALPHA); + } + } + } + + public boolean isEnabled() { + return mEnabled; + } + + public void setSelected(boolean s) { + mSelected = s; + } + + public boolean isSelected() { + return mSelected; + } + + public int getLevel() { + return level; + } + + public void setGeometry(float st, float sw, int inside, int outside) { + start = st; + sweep = sw; + inner = inside; + outer = outside; + } + + public void setFixedSlice(float center, float sweep) { + mCenter = center; + this.sweep = sweep; + } + + public float getCenter() { + return mCenter; + } + + public float getStart() { + return start; + } + + public float getStartAngle() { + return start + animate; + } + + public float getSweep() { + return sweep; + } + + public int getInnerRadius() { + return inner; + } + + public int getOuterRadius() { + return outer; + } + + public void setOnClickListener(OnClickListener listener) { + mOnClickListener = listener; + } + + public void performClick() { + if (mOnClickListener != null) { + mOnClickListener.onClick(this); + } + } + + public int getIntrinsicWidth() { + return mDrawable.getIntrinsicWidth(); + } + + public int getIntrinsicHeight() { + return mDrawable.getIntrinsicHeight(); + } + + public void setBounds(int left, int top, int right, int bottom) { + mDrawable.setBounds(left, top, right, bottom); + } + + public void draw(Canvas canvas) { + mDrawable.draw(canvas); + } + + public void setImageResource(Context context, int resId) { + Drawable d = context.getResources().getDrawable(resId).mutate(); + d.setBounds(mDrawable.getBounds()); + mDrawable = d; + setAlpha(mAlpha); + } + +} \ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java b/src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java new file mode 100644 index 0000000..ce8ca00 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java @@ -0,0 +1,825 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker.camerafocus; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.RectF; +import android.os.Handler; +import android.os.Message; +import android.view.MotionEvent; +import android.view.ViewConfiguration; +import android.view.animation.Animation; +import android.view.animation.Animation.AnimationListener; +import android.view.animation.LinearInterpolator; +import android.view.animation.Transformation; +import com.android.messaging.R; + +import java.util.ArrayList; +import java.util.List; + +public class PieRenderer extends OverlayRenderer + implements FocusIndicator { + // Sometimes continuous autofocus starts and stops several times quickly. + // These states are used to make sure the animation is run for at least some + // time. + private volatile int mState; + private ScaleAnimation mAnimation = new ScaleAnimation(); + private static final int STATE_IDLE = 0; + private static final int STATE_FOCUSING = 1; + private static final int STATE_FINISHING = 2; + private static final int STATE_PIE = 8; + + private Runnable mDisappear = new Disappear(); + private Animation.AnimationListener mEndAction = new EndAction(); + private static final int SCALING_UP_TIME = 600; + private static final int SCALING_DOWN_TIME = 100; + private static final int DISAPPEAR_TIMEOUT = 200; + private static final int DIAL_HORIZONTAL = 157; + + private static final long PIE_FADE_IN_DURATION = 200; + private static final long PIE_XFADE_DURATION = 200; + private static final long PIE_SELECT_FADE_DURATION = 300; + + private static final int MSG_OPEN = 0; + private static final int MSG_CLOSE = 1; + private static final float PIE_SWEEP = (float) (Math.PI * 2 / 3); + // geometry + private Point mCenter; + private int mRadius; + private int mRadiusInc; + + // the detection if touch is inside a slice is offset + // inbounds by this amount to allow the selection to show before the + // finger covers it + private int mTouchOffset; + + private List mItems; + + private PieItem mOpenItem; + + private Paint mSelectedPaint; + private Paint mSubPaint; + + // touch handling + private PieItem mCurrentItem; + + private Paint mFocusPaint; + private int mSuccessColor; + private int mFailColor; + private int mCircleSize; + private int mFocusX; + private int mFocusY; + private int mCenterX; + private int mCenterY; + + private int mDialAngle; + private RectF mCircle; + private RectF mDial; + private Point mPoint1; + private Point mPoint2; + private int mStartAnimationAngle; + private boolean mFocused; + private int mInnerOffset; + private int mOuterStroke; + private int mInnerStroke; + private boolean mTapMode; + private boolean mBlockFocus; + private int mTouchSlopSquared; + private Point mDown; + private boolean mOpening; + private LinearAnimation mXFade; + private LinearAnimation mFadeIn; + private volatile boolean mFocusCancelled; + + private Handler mHandler = new Handler() { + public void handleMessage(Message msg) { + switch(msg.what) { + case MSG_OPEN: + if (mListener != null) { + mListener.onPieOpened(mCenter.x, mCenter.y); + } + break; + case MSG_CLOSE: + if (mListener != null) { + mListener.onPieClosed(); + } + break; + } + } + }; + + private PieListener mListener; + + public static interface PieListener { + public void onPieOpened(int centerX, int centerY); + public void onPieClosed(); + } + + public void setPieListener(PieListener pl) { + mListener = pl; + } + + public PieRenderer(Context context) { + init(context); + } + + private void init(Context ctx) { + setVisible(false); + mItems = new ArrayList(); + Resources res = ctx.getResources(); + mRadius = (int) res.getDimensionPixelSize(R.dimen.pie_radius_start); + mCircleSize = mRadius - res.getDimensionPixelSize(R.dimen.focus_radius_offset); + mRadiusInc = (int) res.getDimensionPixelSize(R.dimen.pie_radius_increment); + mTouchOffset = (int) res.getDimensionPixelSize(R.dimen.pie_touch_offset); + mCenter = new Point(0, 0); + mSelectedPaint = new Paint(); + mSelectedPaint.setColor(Color.argb(255, 51, 181, 229)); + mSelectedPaint.setAntiAlias(true); + mSubPaint = new Paint(); + mSubPaint.setAntiAlias(true); + mSubPaint.setColor(Color.argb(200, 250, 230, 128)); + mFocusPaint = new Paint(); + mFocusPaint.setAntiAlias(true); + mFocusPaint.setColor(Color.WHITE); + mFocusPaint.setStyle(Paint.Style.STROKE); + mSuccessColor = Color.GREEN; + mFailColor = Color.RED; + mCircle = new RectF(); + mDial = new RectF(); + mPoint1 = new Point(); + mPoint2 = new Point(); + mInnerOffset = res.getDimensionPixelSize(R.dimen.focus_inner_offset); + mOuterStroke = res.getDimensionPixelSize(R.dimen.focus_outer_stroke); + mInnerStroke = res.getDimensionPixelSize(R.dimen.focus_inner_stroke); + mState = STATE_IDLE; + mBlockFocus = false; + mTouchSlopSquared = ViewConfiguration.get(ctx).getScaledTouchSlop(); + mTouchSlopSquared = mTouchSlopSquared * mTouchSlopSquared; + mDown = new Point(); + } + + public boolean showsItems() { + return mTapMode; + } + + public void addItem(PieItem item) { + // add the item to the pie itself + mItems.add(item); + } + + public void removeItem(PieItem item) { + mItems.remove(item); + } + + public void clearItems() { + mItems.clear(); + } + + public void showInCenter() { + if ((mState == STATE_PIE) && isVisible()) { + mTapMode = false; + show(false); + } else { + if (mState != STATE_IDLE) { + cancelFocus(); + } + mState = STATE_PIE; + setCenter(mCenterX, mCenterY); + mTapMode = true; + show(true); + } + } + + public void hide() { + show(false); + } + + /** + * guaranteed has center set + * @param show + */ + private void show(boolean show) { + if (show) { + mState = STATE_PIE; + // ensure clean state + mCurrentItem = null; + mOpenItem = null; + for (PieItem item : mItems) { + item.setSelected(false); + } + layoutPie(); + fadeIn(); + } else { + mState = STATE_IDLE; + mTapMode = false; + if (mXFade != null) { + mXFade.cancel(); + } + } + setVisible(show); + mHandler.sendEmptyMessage(show ? MSG_OPEN : MSG_CLOSE); + } + + private void fadeIn() { + mFadeIn = new LinearAnimation(0, 1); + mFadeIn.setDuration(PIE_FADE_IN_DURATION); + mFadeIn.setAnimationListener(new AnimationListener() { + @Override + public void onAnimationStart(Animation animation) { + } + + @Override + public void onAnimationEnd(Animation animation) { + mFadeIn = null; + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + }); + mFadeIn.startNow(); + mOverlay.startAnimation(mFadeIn); + } + + public void setCenter(int x, int y) { + mCenter.x = x; + mCenter.y = y; + // when using the pie menu, align the focus ring + alignFocus(x, y); + } + + private void layoutPie() { + int rgap = 2; + int inner = mRadius + rgap; + int outer = mRadius + mRadiusInc - rgap; + int gap = 1; + layoutItems(mItems, (float) (Math.PI / 2), inner, outer, gap); + } + + private void layoutItems(List items, float centerAngle, int inner, + int outer, int gap) { + float emptyangle = PIE_SWEEP / 16; + float sweep = (float) (PIE_SWEEP - 2 * emptyangle) / items.size(); + float angle = centerAngle - PIE_SWEEP / 2 + emptyangle + sweep / 2; + // check if we have custom geometry + // first item we find triggers custom sweep for all + // this allows us to re-use the path + for (PieItem item : items) { + if (item.getCenter() >= 0) { + sweep = item.getSweep(); + break; + } + } + Path path = makeSlice(getDegrees(0) - gap, getDegrees(sweep) + gap, + outer, inner, mCenter); + for (PieItem item : items) { + // shared between items + item.setPath(path); + if (item.getCenter() >= 0) { + angle = item.getCenter(); + } + int w = item.getIntrinsicWidth(); + int h = item.getIntrinsicHeight(); + // move views to outer border + int r = inner + (outer - inner) * 2 / 3; + int x = (int) (r * Math.cos(angle)); + int y = mCenter.y - (int) (r * Math.sin(angle)) - h / 2; + x = mCenter.x + x - w / 2; + item.setBounds(x, y, x + w, y + h); + float itemstart = angle - sweep / 2; + item.setGeometry(itemstart, sweep, inner, outer); + if (item.hasItems()) { + layoutItems(item.getItems(), angle, inner, + outer + mRadiusInc / 2, gap); + } + angle += sweep; + } + } + + private Path makeSlice(float start, float end, int outer, int inner, Point center) { + RectF bb = + new RectF(center.x - outer, center.y - outer, center.x + outer, + center.y + outer); + RectF bbi = + new RectF(center.x - inner, center.y - inner, center.x + inner, + center.y + inner); + Path path = new Path(); + path.arcTo(bb, start, end - start, true); + path.arcTo(bbi, end, start - end); + path.close(); + return path; + } + + /** + * converts a + * @param angle from 0..PI to Android degrees (clockwise starting at 3 o'clock) + * @return skia angle + */ + private float getDegrees(double angle) { + return (float) (360 - 180 * angle / Math.PI); + } + + private void startFadeOut() { + mOverlay.animate().alpha(0).setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + deselect(); + show(false); + mOverlay.setAlpha(1); + super.onAnimationEnd(animation); + } + }).setDuration(PIE_SELECT_FADE_DURATION); + } + + @Override + public void onDraw(Canvas canvas) { + float alpha = 1; + if (mXFade != null) { + alpha = mXFade.getValue(); + } else if (mFadeIn != null) { + alpha = mFadeIn.getValue(); + } + int state = canvas.save(); + if (mFadeIn != null) { + float sf = 0.9f + alpha * 0.1f; + canvas.scale(sf, sf, mCenter.x, mCenter.y); + } + drawFocus(canvas); + if (mState == STATE_FINISHING) { + canvas.restoreToCount(state); + return; + } + if ((mOpenItem == null) || (mXFade != null)) { + // draw base menu + for (PieItem item : mItems) { + drawItem(canvas, item, alpha); + } + } + if (mOpenItem != null) { + for (PieItem inner : mOpenItem.getItems()) { + drawItem(canvas, inner, (mXFade != null) ? (1 - 0.5f * alpha) : 1); + } + } + canvas.restoreToCount(state); + } + + private void drawItem(Canvas canvas, PieItem item, float alpha) { + if (mState == STATE_PIE) { + if (item.getPath() != null) { + if (item.isSelected()) { + Paint p = mSelectedPaint; + int state = canvas.save(); + float r = getDegrees(item.getStartAngle()); + canvas.rotate(r, mCenter.x, mCenter.y); + canvas.drawPath(item.getPath(), p); + canvas.restoreToCount(state); + } + alpha = alpha * (item.isEnabled() ? 1 : 0.3f); + // draw the item view + item.setAlpha(alpha); + item.draw(canvas); + } + } + } + + @Override + public boolean onTouchEvent(MotionEvent evt) { + float x = evt.getX(); + float y = evt.getY(); + int action = evt.getActionMasked(); + PointF polar = getPolar(x, y, !(mTapMode)); + if (MotionEvent.ACTION_DOWN == action) { + mDown.x = (int) evt.getX(); + mDown.y = (int) evt.getY(); + mOpening = false; + if (mTapMode) { + PieItem item = findItem(polar); + if ((item != null) && (mCurrentItem != item)) { + mState = STATE_PIE; + onEnter(item); + } + } else { + setCenter((int) x, (int) y); + show(true); + } + return true; + } else if (MotionEvent.ACTION_UP == action) { + if (isVisible()) { + PieItem item = mCurrentItem; + if (mTapMode) { + item = findItem(polar); + if (item != null && mOpening) { + mOpening = false; + return true; + } + } + if (item == null) { + mTapMode = false; + show(false); + } else if (!mOpening + && !item.hasItems()) { + item.performClick(); + startFadeOut(); + mTapMode = false; + } + return true; + } + } else if (MotionEvent.ACTION_CANCEL == action) { + if (isVisible() || mTapMode) { + show(false); + } + deselect(); + return false; + } else if (MotionEvent.ACTION_MOVE == action) { + if (polar.y < mRadius) { + if (mOpenItem != null) { + mOpenItem = null; + } else { + deselect(); + } + return false; + } + PieItem item = findItem(polar); + boolean moved = hasMoved(evt); + if ((item != null) && (mCurrentItem != item) && (!mOpening || moved)) { + // only select if we didn't just open or have moved past slop + mOpening = false; + if (moved) { + // switch back to swipe mode + mTapMode = false; + } + onEnter(item); + } + } + return false; + } + + private boolean hasMoved(MotionEvent e) { + return mTouchSlopSquared < (e.getX() - mDown.x) * (e.getX() - mDown.x) + + (e.getY() - mDown.y) * (e.getY() - mDown.y); + } + + /** + * enter a slice for a view + * updates model only + * @param item + */ + private void onEnter(PieItem item) { + if (mCurrentItem != null) { + mCurrentItem.setSelected(false); + } + if (item != null && item.isEnabled()) { + item.setSelected(true); + mCurrentItem = item; + if ((mCurrentItem != mOpenItem) && mCurrentItem.hasItems()) { + openCurrentItem(); + } + } else { + mCurrentItem = null; + } + } + + private void deselect() { + if (mCurrentItem != null) { + mCurrentItem.setSelected(false); + } + if (mOpenItem != null) { + mOpenItem = null; + } + mCurrentItem = null; + } + + private void openCurrentItem() { + if ((mCurrentItem != null) && mCurrentItem.hasItems()) { + mCurrentItem.setSelected(false); + mOpenItem = mCurrentItem; + mOpening = true; + mXFade = new LinearAnimation(1, 0); + mXFade.setDuration(PIE_XFADE_DURATION); + mXFade.setAnimationListener(new AnimationListener() { + @Override + public void onAnimationStart(Animation animation) { + } + + @Override + public void onAnimationEnd(Animation animation) { + mXFade = null; + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + }); + mXFade.startNow(); + mOverlay.startAnimation(mXFade); + } + } + + private PointF getPolar(float x, float y, boolean useOffset) { + PointF res = new PointF(); + // get angle and radius from x/y + res.x = (float) Math.PI / 2; + x = x - mCenter.x; + y = mCenter.y - y; + res.y = (float) Math.sqrt(x * x + y * y); + if (x != 0) { + res.x = (float) Math.atan2(y, x); + if (res.x < 0) { + res.x = (float) (2 * Math.PI + res.x); + } + } + res.y = res.y + (useOffset ? mTouchOffset : 0); + return res; + } + + /** + * @param polar x: angle, y: dist + * @return the item at angle/dist or null + */ + private PieItem findItem(PointF polar) { + // find the matching item: + List items = (mOpenItem != null) ? mOpenItem.getItems() : mItems; + for (PieItem item : items) { + if (inside(polar, item)) { + return item; + } + } + return null; + } + + private boolean inside(PointF polar, PieItem item) { + return (item.getInnerRadius() < polar.y) + && (item.getStartAngle() < polar.x) + && (item.getStartAngle() + item.getSweep() > polar.x) + && (!mTapMode || (item.getOuterRadius() > polar.y)); + } + + @Override + public boolean handlesTouch() { + return true; + } + + // focus specific code + + public void setBlockFocus(boolean blocked) { + mBlockFocus = blocked; + if (blocked) { + clear(); + } + } + + public void setFocus(int x, int y) { + mFocusX = x; + mFocusY = y; + setCircle(mFocusX, mFocusY); + } + + public void alignFocus(int x, int y) { + mOverlay.removeCallbacks(mDisappear); + mAnimation.cancel(); + mAnimation.reset(); + mFocusX = x; + mFocusY = y; + mDialAngle = DIAL_HORIZONTAL; + setCircle(x, y); + mFocused = false; + } + + public int getSize() { + return 2 * mCircleSize; + } + + private int getRandomRange() { + return (int) (-60 + 120 * Math.random()); + } + + @Override + public void layout(int l, int t, int r, int b) { + super.layout(l, t, r, b); + mCenterX = (r - l) / 2; + mCenterY = (b - t) / 2; + mFocusX = mCenterX; + mFocusY = mCenterY; + setCircle(mFocusX, mFocusY); + if (isVisible() && mState == STATE_PIE) { + setCenter(mCenterX, mCenterY); + layoutPie(); + } + } + + private void setCircle(int cx, int cy) { + mCircle.set(cx - mCircleSize, cy - mCircleSize, + cx + mCircleSize, cy + mCircleSize); + mDial.set(cx - mCircleSize + mInnerOffset, cy - mCircleSize + mInnerOffset, + cx + mCircleSize - mInnerOffset, cy + mCircleSize - mInnerOffset); + } + + public void drawFocus(Canvas canvas) { + if (mBlockFocus) { + return; + } + mFocusPaint.setStrokeWidth(mOuterStroke); + canvas.drawCircle((float) mFocusX, (float) mFocusY, (float) mCircleSize, mFocusPaint); + if (mState == STATE_PIE) { + return; + } + int color = mFocusPaint.getColor(); + if (mState == STATE_FINISHING) { + mFocusPaint.setColor(mFocused ? mSuccessColor : mFailColor); + } + mFocusPaint.setStrokeWidth(mInnerStroke); + drawLine(canvas, mDialAngle, mFocusPaint); + drawLine(canvas, mDialAngle + 45, mFocusPaint); + drawLine(canvas, mDialAngle + 180, mFocusPaint); + drawLine(canvas, mDialAngle + 225, mFocusPaint); + canvas.save(); + // rotate the arc instead of its offset to better use framework's shape caching + canvas.rotate(mDialAngle, mFocusX, mFocusY); + canvas.drawArc(mDial, 0, 45, false, mFocusPaint); + canvas.drawArc(mDial, 180, 45, false, mFocusPaint); + canvas.restore(); + mFocusPaint.setColor(color); + } + + private void drawLine(Canvas canvas, int angle, Paint p) { + convertCart(angle, mCircleSize - mInnerOffset, mPoint1); + convertCart(angle, mCircleSize - mInnerOffset + mInnerOffset / 3, mPoint2); + canvas.drawLine(mPoint1.x + mFocusX, mPoint1.y + mFocusY, + mPoint2.x + mFocusX, mPoint2.y + mFocusY, p); + } + + private static void convertCart(int angle, int radius, Point out) { + double a = 2 * Math.PI * (angle % 360) / 360; + out.x = (int) (radius * Math.cos(a) + 0.5); + out.y = (int) (radius * Math.sin(a) + 0.5); + } + + @Override + public void showStart() { + if (mState == STATE_PIE) { + return; + } + cancelFocus(); + mStartAnimationAngle = 67; + int range = getRandomRange(); + startAnimation(SCALING_UP_TIME, + false, mStartAnimationAngle, mStartAnimationAngle + range); + mState = STATE_FOCUSING; + } + + @Override + public void showSuccess(boolean timeout) { + if (mState == STATE_FOCUSING) { + startAnimation(SCALING_DOWN_TIME, + timeout, mStartAnimationAngle); + mState = STATE_FINISHING; + mFocused = true; + } + } + + @Override + public void showFail(boolean timeout) { + if (mState == STATE_FOCUSING) { + startAnimation(SCALING_DOWN_TIME, + timeout, mStartAnimationAngle); + mState = STATE_FINISHING; + mFocused = false; + } + } + + private void cancelFocus() { + mFocusCancelled = true; + mOverlay.removeCallbacks(mDisappear); + if (mAnimation != null) { + mAnimation.cancel(); + } + mFocusCancelled = false; + mFocused = false; + mState = STATE_IDLE; + } + + @Override + public void clear() { + if (mState == STATE_PIE) { + return; + } + cancelFocus(); + mOverlay.post(mDisappear); + } + + private void startAnimation(long duration, boolean timeout, + float toScale) { + startAnimation(duration, timeout, mDialAngle, + toScale); + } + + private void startAnimation(long duration, boolean timeout, + float fromScale, float toScale) { + setVisible(true); + mAnimation.reset(); + mAnimation.setDuration(duration); + mAnimation.setScale(fromScale, toScale); + mAnimation.setAnimationListener(timeout ? mEndAction : null); + mOverlay.startAnimation(mAnimation); + update(); + } + + private class EndAction implements Animation.AnimationListener { + @Override + public void onAnimationEnd(Animation animation) { + // Keep the focus indicator for some time. + if (!mFocusCancelled) { + mOverlay.postDelayed(mDisappear, DISAPPEAR_TIMEOUT); + } + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + + @Override + public void onAnimationStart(Animation animation) { + } + } + + private class Disappear implements Runnable { + @Override + public void run() { + if (mState == STATE_PIE) { + return; + } + setVisible(false); + mFocusX = mCenterX; + mFocusY = mCenterY; + mState = STATE_IDLE; + setCircle(mFocusX, mFocusY); + mFocused = false; + } + } + + private class ScaleAnimation extends Animation { + private float mFrom = 1f; + private float mTo = 1f; + + public ScaleAnimation() { + setFillAfter(true); + } + + public void setScale(float from, float to) { + mFrom = from; + mTo = to; + } + + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + mDialAngle = (int) (mFrom + (mTo - mFrom) * interpolatedTime); + } + } + + + private class LinearAnimation extends Animation { + private float mFrom; + private float mTo; + private float mValue; + + public LinearAnimation(float from, float to) { + setFillAfter(true); + setInterpolator(new LinearInterpolator()); + mFrom = from; + mTo = to; + } + + public float getValue() { + return mValue; + } + + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + mValue = (mFrom + (mTo - mFrom) * interpolatedTime); + } + } + +} \ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/README.txt b/src/com/android/messaging/ui/mediapicker/camerafocus/README.txt new file mode 100644 index 0000000..ed4e783 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/README.txt @@ -0,0 +1,3 @@ +The files in this package were copied from the android-4.4.4_r1 branch of ASOP from the folders +com/android/camera/ and com/android/camera/ui from files with the same name. Some modifications +have been made to remove unneeded features and adjust to our needs. \ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java b/src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java new file mode 100644 index 0000000..95cddc4 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker.camerafocus; + +import android.content.Context; +import android.graphics.Canvas; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.widget.FrameLayout; + +import java.util.ArrayList; +import java.util.List; + +public class RenderOverlay extends FrameLayout { + + interface Renderer { + + public boolean handlesTouch(); + public boolean onTouchEvent(MotionEvent evt); + public void setOverlay(RenderOverlay overlay); + public void layout(int left, int top, int right, int bottom); + public void draw(Canvas canvas); + + } + + private RenderView mRenderView; + private List mClients; + + // reverse list of touch clients + private List mTouchClients; + private int[] mPosition = new int[2]; + + public RenderOverlay(Context context, AttributeSet attrs) { + super(context, attrs); + mRenderView = new RenderView(context); + addView(mRenderView, new LayoutParams(LayoutParams.MATCH_PARENT, + LayoutParams.MATCH_PARENT)); + mClients = new ArrayList(10); + mTouchClients = new ArrayList(10); + setWillNotDraw(false); + + addRenderer(new PieRenderer(context)); + } + + public PieRenderer getPieRenderer() { + for (Renderer renderer : mClients) { + if (renderer instanceof PieRenderer) { + return (PieRenderer) renderer; + } + } + return null; + } + + public void addRenderer(Renderer renderer) { + mClients.add(renderer); + renderer.setOverlay(this); + if (renderer.handlesTouch()) { + mTouchClients.add(0, renderer); + } + renderer.layout(getLeft(), getTop(), getRight(), getBottom()); + } + + public void addRenderer(int pos, Renderer renderer) { + mClients.add(pos, renderer); + renderer.setOverlay(this); + renderer.layout(getLeft(), getTop(), getRight(), getBottom()); + } + + public void remove(Renderer renderer) { + mClients.remove(renderer); + renderer.setOverlay(null); + } + + public int getClientSize() { + return mClients.size(); + } + + @Override + public boolean dispatchTouchEvent(MotionEvent m) { + return false; + } + + public boolean directDispatchTouch(MotionEvent m, Renderer target) { + mRenderView.setTouchTarget(target); + boolean res = super.dispatchTouchEvent(m); + mRenderView.setTouchTarget(null); + return res; + } + + private void adjustPosition() { + getLocationInWindow(mPosition); + } + + public int getWindowPositionX() { + return mPosition[0]; + } + + public int getWindowPositionY() { + return mPosition[1]; + } + + public void update() { + mRenderView.invalidate(); + } + + private class RenderView extends View { + + private Renderer mTouchTarget; + + public RenderView(Context context) { + super(context); + setWillNotDraw(false); + } + + public void setTouchTarget(Renderer target) { + mTouchTarget = target; + } + + @Override + public boolean onTouchEvent(MotionEvent evt) { + if (mTouchTarget != null) { + return mTouchTarget.onTouchEvent(evt); + } + if (mTouchClients != null) { + boolean res = false; + for (Renderer client : mTouchClients) { + res |= client.onTouchEvent(evt); + } + return res; + } + return false; + } + + @Override + public void onLayout(boolean changed, int left, int top, int right, int bottom) { + adjustPosition(); + super.onLayout(changed, left, top, right, bottom); + if (mClients == null) { + return; + } + for (Renderer renderer : mClients) { + renderer.layout(left, top, right, bottom); + } + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + if (mClients == null) { + return; + } + boolean redraw = false; + for (Renderer renderer : mClients) { + renderer.draw(canvas); + redraw = redraw || ((OverlayRenderer) renderer).isVisible(); + } + if (redraw) { + invalidate(); + } + } + } + +} \ No newline at end of file diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoBitmapLoader.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoBitmapLoader.java new file mode 100644 index 0000000..d139a38 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/BuglePhotoBitmapLoader.java @@ -0,0 +1,192 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.photoviewer; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.support.rastermill.FrameSequenceDrawable; +import android.support.v4.content.AsyncTaskLoader; + +import com.android.ex.photo.PhotoViewController; +import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface; +import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult; +import com.android.messaging.datamodel.media.ImageRequestDescriptor; +import com.android.messaging.datamodel.media.ImageResource; +import com.android.messaging.datamodel.media.MediaRequest; +import com.android.messaging.datamodel.media.MediaResourceManager; +import com.android.messaging.datamodel.media.UriImageRequestDescriptor; +import com.android.messaging.util.ImageUtils; + +/** + * Loader for the bitmap of a photo. + */ +public class BuglePhotoBitmapLoader extends AsyncTaskLoader + implements PhotoBitmapLoaderInterface { + private String mPhotoUri; + private ImageResource mImageResource; + // The drawable that is currently "in use" and being presented to the user. This drawable + // should never exist without the image resource backing it. + private Drawable mDrawable; + + public BuglePhotoBitmapLoader(Context context, String photoUri) { + super(context); + mPhotoUri = photoUri; + } + + @Override + public void setPhotoUri(String photoUri) { + mPhotoUri = photoUri; + } + + @Override + public BitmapResult loadInBackground() { + final BitmapResult result = new BitmapResult(); + final Context context = getContext(); + if (context != null && mPhotoUri != null) { + final ImageRequestDescriptor descriptor = + new UriImageRequestDescriptor(Uri.parse(mPhotoUri), + PhotoViewController.sMaxPhotoSize, PhotoViewController.sMaxPhotoSize, + true /* allowCompression */, false /* isStatic */, + false /* cropToCircle */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + final MediaRequest imageRequest = + descriptor.buildSyncMediaRequest(context); + final ImageResource imageResource = + MediaResourceManager.get().requestMediaResourceSync(imageRequest); + if (imageResource != null) { + setImageResource(imageResource); + result.status = BitmapResult.STATUS_SUCCESS; + result.drawable = mImageResource.getDrawable(context.getResources()); + } else { + releaseImageResource(); + result.status = BitmapResult.STATUS_EXCEPTION; + } + } else { + result.status = BitmapResult.STATUS_EXCEPTION; + } + return result; + } + + /** + * Called when there is new data to deliver to the client. The super class will take care of + * delivering it; the implementation here just adds a little more logic. + */ + @Override + public void deliverResult(BitmapResult result) { + final Drawable drawable = result != null ? result.drawable : null; + if (isReset()) { + // An async query came in while the loader is stopped. We don't need the result. + releaseDrawable(drawable); + return; + } + + // We are now going to display this drawable so set to mDrawable + mDrawable = drawable; + + if (isStarted()) { + // If the Loader is currently started, we can immediately deliver its results. + super.deliverResult(result); + } + } + + /** + * Handles a request to start the Loader. + */ + @Override + protected void onStartLoading() { + if (mDrawable != null) { + // If we currently have a result available, deliver it + // immediately. + final BitmapResult result = new BitmapResult(); + result.status = BitmapResult.STATUS_SUCCESS; + result.drawable = mDrawable; + deliverResult(result); + } + + if (takeContentChanged() || (mImageResource == null)) { + // If the data has changed since the last time it was loaded + // or is not currently available, start a load. + forceLoad(); + } + } + + /** + * Handles a request to stop the Loader. + */ + @Override + protected void onStopLoading() { + // Attempt to cancel the current load task if possible. + cancelLoad(); + } + + /** + * Handles a request to cancel a load. + */ + @Override + public void onCanceled(BitmapResult result) { + super.onCanceled(result); + + // At this point we can release the resources associated with 'drawable' if needed. + if (result != null) { + releaseDrawable(result.drawable); + } + } + + /** + * Handles a request to completely reset the Loader. + */ + @Override + protected void onReset() { + super.onReset(); + + // Ensure the loader is stopped + onStopLoading(); + + releaseImageResource(); + } + + private void releaseDrawable(Drawable drawable) { + if (drawable != null && drawable instanceof FrameSequenceDrawable + && !((FrameSequenceDrawable) drawable).isDestroyed()) { + ((FrameSequenceDrawable) drawable).destroy(); + } + + } + + private void setImageResource(final ImageResource resource) { + if (mImageResource != resource) { + // Clear out any information for what is currently used + releaseImageResource(); + mImageResource = resource; + // No need to add ref since a ref is already reserved as a result of + // requestMediaResourceSync. + } + } + + private void releaseImageResource() { + // If we are getting rid of the imageResource backing the drawable, we must also + // destroy the drawable before releasing it. + releaseDrawable(mDrawable); + mDrawable = null; + + if (mImageResource != null) { + mImageResource.release(); + } + mImageResource = null; + } +} \ No newline at end of file diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoPageAdapter.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoPageAdapter.java new file mode 100644 index 0000000..52498c7 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/BuglePhotoPageAdapter.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.photoviewer; + +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.support.v4.app.FragmentManager; + +import com.android.ex.photo.adapters.PhotoPagerAdapter; +import com.android.ex.photo.fragments.PhotoViewFragment; + +public class BuglePhotoPageAdapter extends PhotoPagerAdapter { + + public BuglePhotoPageAdapter(Context context, FragmentManager fm, Cursor c, float maxScale, + boolean thumbsFullScreen) { + super(context, fm, c, maxScale, thumbsFullScreen); + } + + @Override + protected PhotoViewFragment createPhotoViewFragment(Intent intent, int position, + boolean onlyShowSpinner) { + return BuglePhotoViewFragment.newInstance(intent, position, onlyShowSpinner); + } +} diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoViewActivity.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewActivity.java new file mode 100644 index 0000000..1924a96 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewActivity.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.photoviewer; + +import com.android.ex.photo.PhotoViewActivity; +import com.android.ex.photo.PhotoViewController; + +/** + * Activity to display the conversation images in full-screen. Most of the customization is in + * {@link BuglePhotoViewController}. + */ +public class BuglePhotoViewActivity extends PhotoViewActivity { + @Override + public PhotoViewController createController() { + return new BuglePhotoViewController(this); + } +} diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java new file mode 100644 index 0000000..eb39886 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.photoviewer; + +import android.Manifest; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.support.v4.app.FragmentManager; +import android.support.v4.content.Loader; +import android.text.TextUtils; +import android.view.Menu; +import android.view.MenuItem; +import android.widget.ShareActionProvider; +import android.widget.Toast; + +import com.android.ex.photo.PhotoViewController; +import com.android.ex.photo.adapters.PhotoPagerAdapter; +import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult; +import com.android.messaging.R; +import com.android.messaging.datamodel.ConversationImagePartsView.PhotoViewQuery; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.ui.conversation.ConversationFragment; +import com.android.messaging.util.Dates; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; + +/** + * Customizations for the photoviewer to display conversation images in full screen. + */ +public class BuglePhotoViewController extends PhotoViewController { + private ShareActionProvider mShareActionProvider; + private MenuItem mShareItem; + private MenuItem mSaveItem; + + public BuglePhotoViewController(final ActivityInterface activity) { + super(activity); + } + + @Override + public Loader onCreateBitmapLoader( + final int id, final Bundle args, final String uri) { + switch (id) { + case BITMAP_LOADER_AVATAR: + case BITMAP_LOADER_THUMBNAIL: + case BITMAP_LOADER_PHOTO: + return new BuglePhotoBitmapLoader(getActivity().getContext(), uri); + default: + LogUtil.e(LogUtil.BUGLE_TAG, + "Photoviewer unable to open bitmap loader with unknown id: " + id); + return null; + } + } + + @Override + public void updateActionBar() { + final Cursor cursor = getCursorAtProperPosition(); + + if (mSaveItem == null || cursor == null) { + // Load not finished, called from framework code before ready + return; + } + // Show the name as the title + mActionBarTitle = cursor.getString(PhotoViewQuery.INDEX_SENDER_FULL_NAME); + if (TextUtils.isEmpty(mActionBarTitle)) { + // If the name is not known, fall back to the phone number + mActionBarTitle = cursor.getString(PhotoViewQuery.INDEX_DISPLAY_DESTINATION); + } + + // Show the timestamp as the subtitle + final long receivedTimestamp = cursor.getLong(PhotoViewQuery.INDEX_RECEIVED_TIMESTAMP); + mActionBarSubtitle = Dates.getMessageTimeString(receivedTimestamp).toString(); + + setActionBarTitles(getActivity().getActionBarInterface()); + mSaveItem.setVisible(!isTempFile()); + + updateShareActionProvider(); + } + + private void updateShareActionProvider() { + final PhotoPagerAdapter adapter = getAdapter(); + final Cursor cursor = getCursorAtProperPosition(); + if (mShareActionProvider == null || mShareItem == null || adapter == null || + cursor == null) { + // Not enough stuff loaded to update the share action + return; + } + final String photoUri = adapter.getPhotoUri(cursor); + if (isTempFile()) { + mShareItem.setVisible(false); + return; + } + final String contentType = adapter.getContentType(cursor); + + final Intent shareIntent = new Intent(); + shareIntent.setAction(Intent.ACTION_SEND); + shareIntent.setType(contentType); + shareIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(photoUri)); + mShareActionProvider.setShareIntent(shareIntent); + mShareItem.setVisible(true); + } + + /** + * Checks whether the current photo is a temp file. A temp file can be deleted at any time, so + * we need to disable share and save options because the file may no longer be there. + */ + private boolean isTempFile() { + final Cursor cursor = getCursorAtProperPosition(); + final Uri photoUri = Uri.parse(getAdapter().getPhotoUri(cursor)); + return MediaScratchFileProvider.isMediaScratchSpaceUri(photoUri); + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + ((Activity) getActivity()).getMenuInflater().inflate(R.menu.photo_view_menu, menu); + + // Get the ShareActionProvider + mShareItem = menu.findItem(R.id.action_share); + mShareActionProvider = (ShareActionProvider) mShareItem.getActionProvider(); + updateShareActionProvider(); + + mSaveItem = menu.findItem(R.id.action_save); + return true; + } + + @Override + public boolean onPrepareOptionsMenu(final Menu menu) { + return !mIsEmpty; + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + if (item.getItemId() == R.id.action_save) { + if (OsUtil.hasStoragePermission()) { + final PhotoPagerAdapter adapter = getAdapter(); + final Cursor cursor = getCursorAtProperPosition(); + if (cursor == null) { + final Context context = getActivity().getContext(); + final String error = context.getResources().getQuantityString( + R.plurals.attachment_save_error, 1, 1); + Toast.makeText(context, error, Toast.LENGTH_SHORT).show(); + return true; + } + final String photoUri = adapter.getPhotoUri(cursor); + new ConversationFragment.SaveAttachmentTask(((Activity) getActivity()), + Uri.parse(photoUri), adapter.getContentType(cursor)).executeOnThreadPool(); + } else { + ((Activity)getActivity()).requestPermissions( + new String[] { Manifest.permission.WRITE_EXTERNAL_STORAGE }, 0); + } + return true; + } else { + return super.onOptionsItemSelected(item); + } + } + + @Override + public PhotoPagerAdapter createPhotoPagerAdapter(final Context context, + final FragmentManager fm, final Cursor c, final float maxScale) { + return new BuglePhotoPageAdapter(context, fm, c, maxScale, mDisplayThumbsFullScreen); + } +} diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoViewFragment.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewFragment.java new file mode 100644 index 0000000..698c510 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewFragment.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.photoviewer; + +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.support.rastermill.FrameSequenceDrawable; +import android.support.v4.content.Loader; + +import com.android.ex.photo.PhotoViewCallbacks; +import com.android.ex.photo.fragments.PhotoViewFragment; +import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult; + +public class BuglePhotoViewFragment extends PhotoViewFragment { + + /** Public no-arg constructor for allowing the framework to handle orientation changes */ + public BuglePhotoViewFragment() { + // Do nothing. + } + + public static PhotoViewFragment newInstance(Intent intent, int position, + boolean onlyShowSpinner) { + final PhotoViewFragment f = new BuglePhotoViewFragment(); + initializeArguments(intent, position, onlyShowSpinner, f); + return f; + } + + @Override + public void onLoadFinished(Loader loader, BitmapResult result) { + super.onLoadFinished(loader, result); + // Need to check for the first time when we load the photos + if (PhotoViewCallbacks.BITMAP_LOADER_PHOTO == loader.getId() + && result.status == BitmapResult.STATUS_SUCCESS + && mCallback.isFragmentActive(this)) { + startGif(); + } + } + + @Override + public void onResume() { + super.onResume(); + startGif(); + } + + @Override + public void onPause() { + stopGif(); + super.onPause(); + } + + @Override + public void onViewActivated() { + super.onViewActivated(); + startGif(); + } + + @Override + public void resetViews() { + super.resetViews(); + stopGif(); + } + + private void stopGif() { + final Drawable drawable = getDrawable(); + if (drawable != null && drawable instanceof FrameSequenceDrawable) { + ((FrameSequenceDrawable) drawable).stop(); + } + } + + private void startGif() { + final Drawable drawable = getDrawable(); + if (drawable != null && drawable instanceof FrameSequenceDrawable) { + ((FrameSequenceDrawable) drawable).start(); + } + } +} diff --git a/src/com/android/messaging/util/AccessibilityUtil.java b/src/com/android/messaging/util/AccessibilityUtil.java new file mode 100644 index 0000000..f6c64a9 --- /dev/null +++ b/src/com/android/messaging/util/AccessibilityUtil.java @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import android.content.Context; +import android.content.res.Resources; +import android.support.v4.view.accessibility.AccessibilityEventCompat; +import android.support.v4.view.accessibility.AccessibilityRecordCompat; +import android.text.TextUtils; +import android.view.View; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityManager; + +import com.android.messaging.Factory; +import com.android.messaging.R; + +import javax.annotation.Nullable; + +public class AccessibilityUtil { + public static String sContentDescriptionDivider; + + public static boolean isTouchExplorationEnabled(final Context context) { + final AccessibilityManager accessibilityManager = (AccessibilityManager) + context.getSystemService(Context.ACCESSIBILITY_SERVICE); + return accessibilityManager.isTouchExplorationEnabled(); + } + + public static StringBuilder appendContentDescription(final Context context, + final StringBuilder contentDescription, final String val) { + if (sContentDescriptionDivider == null) { + sContentDescriptionDivider = + context.getResources().getString(R.string.enumeration_comma); + } + if (contentDescription.length() != 0) { + contentDescription.append(sContentDescriptionDivider); + } + contentDescription.append(val); + return contentDescription; + } + + public static void announceForAccessibilityCompat( + final View view, @Nullable final AccessibilityManager accessibilityManager, + final int textResourceId) { + final String text = Factory.get().getApplicationContext().getResources().getString( + textResourceId); + announceForAccessibilityCompat(view, accessibilityManager, text); + } + + public static void announceForAccessibilityCompat( + final View view, @Nullable AccessibilityManager accessibilityManager, + final CharSequence text) { + final Context context = view.getContext().getApplicationContext(); + if (accessibilityManager == null) { + accessibilityManager = (AccessibilityManager) context.getSystemService( + Context.ACCESSIBILITY_SERVICE); + } + + if (!accessibilityManager.isEnabled()) { + return; + } + + // Jelly Bean added support for speaking text verbatim + final int eventType = OsUtil.isAtLeastJB() ? AccessibilityEvent.TYPE_ANNOUNCEMENT + : AccessibilityEvent.TYPE_VIEW_FOCUSED; + + // Construct an accessibility event with the minimum recommended + // attributes. An event without a class name or package may be dropped. + final AccessibilityEvent event = AccessibilityEvent.obtain(eventType); + event.getText().add(text); + event.setEnabled(view.isEnabled()); + event.setClassName(view.getClass().getName()); + event.setPackageName(context.getPackageName()); + + // JellyBean MR1 requires a source view to set the window ID. + final AccessibilityRecordCompat record = AccessibilityEventCompat.asRecord(event); + record.setSource(view); + + // Sends the event directly through the accessibility manager. If we only supported SDK 14+ + // we could have done: + // getParent().requestSendAccessibilityEvent(this, event); + accessibilityManager.sendAccessibilityEvent(event); + } + + /** + * Check to see if the current layout is Right-to-Left. This check is only supported for + * API 17+. + * For earlier versions, this method will just return false. + * @return boolean Boolean indicating whether the currently locale is RTL. + */ + public static boolean isLayoutRtl(final View view) { + if (OsUtil.isAtLeastJB_MR1()) { + return View.LAYOUT_DIRECTION_RTL == view.getLayoutDirection(); + } else { + return false; + } + } + + public static String getVocalizedPhoneNumber(final Resources res, final String phoneNumber) { + if (TextUtils.isEmpty(phoneNumber)) { + return ""; + } + final StringBuilder vocalizedPhoneNumber = new StringBuilder(); + for (final char c : phoneNumber.toCharArray()) { + getVocalizedNumber(res, c, vocalizedPhoneNumber); + } + return vocalizedPhoneNumber.toString(); + } + + public static void getVocalizedNumber(final Resources res, final char c, + final StringBuilder builder) { + switch (c) { + case '0': + builder.append(res.getString(R.string.content_description_for_number_zero)); + builder.append(" "); + return; + case '1': + builder.append(res.getString(R.string.content_description_for_number_one)); + builder.append(" "); + return; + case '2': + builder.append(res.getString(R.string.content_description_for_number_two)); + builder.append(" "); + return; + case '3': + builder.append(res.getString(R.string.content_description_for_number_three)); + builder.append(" "); + return; + case '4': + builder.append(res.getString(R.string.content_description_for_number_four)); + builder.append(" "); + return; + case '5': + builder.append(res.getString(R.string.content_description_for_number_five)); + builder.append(" "); + return; + case '6': + builder.append(res.getString(R.string.content_description_for_number_six)); + builder.append(" "); + return; + case '7': + builder.append(res.getString(R.string.content_description_for_number_seven)); + builder.append(" "); + return; + case '8': + builder.append(res.getString(R.string.content_description_for_number_eight)); + builder.append(" "); + return; + case '9': + builder.append(res.getString(R.string.content_description_for_number_nine)); + builder.append(" "); + return; + default: + builder.append(c); + return; + } + } +} diff --git a/src/com/android/messaging/util/Assert.java b/src/com/android/messaging/util/Assert.java new file mode 100644 index 0000000..437965c --- /dev/null +++ b/src/com/android/messaging/util/Assert.java @@ -0,0 +1,214 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import android.os.Looper; + +import java.util.Arrays; + +public final class Assert { + public static @interface RunsOnMainThread {} + public static @interface DoesNotRunOnMainThread {} + public static @interface RunsOnAnyThread {} + + private static final String TEST_THREAD_SUBSTRING = "test"; + + private static boolean sIsEngBuild; + private static boolean sShouldCrash; + + // Private constructor so no one creates this class. + private Assert() { + } + + // The proguard rules will strip this method out on user/userdebug builds. + // If you change the method signature you MUST edit proguard-release.flags. + private static void setIfEngBuild() { + sShouldCrash = sIsEngBuild = true; + } + + private static void refreshGservices(final BugleGservices gservices) { + sShouldCrash = sIsEngBuild; + if (!sShouldCrash) { + sShouldCrash = gservices.getBoolean( + BugleGservicesKeys.ASSERTS_FATAL, + BugleGservicesKeys.ASSERTS_FATAL_DEFAULT); + } + } + + // Static initializer block to find out if we're running an eng or + // release build. + static { + setIfEngBuild(); + } + + // This is called from FactoryImpl once the Gservices class is initialized. + public static void initializeGservices (final BugleGservices gservices) { + gservices.registerForChanges(new Runnable() { + @Override + public void run() { + refreshGservices(gservices); + } + }); + refreshGservices(gservices); + } + + /** + * Halt execution if this is not an eng build. + *

Intended for use in code paths that should be run only for tests and never on + * a real build. + *

Note that this will crash on a user build even though asserts don't normally + * crash on a user build. + */ + public static void isEngBuild() { + isTrueReleaseCheck(sIsEngBuild); + } + + /** + * Halt execution if this isn't the case. + */ + public static void isTrue(final boolean condition) { + if (!condition) { + fail("Expected condition to be true", false); + } + } + + /** + * Halt execution if this isn't the case. + */ + public static void isFalse(final boolean condition) { + if (condition) { + fail("Expected condition to be false", false); + } + } + + /** + * Halt execution even in release builds if this isn't the case. + */ + public static void isTrueReleaseCheck(final boolean condition) { + if (!condition) { + fail("Expected condition to be true", true); + } + } + + public static void equals(final int expected, final int actual) { + if (expected != actual) { + fail("Expected " + expected + " but got " + actual, false); + } + } + + public static void equals(final long expected, final long actual) { + if (expected != actual) { + fail("Expected " + expected + " but got " + actual, false); + } + } + + public static void equals(final Object expected, final Object actual) { + if (expected != actual + && (expected == null || actual == null || !expected.equals(actual))) { + fail("Expected " + expected + " but got " + actual, false); + } + } + + public static void oneOf(final int actual, final int ...expected) { + for (int value : expected) { + if (actual == value) { + return; + } + } + fail("Expected value to be one of " + Arrays.toString(expected) + " but was " + actual); + } + + public static void inRange( + final int val, final int rangeMinInclusive, final int rangeMaxInclusive) { + if (val < rangeMinInclusive || val > rangeMaxInclusive) { + fail("Expected value in range [" + rangeMinInclusive + ", " + + rangeMaxInclusive + "], but was " + val, false); + } + } + + public static void inRange( + final long val, final long rangeMinInclusive, final long rangeMaxInclusive) { + if (val < rangeMinInclusive || val > rangeMaxInclusive) { + fail("Expected value in range [" + rangeMinInclusive + ", " + + rangeMaxInclusive + "], but was " + val, false); + } + } + + public static void isMainThread() { + if (Looper.myLooper() != Looper.getMainLooper() + && !Thread.currentThread().getName().contains(TEST_THREAD_SUBSTRING)) { + fail("Expected to run on main thread", false); + } + } + + public static void isNotMainThread() { + if (Looper.myLooper() == Looper.getMainLooper() + && !Thread.currentThread().getName().contains(TEST_THREAD_SUBSTRING)) { + fail("Not expected to run on main thread", false); + } + } + + /** + * Halt execution if the value passed in is not null + * @param obj The object to check + */ + public static void isNull(final Object obj) { + if (obj != null) { + fail("Expected object to be null", false); + } + } + + /** + * Halt execution if the value passed in is not null + * @param obj The object to check + * @param failureMessage message to print when halting execution + */ + public static void isNull(final Object obj, final String failureMessage) { + if (obj != null) { + fail(failureMessage, false); + } + } + + /** + * Halt execution if the value passed in is null + * @param obj The object to check + */ + public static void notNull(final Object obj) { + if (obj == null) { + fail("Expected value to be non-null", false); + } + } + + public static void fail(final String message) { + fail("Assert.fail() called: " + message, false); + } + + private static void fail(final String message, final boolean crashRelease) { + LogUtil.e(LogUtil.BUGLE_TAG, message); + if (crashRelease || sShouldCrash) { + throw new AssertionError(message); + } else { + // Find the method whose assertion failed. We're using a depth of 2, because all public + // Assert methods delegate to this one (see javadoc on getCaller() for details). + StackTraceElement caller = DebugUtils.getCaller(2); + if (caller != null) { + // This log message can be de-obfuscated by the Proguard retrace tool, just like a + // full stack trace from a crash. + LogUtil.e(LogUtil.BUGLE_TAG, "\tat " + caller.toString()); + } + } + } +} diff --git a/src/com/android/messaging/util/AvatarUriUtil.java b/src/com/android/messaging/util/AvatarUriUtil.java new file mode 100644 index 0000000..df7d085 --- /dev/null +++ b/src/com/android/messaging/util/AvatarUriUtil.java @@ -0,0 +1,320 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import android.graphics.Color; +import android.net.Uri; +import android.net.Uri.Builder; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; + +import com.android.messaging.datamodel.data.ParticipantData; + +import java.util.ArrayList; +import java.util.List; + +/** + * A helper utility for creating {@link android.net.Uri}s to describe what avatar to fetch or + * generate and will help verify and extract information from avatar {@link android.net.Uri}s. + * + * There are three types of avatar {@link android.net.Uri}. + * + * 1) Group Avatars - These are avatars which are used to represent a group conversation. Group + * avatars uris are basically multiple avatar uri which can be any of the below types but not + * another group avatar. The group avatars can hold anywhere from two to four avatars uri and can + * be in any of the following format + * messaging://avatar/g?p=&p= + * messaging://avatar/g?p=&p=&p= + * messaging://avatar/g?p=&p=&p=&p= + * + * 2) Local Resource - A local resource avatar is use when there is a profile photo for the + * participant. This can be any local resource. + * + * 3) Letter Tile - A letter tile is used when a participant has a name but no profile photo. A + * letter tile will contain the first code point of the participant's name and a background color + * based on the hash of the participant's full name. Letter tiles will be in the following format. + * messaging://avatar/l?n= + * + * 4) Default Avatars - These are avatars are used when the participant has no profile photo or + * name. In these cases we use the default person icon with a color background. The color + * background is based on a hash of the normalized phone number. + * + * 5) Default Background Avatars - This is a special case for Default Avatars where we use the + * default background color for the default avatar. + * + * 6) SIM Selector Avatars - These are avatars used in the SIM selector. This may either be a + * regular local resource avatar (2) or an avatar with a SIM identifier (i.e. SIM background with + * a letter or a slot number). + */ +public class AvatarUriUtil { + private static final int MAX_GROUP_PARTICIPANTS = 4; + + public static final String TYPE_GROUP_URI = "g"; + public static final String TYPE_LOCAL_RESOURCE_URI = "r"; + public static final String TYPE_LETTER_TILE_URI = "l"; + public static final String TYPE_DEFAULT_URI = "d"; + public static final String TYPE_DEFAULT_BACKGROUND_URI = "b"; + public static final String TYPE_SIM_SELECTOR_URI = "s"; + + private static final String SCHEME = "messaging"; + private static final String AUTHORITY = "avatar"; + private static final String PARAM_NAME = "n"; + private static final String PARAM_PRIMARY_URI = "m"; + private static final String PARAM_FALLBACK_URI = "f"; + private static final String PARAM_PARTICIPANT = "p"; + private static final String PARAM_IDENTIFIER = "i"; + private static final String PARAM_SIM_COLOR = "c"; + private static final String PARAM_SIM_SELECTED = "s"; + private static final String PARAM_SIM_INCOMING = "g"; + + public static final Uri DEFAULT_BACKGROUND_AVATAR = new Uri.Builder().scheme(SCHEME) + .authority(AUTHORITY).appendPath(TYPE_DEFAULT_BACKGROUND_URI).build(); + + private static final Uri BLANK_SIM_INDICATOR_INCOMING_URI = createSimIconUri("", + false /* selected */, Color.TRANSPARENT, true /* incoming */); + private static final Uri BLANK_SIM_INDICATOR_OUTGOING_URI = createSimIconUri("", + false /* selected */, Color.TRANSPARENT, false /* incoming */); + + /** + * Creates an avatar uri based on a list of ParticipantData. The list of participants may not + * be null or empty. Depending on the size of the list either a group avatar uri will be create + * or an individual's avatar will be created. This will never return a null uri. + */ + public static Uri createAvatarUri(@NonNull final List participants) { + Assert.notNull(participants); + Assert.isTrue(!participants.isEmpty()); + + if (participants.size() == 1) { + return createAvatarUri(participants.get(0)); + } + + final int numParticipants = Math.min(participants.size(), MAX_GROUP_PARTICIPANTS); + final ArrayList avatarUris = new ArrayList(numParticipants); + for (int i = 0; i < numParticipants; i++) { + avatarUris.add(createAvatarUri(participants.get(i))); + } + return AvatarUriUtil.joinAvatarUriToGroup(avatarUris); + } + + /** + * Joins together a list of valid avatar uri into a group uri.The list of participants may not + * be null or empty. If a lit of one is given then the first element will be return back + * instead of a group avatar uri. All uris in the list must be a valid avatar uri. This will + * never return a null uri. + */ + public static Uri joinAvatarUriToGroup(@NonNull final List avatarUris) { + Assert.notNull(avatarUris); + Assert.isTrue(!avatarUris.isEmpty()); + + if (avatarUris.size() == 1) { + final Uri firstAvatar = avatarUris.get(0); + Assert.isTrue(AvatarUriUtil.isAvatarUri(firstAvatar)); + return firstAvatar; + } + + final Builder builder = new Builder(); + builder.scheme(SCHEME); + builder.authority(AUTHORITY); + builder.appendPath(TYPE_GROUP_URI); + final int numParticipants = Math.min(avatarUris.size(), MAX_GROUP_PARTICIPANTS); + for (int i = 0; i < numParticipants; i++) { + final Uri uri = avatarUris.get(i); + Assert.notNull(uri); + Assert.isTrue(UriUtil.isLocalResourceUri(uri) || AvatarUriUtil.isAvatarUri(uri)); + builder.appendQueryParameter(PARAM_PARTICIPANT, uri.toString()); + } + return builder.build(); + } + + /** + * Creates an avatar uri based on ParticipantData which may not be null and expected to have + * profilePhotoUri, fullName and normalizedDestination populated. This will never return a null + * uri. + */ + public static Uri createAvatarUri(@NonNull final ParticipantData participant) { + Assert.notNull(participant); + final String photoUriString = participant.getProfilePhotoUri(); + final Uri profilePhotoUri = (photoUriString == null) ? null : Uri.parse(photoUriString); + final String name = participant.getFullName(); + final String destination = participant.getNormalizedDestination(); + final String contactLookupKey = participant.getLookupKey(); + return createAvatarUri(profilePhotoUri, name, destination, contactLookupKey); + } + + /** + * Creates an avatar uri based on a the input data. + */ + public static Uri createAvatarUri(final Uri profilePhotoUri, final CharSequence name, + final String defaultIdentifier, final String contactLookupKey) { + Uri generatedUri; + if (!TextUtils.isEmpty(name) && isValidFirstCharacter(name)) { + generatedUri = AvatarUriUtil.fromName(name, contactLookupKey); + } else { + final String identifier = TextUtils.isEmpty(contactLookupKey) + ? defaultIdentifier : contactLookupKey; + generatedUri = AvatarUriUtil.fromIdentifier(identifier); + } + + if (profilePhotoUri != null) { + if (UriUtil.isLocalResourceUri(profilePhotoUri)) { + return fromLocalResourceWithFallback(profilePhotoUri, generatedUri); + } else { + return profilePhotoUri; + } + } else { + return generatedUri; + } + } + + public static boolean isValidFirstCharacter(final CharSequence name) { + final char c = name.charAt(0); + return c != '+'; + } + + /** + * Creates an avatar URI used for the SIM selector. + * @param participantData the self participant data for an active SIM + * @param slotIdentifier when null, this will simply use a regular avatar; otherwise, the + * first letter of slotIdentifier will be used for the icon. + * @param selected is this the currently selected SIM? + * @param incoming is this for an incoming message or outgoing message? + */ + public static Uri createAvatarUri(@NonNull final ParticipantData participantData, + @Nullable final String slotIdentifier, final boolean selected, final boolean incoming) { + Assert.notNull(participantData); + Assert.isTrue(participantData.isActiveSubscription()); + Assert.isTrue(!TextUtils.isEmpty(slotIdentifier) || + !TextUtils.isEmpty(participantData.getProfilePhotoUri())); + if (TextUtils.isEmpty(slotIdentifier)) { + return createAvatarUri(participantData); + } + + return createSimIconUri(slotIdentifier, selected, participantData.getSubscriptionColor(), + incoming); + } + + private static Uri createSimIconUri(final String slotIdentifier, final boolean selected, + final int subColor, final boolean incoming) { + final Builder builder = new Builder(); + builder.scheme(SCHEME); + builder.authority(AUTHORITY); + builder.appendPath(TYPE_SIM_SELECTOR_URI); + builder.appendQueryParameter(PARAM_IDENTIFIER, slotIdentifier); + builder.appendQueryParameter(PARAM_SIM_COLOR, String.valueOf(subColor)); + builder.appendQueryParameter(PARAM_SIM_SELECTED, String.valueOf(selected)); + builder.appendQueryParameter(PARAM_SIM_INCOMING, String.valueOf(incoming)); + return builder.build(); + } + + public static Uri getBlankSimIndicatorUri(final boolean incoming) { + return incoming ? BLANK_SIM_INDICATOR_INCOMING_URI : BLANK_SIM_INDICATOR_OUTGOING_URI; + } + + /** + * Creates an avatar uri from the given local resource Uri, followed by a fallback Uri in case + * the local resource one could not be loaded. + */ + private static Uri fromLocalResourceWithFallback(@NonNull final Uri profilePhotoUri, + @NonNull Uri fallbackUri) { + Assert.notNull(profilePhotoUri); + Assert.notNull(fallbackUri); + final Builder builder = new Builder(); + builder.scheme(SCHEME); + builder.authority(AUTHORITY); + builder.appendPath(TYPE_LOCAL_RESOURCE_URI); + builder.appendQueryParameter(PARAM_PRIMARY_URI, profilePhotoUri.toString()); + builder.appendQueryParameter(PARAM_FALLBACK_URI, fallbackUri.toString()); + return builder.build(); + } + + private static Uri fromName(@NonNull final CharSequence name, final String contactLookupKey) { + Assert.notNull(name); + final Builder builder = new Builder(); + builder.scheme(SCHEME); + builder.authority(AUTHORITY); + builder.appendPath(TYPE_LETTER_TILE_URI); + final String nameString = String.valueOf(name); + builder.appendQueryParameter(PARAM_NAME, nameString); + final String identifier = + TextUtils.isEmpty(contactLookupKey) ? nameString : contactLookupKey; + builder.appendQueryParameter(PARAM_IDENTIFIER, identifier); + return builder.build(); + } + + private static Uri fromIdentifier(@NonNull final String identifier) { + final Builder builder = new Builder(); + builder.scheme(SCHEME); + builder.authority(AUTHORITY); + builder.appendPath(TYPE_DEFAULT_URI); + builder.appendQueryParameter(PARAM_IDENTIFIER, identifier); + return builder.build(); + } + + public static boolean isAvatarUri(@NonNull final Uri uri) { + Assert.notNull(uri); + return uri != null && TextUtils.equals(SCHEME, uri.getScheme()) && + TextUtils.equals(AUTHORITY, uri.getAuthority()); + } + + public static String getAvatarType(@NonNull final Uri uri) { + Assert.notNull(uri); + final List path = uri.getPathSegments(); + return path.isEmpty() ? null : path.get(0); + } + + public static String getIdentifier(@NonNull final Uri uri) { + Assert.notNull(uri); + return uri.getQueryParameter(PARAM_IDENTIFIER); + } + + public static String getName(@NonNull final Uri uri) { + Assert.notNull(uri); + return uri.getQueryParameter(PARAM_NAME); + } + + public static List getGroupParticipantUris(@NonNull final Uri uri) { + Assert.notNull(uri); + return uri.getQueryParameters(PARAM_PARTICIPANT); + } + + public static int getSimColor(@NonNull final Uri uri) { + Assert.notNull(uri); + return Integer.valueOf(uri.getQueryParameter(PARAM_SIM_COLOR)); + } + + public static boolean getSimSelected(@NonNull final Uri uri) { + Assert.notNull(uri); + return Boolean.valueOf(uri.getQueryParameter(PARAM_SIM_SELECTED)); + } + + public static boolean getSimIncoming(@NonNull final Uri uri) { + Assert.notNull(uri); + return Boolean.valueOf(uri.getQueryParameter(PARAM_SIM_INCOMING)); + } + + public static Uri getPrimaryUri(@NonNull final Uri uri) { + Assert.notNull(uri); + final String primaryUriString = uri.getQueryParameter(PARAM_PRIMARY_URI); + return primaryUriString == null ? null : Uri.parse(primaryUriString); + } + + public static Uri getFallbackUri(@NonNull final Uri uri) { + Assert.notNull(uri); + final String fallbackUriString = uri.getQueryParameter(PARAM_FALLBACK_URI); + return fallbackUriString == null ? null : Uri.parse(fallbackUriString); + } +} diff --git a/src/com/android/messaging/util/BugleActivityUtil.java b/src/com/android/messaging/util/BugleActivityUtil.java new file mode 100644 index 0000000..7f722fd --- /dev/null +++ b/src/com/android/messaging/util/BugleActivityUtil.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.os.UserManager; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.ui.conversation.ConversationActivity; +import com.android.messaging.ui.conversationlist.ConversationListActivity; + +/** + * Utility class including logic to verify requirements to run Bugle and other activity startup + * logic. Called from base Bugle activity classes. + */ +public class BugleActivityUtil { + + private static final int REQUEST_GOOGLE_PLAY_SERVICES = 0; + + /** + * Determine if the requirements for the app to run are met. Log any Activity startup + * analytics. + * @param context + * @param activity is used to launch an error Dialog if necessary + * @return true if resume should continue normally. Returns false if some requirements to run + * are not met. + */ + public static boolean onActivityResume(Context context, Activity activity) { + DataModel.get().onActivityResume(); + Factory.get().onActivityResume(); + + // Validate all requirements to run are met + return checkHasSmsPermissionsForUser(context, activity); + } + + /** + * Determine if the user doesn't have SMS permissions. This can happen if you are not the phone + * owner and the owner has disabled your SMS permissions. + * @param context is the Context used to resolve the user permissions + * @param activity is the Activity used to launch an error Dialog if necessary + * @return true if the user has SMS permissions, otherwise false. + */ + private static boolean checkHasSmsPermissionsForUser(Context context, Activity activity) { + if (!OsUtil.isAtLeastL()) { + // UserManager.DISALLOW_SMS added in L. No multiuser phones before this + return true; + } + UserManager userManager = (UserManager) context.getSystemService(Context.USER_SERVICE); + if (userManager.hasUserRestriction(UserManager.DISALLOW_SMS)) { + new AlertDialog.Builder(activity) + .setMessage(R.string.requires_sms_permissions_message) + .setCancelable(false) + .setNegativeButton(R.string.requires_sms_permissions_close_button, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, + final int button) { + System.exit(0); + } + }) + .show(); + return false; + } + return true; + } +} + diff --git a/src/com/android/messaging/util/BugleApplicationPrefs.java b/src/com/android/messaging/util/BugleApplicationPrefs.java new file mode 100644 index 0000000..e9fceb4 --- /dev/null +++ b/src/com/android/messaging/util/BugleApplicationPrefs.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import android.content.Context; + +/** + * Provides interface to access application-wide shared preferences. This includes both the user + * visible preferences (e.g. the general settings in the settings page), and internal preferences + * under {@link BuglePrefsKeys}. + */ +public class BugleApplicationPrefs extends BuglePrefsImpl { + public BugleApplicationPrefs(Context context) { + super(context); + } + + @Override + public String getSharedPreferencesName() { + return SHARED_PREFERENCES_NAME; + } + + @Override + protected void validateKey(String key) { + super.validateKey(key); + // Callers shouldn't try to access per-subscription preferences from this class + Assert.isFalse(key.startsWith(SHARED_PREFERENCES_PER_SUBSCRIPTION_PREFIX)); + } + + @Override + public void onUpgrade(int oldVersion, int newVersion) { + } +} diff --git a/src/com/android/messaging/util/BugleGservices.java b/src/com/android/messaging/util/BugleGservices.java new file mode 100644 index 0000000..2e095de --- /dev/null +++ b/src/com/android/messaging/util/BugleGservices.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import com.android.messaging.Factory; + +/** + * A thin wrapper for getting GServices value. During constructor time a one time background thread + * will cache all GServices key with the prefix of "bugle_". All get calls will wait for Gservices + * to finish caching the first time. In practice, the background thread will finish before any get + * request. + */ +public abstract class BugleGservices { + static final String BUGLE_GSERVICES_PREFIX = "bugle_"; + + public static BugleGservices get() { + return Factory.get().getBugleGservices(); + } + + public abstract void registerForChanges(final Runnable r); + + /** + * @param key The key to look up in GServices + * @param defaultValue The default value if value in GServices is null or if + * NumberFormatException is caught. + * @return The corresponding value, or the default value. + */ + public abstract long getLong(final String key, final long defaultValue); + + /** + * @param key The key to look up in GServices + * @param defaultValue The default value if value in GServices is null or if + * NumberFormatException is caught. + * @return The corresponding value, or the default value. + */ + public abstract int getInt(final String key, final int defaultValue); + + /** + * @param key The key to look up in GServices + * @param defaultValue The default value if value in GServices is null. + * @return The corresponding value, or the default value. + */ + public abstract boolean getBoolean(final String key, final boolean defaultValue); + + /** + * @param key The key to look up in GServices + * @param defaultValue The default value if value in GServices is null. + * @return The corresponding value, or the default value. + */ + public abstract String getString(final String key, final String defaultValue); + + /** + * @param key The key to look up in GServices + * @param defaultValue The default value if value in GServices is null. + * @return The corresponding value, or the default value. + */ + public abstract float getFloat(final String key, final float defaultValue); +} diff --git a/src/com/android/messaging/util/BugleGservicesImpl.java b/src/com/android/messaging/util/BugleGservicesImpl.java new file mode 100644 index 0000000..5ef0898 --- /dev/null +++ b/src/com/android/messaging/util/BugleGservicesImpl.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.content.Context; + +/** + * A thin wrapper for getting GServices value. + */ +public class BugleGservicesImpl extends BugleGservices { + public BugleGservicesImpl(final Context context) { + } + + @Override + public void registerForChanges(final Runnable r) { + } + + /** + * Asserts that the key has the expected prefix. + */ + private void assertKeyAndWaitForGservices(final String key) { + Assert.isTrue(key.startsWith(BUGLE_GSERVICES_PREFIX)); + } + + @Override + public long getLong(final String key, final long defaultValue) { + assertKeyAndWaitForGservices(key); + return defaultValue; + } + + @Override + public int getInt(final String key, final int defaultValue) { + assertKeyAndWaitForGservices(key); + return defaultValue; + } + + @Override + public boolean getBoolean(final String key, final boolean defaultValue) { + assertKeyAndWaitForGservices(key); + return defaultValue; + } + + @Override + public String getString(final String key, final String defaultValue) { + assertKeyAndWaitForGservices(key); + return defaultValue; + } + + @Override + public float getFloat(final String key, final float defaultValue) { + assertKeyAndWaitForGservices(key); + return defaultValue; + } +} diff --git a/src/com/android/messaging/util/BugleGservicesKeys.java b/src/com/android/messaging/util/BugleGservicesKeys.java new file mode 100644 index 0000000..f36dd7f --- /dev/null +++ b/src/com/android/messaging/util/BugleGservicesKeys.java @@ -0,0 +1,298 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + + +/** + * List of gservices keys and default values which are in use. + */ +public final class BugleGservicesKeys { + private BugleGservicesKeys() {} // do not instantiate + + /** + * Whether to enable extra debugging features on the client. Default is + * {@value #ENABLE_DEBUGGING_FEATURES_DEFAULT}. + */ + public static final String ENABLE_DEBUGGING_FEATURES + = "bugle_debugging"; + public static final boolean ENABLE_DEBUGGING_FEATURES_DEFAULT + = false; + + /** + * Whether to enable saving extra logs. Default is {@value #ENABLE_LOG_SAVER_DEFAULT}. + */ + public static final String ENABLE_LOG_SAVER = "bugle_logsaver"; + public static final boolean ENABLE_LOG_SAVER_DEFAULT = false; + + /** + * Time in milliseconds of initial (attempt 1) resend backoff for failing messages + */ + public static final String INITIAL_MESSAGE_RESEND_DELAY_MS = "bugle_resend_delay_in_millis"; + public static final long INITIAL_MESSAGE_RESEND_DELAY_MS_DEFAULT = 5 * 1000L; + + /** + * Time in milliseconds of max resend backoff for failing messages + */ + public static final String MAX_MESSAGE_RESEND_DELAY_MS = "bugle_max_resend_delay_in_millis"; + public static final long MAX_MESSAGE_RESEND_DELAY_MS_DEFAULT = 2 * 60 * 60 * 1000L; + + /** + * Time in milliseconds of resend window for unsent messages + */ + public static final String MESSAGE_RESEND_TIMEOUT_MS = "bugle_resend_timeout_in_millis"; + public static final long MESSAGE_RESEND_TIMEOUT_MS_DEFAULT = 20 * 60 * 1000L; + + /** + * Time in milliseconds of download window for new mms notifications + */ + public static final String MESSAGE_DOWNLOAD_TIMEOUT_MS = "bugle_download_timeout_in_millis"; + public static final long MESSAGE_DOWNLOAD_TIMEOUT_MS_DEFAULT = 20 * 60 * 1000L; + + /** + * Time in milliseconds for SMS send timeout + */ + public static final String SMS_SEND_TIMEOUT_IN_MILLIS = "bugle_sms_send_timeout"; + public static final long SMS_SEND_TIMEOUT_IN_MILLIS_DEFAULT = 5 * 60 * 1000L; + + /** + * Keys to control the SMS sync batch size. The batch size is defined by the number + * of messages that incur local database change, e.g. importing messages and + * deleting messages. + * + * 1. The minimum size for a batch and + * 2. The maximum size for a batch. + * The first batch uses the minimum size for probing. Set this to a small number for the + * first sync batch to make sure the user sees SMS showing up in conversations quickly + * Use these two settings to limit the number of messages to sync in each batch. + * The minimum is to make sure we always make progress during sync. The maximum is + * to limit the sync batch size within a reasonable range (needs to fit in an intent). + * 3. The time limit controls the limit of time duration of a sync batch. We can + * not control this directly due to the batching nature of sync. So this provides + * heuristics. We may sometime exceeds the limit if our calculation is off due to + * whatever reasons. Keeping this low ensures responsiveness of the application. + * 4. The limit on number of total messages to scan in one batch. + */ + public static final String SMS_SYNC_BATCH_SIZE_MIN = + "bugle_sms_sync_batch_size_min"; + public static final int SMS_SYNC_BATCH_SIZE_MIN_DEFAULT = 80; + public static final String SMS_SYNC_BATCH_SIZE_MAX = + "bugle_sms_sync_batch_size_max"; + public static final int SMS_SYNC_BATCH_SIZE_MAX_DEFAULT = 1000; + public static final String SMS_SYNC_BATCH_TIME_LIMIT_MILLIS = + "bugle_sms_sync_batch_time_limit"; + public static final long SMS_SYNC_BATCH_TIME_LIMIT_MILLIS_DEFAULT = 400; + public static final String SMS_SYNC_BATCH_MAX_MESSAGES_TO_SCAN = + "bugle_sms_sync_batch_max_messages_to_scan"; + public static final int SMS_SYNC_BATCH_MAX_MESSAGES_TO_SCAN_DEFAULT = + SMS_SYNC_BATCH_SIZE_MAX_DEFAULT * 4; + + /** + * Time in ms for sync to backoff from "now" to the latest message that will be sync'd. + * + * This controls the best case for how out of date the application will appear to be + * when bringing in changes made outside the application. It also represents a buffer + * to ensure that sync doesn't trigger based on changes made within the application. + */ + public static final String SMS_SYNC_BACKOFF_TIME_MILLIS = + "bugle_sms_sync_backoff_time"; + public static final long SMS_SYNC_BACKOFF_TIME_MILLIS_DEFAULT = 5000L; + + /** + * Just in case if we fall into a loop of full sync -> still not synchronized -> full sync ... + * This forces a backoff time so that we at most do full sync once a while (an hour by default) + */ + public static final String SMS_FULL_SYNC_BACKOFF_TIME_MILLIS = + "bugle_sms_full_sync_backoff_time"; + public static final long SMS_FULL_SYNC_BACKOFF_TIME_MILLIS_DEFAULT = 60 * 60 * 1000; + + /** + * Time duration to retain the most recent SMS messages for SMS storage purging + * + * Format: + * (w|m|y) + * Examples: + * "1y" -- a year + * "2w" -- two weeks + * "6m" -- six months + */ + public static final String SMS_STORAGE_PURGING_MESSAGE_RETAINING_DURATION = + "bugle_sms_storage_purging_message_retaining_duration"; + public static final String SMS_STORAGE_PURGING_MESSAGE_RETAINING_DURATION_DEFAULT = "1m"; + + /** + * MMS UA profile url. + * + * This is used on all Android devices running Hangout, so cannot just host the profile of the + * latest and greatest phones. However, if we're on KitKat or below we can't get the phone's + * UA profile and thus we need to send them the default url. + */ + public static final String MMS_UA_PROFILE_URL = + "bugle_mms_uaprofurl"; + public static final String MMS_UA_PROFILE_URL_DEFAULT = + "http://www.gstatic.com/android/sms/mms_ua_profile.xml"; + + /** + * MMS apn mmsc + */ + public static final String MMS_MMSC = + "bugle_mms_mmsc"; + + /** + * MMS apn proxy ip address + */ + public static final String MMS_PROXY_ADDRESS = + "bugle_mms_proxy_address"; + + /** + * MMS apn proxy port + */ + public static final String MMS_PROXY_PORT = + "bugle_mms_proxy_port"; + + /** + * List of known SMS system messages that we will ignore (no deliver, no abort) so that the + * user doesn't see them and the appropriate app is able to handle them. We are delivering + * these as a \n delimited list of patterns, however we should eventually move to storing + * them with the per-carrier mms config xml file. + */ + public static final String SMS_IGNORE_MESSAGE_REGEX = + "bugle_sms_ignore_message_regex"; + public static final String SMS_IGNORE_MESSAGE_REGEX_DEFAULT = ""; + + /** + * When receiving or importing an mms, limit the length of text to this limit. Huge blocks + * of text can cause the app to hang/ANR/or crash in native text code.. + */ + public static final String MMS_TEXT_LIMIT = "bugle_mms_text_limit"; + public static final int MMS_TEXT_LIMIT_DEFAULT = 2000; + + /** + * Max number of attachments the user may add to a single message. + */ + public static final String MMS_ATTACHMENT_LIMIT = "bugle_mms_attachment_limit"; + public static final int MMS_ATTACHMENT_LIMIT_DEFAULT = 10; + + /** + * The max number of messages to show in a single conversation notification. We always show + * the most recent message. If this value is >1, we may also include prior messages as well. + */ + public static final String MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION = + "bugle_max_messages_in_conversation_notification"; + public static final int MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_DEFAULT = 7; + + /** + * Time (in seconds) between notification ringing for incoming messages of the same + * conversation. We won't ding more often than this value for messages coming in at a high rate. + */ + public static final String NOTIFICATION_TIME_BETWEEN_RINGS_SECONDS + = "bugle_notification_time_between_rings_seconds"; + public static final int NOTIFICATION_TIME_BETWEEN_RINGS_SECONDS_DEFAULT = 10; + + /** + * The max number of messages to show in a single conversation notification, when a wearable + * device (i.e. smartwatch) is paired with the phone. Watches have a different UX model and + * less screen real estate, so we may want to optimize for that case. Note that if a wearable + * is paired, this value will apply to notifications as shown both on the watch and the phone. + */ + public static final String MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_WITH_WEARABLE = + "bugle_max_messages_in_conversation_notification_with_wearable"; + public static final int MAX_MESSAGES_IN_CONVERSATION_NOTIFICATION_WITH_WEARABLE_DEFAULT = 1; + + /** + * Regular expression to match against query. If it matches then display + * the query plan for this query. + */ + public static final String EXPLAIN_QUERY_PLAN_REGEXP = "bugle_query_plan_regexp"; + + /** + * Whether asserts are fatal on user/userdebug builds. + * Default is {@value #ASSERTS_FATAL_DEFAULT}. + */ + public static final String ASSERTS_FATAL = "bugle_asserts_fatal"; + public static final boolean ASSERTS_FATAL_DEFAULT = false; + + /** + * Whether to use API for sending/downloading MMS (if present, true for L). + * Default is {@value #USE_MMS_API_IF_PRESENT_DEFAULT}. + */ + public static final String USE_MMS_API_IF_PRESENT = "bugle_use_mms_api"; + public static final boolean USE_MMS_API_IF_PRESENT_DEFAULT = true; + + /** + * Whether to always auto-complete email addresses for sending MMS. By default, Bugle starts + * to auto-complete after the user has typed the "@" character. + * Default is (@value ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS_DEFAULT}. + */ + public static final String ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS = + "bugle_always_autocomplete_email_address"; + public static final boolean ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS_DEFAULT = false; + + // We typically request an aspect ratio close the the screen size, but some cameras can be + // flaky and not work well in certain aspect ratios. This allows us to guide the CameraManager + // to pick a more reliable aspect ratio. The value is a float like 1.333f or 1.777f. There is + // no hard coded default because the default is the screen aspect ratio. + public static final String CAMERA_ASPECT_RATIO = "bugle_camera_aspect_ratio"; + + /** + * The recent time range within which we should check MMS WAP Push duplication + * If the value is 0, it signals that we should use old dedup algorithm for wap push + */ + public static final String MMS_WAP_PUSH_DEDUP_TIME_LIMIT_SECS = + "bugle_mms_wap_push_dedup_time_limit_secs"; + public static final long MMS_WAP_PUSH_DEDUP_TIME_LIMIT_SECS_DEFAULT = 7 * 24 * 3600; // 7 days + + /** + * Whether to use persistent, on-disk LogSaver + */ + public static final String PERSISTENT_LOGSAVER = "bugle_persistent_logsaver"; + public static final boolean PERSISTENT_LOGSAVER_DEFAULT = false; + + /** + * For in-memory LogSaver, what's the size of memory buffer in number of records + */ + public static final String IN_MEMORY_LOGSAVER_RECORD_COUNT = + "bugle_in_memory_logsaver_record_count"; + public static final int IN_MEMORY_LOGSAVER_RECORD_COUNT_DEFAULT = 500; + + /** + * For on-disk LogSaver, what's the size of file rotation set + */ + public static final String PERSISTENT_LOGSAVER_ROTATION_SET_SIZE = + "bugle_persistent_logsaver_rotation_set_size"; + public static final int PERSISTENT_LOGSAVER_ROTATION_SET_SIZE_DEFAULT = 8; + + /** + * For on-disk LogSaver, what's the byte limit of a single log file + */ + public static final String PERSISTENT_LOGSAVER_FILE_LIMIT_BYTES = + "bugle_persistent_logsaver_file_limit"; + public static final int PERSISTENT_LOGSAVER_FILE_LIMIT_BYTES_DEFAULT = 256 * 1024; // 256KB + + /** + * We concatenate all text parts in an MMS to form the message text. This specifies + * the separator between the combinated text parts. Default is ' ' (space). + */ + public static final String MMS_TEXT_CONCAT_SEPARATOR = "bugle_mms_text_concat_separator"; + public static final String MMS_TEXT_CONCAT_SEPARATOR_DEFAULT = " "; + + /** + * Whether to enable transcoding GIFs. We sometimes need to compress GIFs to make them small + * enough to send via MMS (which often limits messages to 1 MB in size). + */ + public static final String ENABLE_GIF_TRANSCODING = "bugle_gif_transcoding"; + public static final boolean ENABLE_GIF_TRANSCODING_DEFAULT = true; +} diff --git a/src/com/android/messaging/util/BuglePrefs.java b/src/com/android/messaging/util/BuglePrefs.java new file mode 100644 index 0000000..74a0d46 --- /dev/null +++ b/src/com/android/messaging/util/BuglePrefs.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import com.android.messaging.Factory; + +/** + * Thin wrapper to get/set shared prefs values. + */ +public abstract class BuglePrefs { + /** + * Shared preferences name for preferences applicable to the entire app. + */ + public static final String SHARED_PREFERENCES_NAME = "bugle"; + + /** + * Shared preferences name for subscription-specific preferences. + * Note: for all subscription-specific preferences, please prefix the shared preference keys + * with "buglesub_", so that Bugle may perform runtime validations on preferences to make sure + * you don't accidentally write per-subscription settings into the general pref file, and vice + * versa. + */ + public static final String SHARED_PREFERENCES_PER_SUBSCRIPTION_PREFIX = "buglesub_"; + + /** + * A placeholder base version for Bugle builds where no shared pref version was defined. + */ + public static final int NO_SHARED_PREFERENCES_VERSION = -1; + + /** + * Returns the shared preferences file name to use. + * Subclasses should override and return the shared preferences file. + */ + public abstract String getSharedPreferencesName(); + + + /** + * Handles pref version upgrade. + */ + public abstract void onUpgrade(final int oldVersion, final int newVersion); + + /** + * Gets the SharedPreferences accessor to the application-wide preferences. + */ + public static BuglePrefs getApplicationPrefs() { + return Factory.get().getApplicationPrefs(); + } + + /** + * Gets the SharedPreferences accessor to the subscription-specific preferences. + */ + public static BuglePrefs getSubscriptionPrefs(final int subId) { + return Factory.get().getSubscriptionPrefs(subId); + } + + /** + * @param key The key to look up in shared prefs + * @param defaultValue The default value if value in shared prefs is null or if + * NumberFormatException is caught. + * @return The corresponding value, or the default value. + */ + public abstract int getInt(final String key, final int defaultValue); + + /** + * @param key The key to look up in shared prefs + * @param defaultValue The default value if value in shared prefs is null or if + * NumberFormatException is caught. + * @return The corresponding value, or the default value. + */ + public abstract long getLong(final String key, final long defaultValue); + + /** + * @param key The key to look up in shared prefs + * @param defaultValue The default value if value in shared prefs is null. + * @return The corresponding value, or the default value. + */ + public abstract boolean getBoolean(final String key, final boolean defaultValue); + + /** + * @param key The key to look up in shared prefs + * @param defaultValue The default value if value in shared prefs is null. + * @return The corresponding value, or the default value. + */ + public abstract String getString(final String key, final String defaultValue); + + /** + * @param key The key to look up in shared prefs + * @return The corresponding value, or null if not found. + */ + public abstract byte[] getBytes(final String key); + + /** + * @param key The key to set in shared prefs + * @param value The value to assign to the key + */ + public abstract void putInt(final String key, final int value); + + /** + * @param key The key to set in shared prefs + * @param value The value to assign to the key + */ + public abstract void putLong(final String key, final long value); + + /** + * @param key The key to set in shared prefs + * @param value The value to assign to the key + */ + public abstract void putBoolean(final String key, final boolean value); + + /** + * @param key The key to set in shared prefs + * @param value The value to assign to the key + */ + public abstract void putString(final String key, final String value); + + /** + * @param key The key to set in shared prefs + * @param value The value to assign to the key + */ + public abstract void putBytes(final String key, final byte[] value); + + /** + * @param key The key to remove from shared prefs + */ + public abstract void remove(String key); +} diff --git a/src/com/android/messaging/util/BuglePrefsImpl.java b/src/com/android/messaging/util/BuglePrefsImpl.java new file mode 100644 index 0000000..7563040 --- /dev/null +++ b/src/com/android/messaging/util/BuglePrefsImpl.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Base64; + +/** + * Thin wrapper to get/set shared prefs values. + */ +public abstract class BuglePrefsImpl extends BuglePrefs { + + private final Context mContext; + + public BuglePrefsImpl(final Context context) { + mContext = context; + } + + /** + * Validate the prefs key passed in. Subclasses should override this as needed to perform + * runtime checks (such as making sure per-subscription settings don't sneak into application- + * wide settings). + */ + protected void validateKey(String key) { + } + + @Override + public int getInt(final String key, final int defaultValue) { + validateKey(key); + final SharedPreferences prefs = mContext.getSharedPreferences( + getSharedPreferencesName(), Context.MODE_PRIVATE); + return prefs.getInt(key, defaultValue); + } + + @Override + public long getLong(final String key, final long defaultValue) { + validateKey(key); + final SharedPreferences prefs = mContext.getSharedPreferences( + getSharedPreferencesName(), Context.MODE_PRIVATE); + return prefs.getLong(key, defaultValue); + } + + @Override + public boolean getBoolean(final String key, final boolean defaultValue) { + validateKey(key); + final SharedPreferences prefs = mContext.getSharedPreferences( + getSharedPreferencesName(), Context.MODE_PRIVATE); + return prefs.getBoolean(key, defaultValue); + } + + @Override + public String getString(final String key, final String defaultValue) { + validateKey(key); + final SharedPreferences prefs = mContext.getSharedPreferences( + getSharedPreferencesName(), Context.MODE_PRIVATE); + return prefs.getString(key, defaultValue); + } + + @Override + public byte[] getBytes(String key) { + final String byteValue = getString(key, null); + return byteValue == null ? null : Base64.decode(byteValue, Base64.DEFAULT); + } + + @Override + public void putInt(final String key, final int value) { + validateKey(key); + final SharedPreferences prefs = mContext.getSharedPreferences( + getSharedPreferencesName(), Context.MODE_PRIVATE); + final SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(key, value); + editor.apply(); + } + + @Override + public void putLong(final String key, final long value) { + validateKey(key); + final SharedPreferences prefs = mContext.getSharedPreferences( + getSharedPreferencesName(), Context.MODE_PRIVATE); + final SharedPreferences.Editor editor = prefs.edit(); + editor.putLong(key, value); + editor.apply(); + } + + @Override + public void putBoolean(final String key, final boolean value) { + validateKey(key); + final SharedPreferences prefs = mContext.getSharedPreferences( + getSharedPreferencesName(), Context.MODE_PRIVATE); + final SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(key, value); + editor.apply(); + } + + @Override + public void putString(final String key, final String value) { + validateKey(key); + final SharedPreferences prefs = mContext.getSharedPreferences( + getSharedPreferencesName(), Context.MODE_PRIVATE); + final SharedPreferences.Editor editor = prefs.edit(); + editor.putString(key, value); + editor.apply(); + } + + @Override + public void putBytes(String key, byte[] value) { + final String encodedBytes = Base64.encodeToString(value, Base64.DEFAULT); + putString(key, encodedBytes); + } + + @Override + public void remove(final String key) { + validateKey(key); + final SharedPreferences prefs = mContext.getSharedPreferences( + getSharedPreferencesName(), Context.MODE_PRIVATE); + final SharedPreferences.Editor editor = prefs.edit(); + editor.remove(key); + editor.apply(); + } +} diff --git a/src/com/android/messaging/util/BuglePrefsKeys.java b/src/com/android/messaging/util/BuglePrefsKeys.java new file mode 100644 index 0000000..ae409bc --- /dev/null +++ b/src/com/android/messaging/util/BuglePrefsKeys.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +/** + * List of shared preferences keys and default values. These are all internal + * (not user-visible) preferences. Preferences that are exposed via the Settings + * activity should be defined in the constants.xml resource file instead. + */ +public final class BuglePrefsKeys { + private BuglePrefsKeys() {} // do not instantiate + + /** + * Bugle's shared preferences version + */ + public static final String SHARED_PREFERENCES_VERSION = + "shared_preferences_version"; + public static final int SHARED_PREFERENCES_VERSION_DEFAULT = + BuglePrefs.NO_SHARED_PREFERENCES_VERSION; + + /** + * Last time that we ran a a sync (in millis) + */ + public static final String LAST_SYNC_TIME + = "last_sync_time_millis"; + public static final long LAST_SYNC_TIME_DEFAULT + = -1; + + /** + * Last time that we ran a full sync (in millis) + */ + public static final String LAST_FULL_SYNC_TIME + = "last_full_sync_time_millis"; + public static final long LAST_FULL_SYNC_TIME_DEFAULT + = -1; + + /** + * Timestamp of the message for which we last did a message notification. + */ + public static final String LATEST_NOTIFICATION_MESSAGE_TIMESTAMP + = "latest_notification_message_timestamp"; + + /** + * The last selected chooser index in the media picker. + */ + public static final String SELECTED_MEDIA_PICKER_CHOOSER_INDEX + = "selected_media_picker_chooser_index"; + public static final int SELECTED_MEDIA_PICKER_CHOOSER_INDEX_DEFAULT + = -1; + + /** + * The attempt number when retrying ProcessPendingMessagesAction + */ + public static final String PROCESS_PENDING_MESSAGES_RETRY_COUNT + = "process_pending_retry"; + +} diff --git a/src/com/android/messaging/util/BugleSubscriptionPrefs.java b/src/com/android/messaging/util/BugleSubscriptionPrefs.java new file mode 100644 index 0000000..039712a --- /dev/null +++ b/src/com/android/messaging/util/BugleSubscriptionPrefs.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import android.content.Context; +import android.content.res.Resources; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.R; + +/** + * Provides interface to access per-subscription shared preferences. We have one instance of + * this per active subscription. + */ +public class BugleSubscriptionPrefs extends BuglePrefsImpl { + private final int mSubId; + + public BugleSubscriptionPrefs(final Context context, final int subId) { + super(context); + mSubId = subId; + } + + @Override + public String getSharedPreferencesName() { + return SHARED_PREFERENCES_PER_SUBSCRIPTION_PREFIX + String.valueOf(mSubId); + } + + @Override + protected void validateKey(String key) { + super.validateKey(key); + // Callers should only access per-subscription preferences from this class + Assert.isTrue(key.startsWith(SHARED_PREFERENCES_PER_SUBSCRIPTION_PREFIX)); + } + + @Override + public void onUpgrade(final int oldVersion, final int newVersion) { + switch (oldVersion) { + case BuglePrefs.NO_SHARED_PREFERENCES_VERSION: + // Upgrade to version 1. Adding per-subscription shared prefs. + // Migrate values from the application-wide settings. + migratePrefBooleanInternal(BuglePrefs.getApplicationPrefs(), "delivery_reports", + R.string.delivery_reports_pref_key, R.bool.delivery_reports_pref_default); + migratePrefBooleanInternal(BuglePrefs.getApplicationPrefs(), "auto_retrieve_mms", + R.string.auto_retrieve_mms_pref_key, R.bool.auto_retrieve_mms_pref_default); + migratePrefBooleanInternal(BuglePrefs.getApplicationPrefs(), + "auto_retrieve_mms_when_roaming", + R.string.auto_retrieve_mms_when_roaming_pref_key, + R.bool.auto_retrieve_mms_when_roaming_pref_default); + migratePrefBooleanInternal(BuglePrefs.getApplicationPrefs(), "group_messaging", + R.string.group_mms_pref_key, R.bool.group_mms_pref_default); + + if (PhoneUtils.getDefault().getActiveSubscriptionCount() == 1) { + migratePrefStringInternal(BuglePrefs.getApplicationPrefs(), "mms_phone_number", + R.string.mms_phone_number_pref_key, null); + } + } + } + + private void migratePrefBooleanInternal(final BuglePrefs oldPrefs, final String oldKey, + final int newKeyResId, final int defaultValueResId) { + final Resources resources = Factory.get().getApplicationContext().getResources(); + final boolean defaultValue = resources.getBoolean(defaultValueResId); + final boolean oldValue = oldPrefs.getBoolean(oldKey, defaultValue); + + // Only migrate pref value if it's different than the default. + if (oldValue != defaultValue) { + putBoolean(resources.getString(newKeyResId), oldValue); + } + } + + private void migratePrefStringInternal(final BuglePrefs oldPrefs, final String oldKey, + final int newKeyResId, final String defaultValue) { + final Resources resources = Factory.get().getApplicationContext().getResources(); + final String oldValue = oldPrefs.getString(oldKey, defaultValue); + + // Only migrate pref value if it's different than the default. + if (!TextUtils.equals(oldValue, defaultValue)) { + putString(resources.getString(newKeyResId), oldValue); + } + } +} diff --git a/src/com/android/messaging/util/BugleWidgetPrefs.java b/src/com/android/messaging/util/BugleWidgetPrefs.java new file mode 100644 index 0000000..63ba567 --- /dev/null +++ b/src/com/android/messaging/util/BugleWidgetPrefs.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import android.content.Context; + +/** + * Provides interface to access shared preferences used by bugle widgets. + */ +public class BugleWidgetPrefs extends BuglePrefsImpl { + /** + * Shared preferences name for preferences applicable to the entire app. + */ + public static final String SHARED_PREFERENCES_WIDGET_NAME = "bugle_widgets"; + + public BugleWidgetPrefs(Context context) { + super(context); + } + + @Override + public String getSharedPreferencesName() { + return SHARED_PREFERENCES_WIDGET_NAME; + } + + @Override + public void onUpgrade(int oldVersion, int newVersion) { + } +} diff --git a/src/com/android/messaging/util/ChangeDefaultSmsAppHelper.java b/src/com/android/messaging/util/ChangeDefaultSmsAppHelper.java new file mode 100644 index 0000000..6cf2f25 --- /dev/null +++ b/src/com/android/messaging/util/ChangeDefaultSmsAppHelper.java @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.app.Activity; +import android.app.Fragment; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.view.View; + +import com.android.messaging.R; +import com.android.messaging.ui.SnackBar; +import com.android.messaging.ui.UIIntents; + +public class ChangeDefaultSmsAppHelper { + private Runnable mRunAfterMadeDefault; + private ChangeSmsAppSettingRunnable mChangeSmsAppSettingRunnable; + + private static final int REQUEST_SET_DEFAULT_SMS_APP = 1; + + /** + * When there's some condition that prevents an operation, such as sending a message, + * call warnOfMissingActionConditions to put up a toast and allow the user to repair + * that condition. + * @param sending - true if we're called during a sending operation + * @param runAfterMadeDefault - a runnable to run after the user responds + * positively to the condition prompt and resolves the condition. It is + * preferable to specify the value in {@link #handleChangeDefaultSmsResult} + * as that handles the case where the process gets restarted. + * If null, the user will be shown a generic toast message. + * @param composeView - compose view that may have the keyboard opened and focused + * @param rootView - if non-null, use this to attach a snackBar + * @param activity - calling activity + * @param fragment - calling fragment, may be null if called directly from an activity + */ + public void warnOfMissingActionConditions(final boolean sending, + final Runnable runAfterMadeDefault, + final View composeView, final View rootView, + final Activity activity, final Fragment fragment) { + final PhoneUtils phoneUtils = PhoneUtils.getDefault(); + final boolean isSmsCapable = phoneUtils.isSmsCapable(); + final boolean hasPreferredSmsSim = phoneUtils.getHasPreferredSmsSim(); + final boolean isDefaultSmsApp = phoneUtils.isDefaultSmsApp(); + + // Supports SMS? + if (!isSmsCapable) { + UiUtils.showToast(R.string.sms_disabled); + + // Has a preferred sim? + } else if (!hasPreferredSmsSim) { + UiUtils.showToast(R.string.no_preferred_sim_selected); + + // Is the default sms app? + } else if (!isDefaultSmsApp) { + mChangeSmsAppSettingRunnable = new ChangeSmsAppSettingRunnable(activity, fragment); + promptToChangeDefaultSmsApp(sending, runAfterMadeDefault, + composeView, rootView, activity); + } + + LogUtil.w(LogUtil.BUGLE_TAG, "Unsatisfied action condition: " + + "isSmsCapable=" + isSmsCapable + ", " + + "hasPreferredSmsSim=" + hasPreferredSmsSim + ", " + + "isDefaultSmsApp=" + isDefaultSmsApp); + } + + private void promptToChangeDefaultSmsApp(final boolean sending, + final Runnable runAfterMadeDefault, + final View composeView, final View rootView, + final Activity activity) { + if (composeView != null) { + // Avoid bug in system which puts soft keyboard over dialog after orientation change + ImeUtil.hideSoftInput(activity, composeView); + } + mRunAfterMadeDefault = runAfterMadeDefault; + + if (rootView == null) { + // Immediately open the system "Change default SMS app?" dialog setting. + mChangeSmsAppSettingRunnable.run(); + } else { + UiUtils.showSnackBarWithCustomAction(activity, + rootView, + activity.getString(sending ? R.string.requires_default_sms_app_to_send : + R.string.requires_default_sms_app), + SnackBar.Action.createCustomAction(mChangeSmsAppSettingRunnable, + activity.getString(R.string.requires_default_sms_change_button)), + null /* interactions */, + SnackBar.Placement.above(composeView)); + } + } + + private class ChangeSmsAppSettingRunnable implements Runnable { + private final Activity mActivity; + private final Fragment mFragment; + + public ChangeSmsAppSettingRunnable(final Activity activity, final Fragment fragment) { + mActivity = activity; + mFragment = fragment; + } + + @Override + public void run() { + try { + final Intent intent = UIIntents.get().getChangeDefaultSmsAppIntent(mActivity); + if (mFragment != null) { + mFragment.startActivityForResult(intent, REQUEST_SET_DEFAULT_SMS_APP); + } else { + mActivity.startActivityForResult(intent, REQUEST_SET_DEFAULT_SMS_APP); + } + } catch (final ActivityNotFoundException ex) { + // We shouldn't get here, but the monkey on JB MR0 can trigger it. + LogUtil.w(LogUtil.BUGLE_TAG, "Couldn't find activity:", ex); + UiUtils.showToastAtBottom(R.string.activity_not_found_message); + } + } + } + + public void handleChangeDefaultSmsResult( + final int requestCode, + final int resultCode, + Runnable runAfterMadeDefault) { + Assert.isTrue(mRunAfterMadeDefault == null || runAfterMadeDefault == null); + if (runAfterMadeDefault == null) { + runAfterMadeDefault = mRunAfterMadeDefault; + } + + if (requestCode == REQUEST_SET_DEFAULT_SMS_APP) { + if (resultCode == Activity.RESULT_OK) { + // mRunAfterMadeDefault can be null if it was set only in + // promptToChangeDefaultSmsApp, and the process subsequently restarted when the + // user momentarily switched to another app. In that case, we'll simply show a + // generic toast since we do not know what the runnable was supposed to do. + if (runAfterMadeDefault != null) { + runAfterMadeDefault.run(); + } else { + UiUtils.showToast(R.string.toast_after_setting_default_sms_app); + } + } + mRunAfterMadeDefault = null; // don't want to accidentally run it again + } + } +} + + diff --git a/src/com/android/messaging/util/CircularArray.java b/src/com/android/messaging/util/CircularArray.java new file mode 100644 index 0000000..db6cf12 --- /dev/null +++ b/src/com/android/messaging/util/CircularArray.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +/** + * Very simple circular array implementation. + * + * @param The element type of this list. + * @LibraryInternal + */ +public class CircularArray { + private int mNextWriter; + private boolean mHasWrapped; + private int mMaxCount; + Object mList[]; + + /** + * Constructor for CircularArray. + * + * @param count Max elements to hold in the list. + */ + public CircularArray(int count) { + mMaxCount = count; + clear(); + } + + /** + * Reset the list. + */ + public void clear() { + mNextWriter = 0; + mHasWrapped = false; + mList = new Object[mMaxCount]; + } + + /** + * Add an element to the end of the list. + * + * @param object The object to add. + */ + public void add(E object) { + mList[mNextWriter] = object; + ++mNextWriter; + if (mNextWriter == mMaxCount) { + mNextWriter = 0; + mHasWrapped = true; + } + } + + /** + * Get the number of elements in the list. This will be 0 <= returned count <= max count + * + * @return Elements in the circular list. + */ + public int count() { + if (mHasWrapped) { + return mMaxCount; + } else { + return mNextWriter; + } + } + + /** + * Return null if the list hasn't wrapped yet. Otherwise return the next object that would be + * overwritten. Can be useful to avoid extra allocations. + * + * @return + */ + @SuppressWarnings("unchecked") + public E getFree() { + if (!mHasWrapped) { + return null; + } else { + return (E) mList[mNextWriter]; + } + } + + /** + * Get the object at index. Index 0 is the oldest item inserted into the list. Index (count() - + * 1) is the newest. + * + * @param index Index to retrieve. + * @return Object at index. + */ + @SuppressWarnings("unchecked") + public E get(int index) { + if (mHasWrapped) { + int wrappedIndex = index + mNextWriter; + if (wrappedIndex >= mMaxCount) { + wrappedIndex -= mMaxCount; + } + return (E) mList[wrappedIndex]; + } else { + return (E) mList[index]; + } + } +} diff --git a/src/com/android/messaging/util/ConnectivityUtil.java b/src/com/android/messaging/util/ConnectivityUtil.java new file mode 100644 index 0000000..49f6e0a --- /dev/null +++ b/src/com/android/messaging/util/ConnectivityUtil.java @@ -0,0 +1,247 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.ConnectivityManager; +import android.telephony.PhoneStateListener; +import android.telephony.ServiceState; +import android.telephony.SignalStrength; +import android.telephony.TelephonyManager; + +public class ConnectivityUtil { + // Assume not connected until informed differently + private volatile int mCurrentServiceState = ServiceState.STATE_POWER_OFF; + + private final TelephonyManager mTelephonyManager; + private final Context mContext; + private final ConnectivityBroadcastReceiver mReceiver; + private final ConnectivityManager mConnMgr; + + private ConnectivityListener mListener; + private final IntentFilter mIntentFilter; + + public interface ConnectivityListener { + public void onConnectivityStateChanged(final Context context, final Intent intent); + public void onPhoneStateChanged(final Context context, int serviceState); + } + + public ConnectivityUtil(final Context context) { + mContext = context; + mTelephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + mConnMgr = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + mReceiver = new ConnectivityBroadcastReceiver(); + mIntentFilter = new IntentFilter(); + mIntentFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION); + } + + public int getCurrentServiceState() { + return mCurrentServiceState; + } + + private final PhoneStateListener mPhoneStateListener = new PhoneStateListener() { + @Override + public void onServiceStateChanged(final ServiceState serviceState) { + if (mCurrentServiceState != serviceState.getState()) { + mCurrentServiceState = serviceState.getState(); + onPhoneStateChanged(mCurrentServiceState); + } + } + + @Override + public void onDataConnectionStateChanged(final int state) { + mCurrentServiceState = (state == TelephonyManager.DATA_DISCONNECTED) ? + ServiceState.STATE_OUT_OF_SERVICE : ServiceState.STATE_IN_SERVICE; + } + }; + + private void onPhoneStateChanged(final int serviceState) { + final ConnectivityListener listener = mListener; + if (listener != null) { + listener.onPhoneStateChanged(mContext, serviceState); + } + } + + private void onConnectivityChanged(final Context context, final Intent intent) { + final ConnectivityListener listener = mListener; + if (listener != null) { + listener.onConnectivityStateChanged(context, intent); + } + } + + public void register(final ConnectivityListener listener) { + Assert.isTrue(mListener == null || mListener == listener); + if (mListener == null) { + if (mTelephonyManager != null) { + mCurrentServiceState = (PhoneUtils.getDefault().isAirplaneModeOn() ? + ServiceState.STATE_POWER_OFF : ServiceState.STATE_IN_SERVICE); + mTelephonyManager.listen(mPhoneStateListener, + PhoneStateListener.LISTEN_SERVICE_STATE); + } + if (mConnMgr != null) { + mContext.registerReceiver(mReceiver, mIntentFilter); + } + } + mListener = listener; + } + + public void unregister() { + if (mListener != null) { + if (mTelephonyManager != null) { + mTelephonyManager.listen(mPhoneStateListener, PhoneStateListener.LISTEN_NONE); + mCurrentServiceState = ServiceState.STATE_POWER_OFF; + } + if (mConnMgr != null) { + mContext.unregisterReceiver(mReceiver); + } + } + mListener = null; + } + + /** + * Connectivity change broadcast receiver. This gets the network connectivity updates. + * In case we don't get the active connectivity when we first acquire the network, + * this receiver will notify us when it is connected, so to unblock the waiting thread + * which is sending the message. + */ + public class ConnectivityBroadcastReceiver extends BroadcastReceiver { + @Override + public void onReceive(final Context context, final Intent intent) { + if (!intent.getAction().equals(ConnectivityManager.CONNECTIVITY_ACTION)) { + return; + } + + onConnectivityChanged(context, intent); + } + } + + private int mSignalLevel = SIGNAL_STRENGTH_NONE_OR_UNKNOWN; + + // We use a separate instance than mPhoneStateListener because the lifetimes are different. + private final PhoneStateListener mSignalStrengthListener = new PhoneStateListener() { + @Override + public void onSignalStrengthsChanged(final SignalStrength signalStrength) { + mSignalLevel = getLevel(signalStrength); + } + }; + + public void registerForSignalStrength() { + mTelephonyManager.listen( + mSignalStrengthListener, PhoneStateListener.LISTEN_SIGNAL_STRENGTHS); + } + + public void unregisterForSignalStrength() { + mTelephonyManager.listen(mSignalStrengthListener, PhoneStateListener.LISTEN_NONE); + } + + /** + * @param subId This is ignored because TelephonyManager does not support it. + * @return Signal strength as level 0..4 + */ + public int getSignalLevel(final int subId) { + return mSignalLevel; + } + + private static final int SIGNAL_STRENGTH_NONE_OR_UNKNOWN = 0; + private static final int SIGNAL_STRENGTH_POOR = 1; + private static final int SIGNAL_STRENGTH_MODERATE = 2; + private static final int SIGNAL_STRENGTH_GOOD = 3; + private static final int SIGNAL_STRENGTH_GREAT = 4; + + private static final int GSM_SIGNAL_STRENGTH_GREAT = 12; + private static final int GSM_SIGNAL_STRENGTH_GOOD = 8; + private static final int GSM_SIGNAL_STRENGTH_MODERATE = 8; + + private static int getLevel(final SignalStrength signalStrength) { + if (signalStrength.isGsm()) { + // From frameworks/base/telephony/java/android/telephony/CellSignalStrengthGsm.java + + // ASU ranges from 0 to 31 - TS 27.007 Sec 8.5 + // asu = 0 (-113dB or less) is very weak + // signal, its better to show 0 bars to the user in such cases. + // asu = 99 is a special case, where the signal strength is unknown. + final int asu = signalStrength.getGsmSignalStrength(); + if (asu <= 2 || asu == 99) return SIGNAL_STRENGTH_NONE_OR_UNKNOWN; + else if (asu >= GSM_SIGNAL_STRENGTH_GREAT) return SIGNAL_STRENGTH_GREAT; + else if (asu >= GSM_SIGNAL_STRENGTH_GOOD) return SIGNAL_STRENGTH_GOOD; + else if (asu >= GSM_SIGNAL_STRENGTH_MODERATE) return SIGNAL_STRENGTH_MODERATE; + else return SIGNAL_STRENGTH_POOR; + } else { + // From frameworks/base/telephony/java/android/telephony/CellSignalStrengthCdma.java + + final int cdmaLevel = getCdmaLevel(signalStrength); + final int evdoLevel = getEvdoLevel(signalStrength); + if (evdoLevel == SIGNAL_STRENGTH_NONE_OR_UNKNOWN) { + /* We don't know evdo, use cdma */ + return getCdmaLevel(signalStrength); + } else if (cdmaLevel == SIGNAL_STRENGTH_NONE_OR_UNKNOWN) { + /* We don't know cdma, use evdo */ + return getEvdoLevel(signalStrength); + } else { + /* We know both, use the lowest level */ + return cdmaLevel < evdoLevel ? cdmaLevel : evdoLevel; + } + } + } + + /** + * Get cdma as level 0..4 + */ + private static int getCdmaLevel(final SignalStrength signalStrength) { + final int cdmaDbm = signalStrength.getCdmaDbm(); + final int cdmaEcio = signalStrength.getCdmaEcio(); + int levelDbm; + int levelEcio; + if (cdmaDbm >= -75) levelDbm = SIGNAL_STRENGTH_GREAT; + else if (cdmaDbm >= -85) levelDbm = SIGNAL_STRENGTH_GOOD; + else if (cdmaDbm >= -95) levelDbm = SIGNAL_STRENGTH_MODERATE; + else if (cdmaDbm >= -100) levelDbm = SIGNAL_STRENGTH_POOR; + else levelDbm = SIGNAL_STRENGTH_NONE_OR_UNKNOWN; + // Ec/Io are in dB*10 + if (cdmaEcio >= -90) levelEcio = SIGNAL_STRENGTH_GREAT; + else if (cdmaEcio >= -110) levelEcio = SIGNAL_STRENGTH_GOOD; + else if (cdmaEcio >= -130) levelEcio = SIGNAL_STRENGTH_MODERATE; + else if (cdmaEcio >= -150) levelEcio = SIGNAL_STRENGTH_POOR; + else levelEcio = SIGNAL_STRENGTH_NONE_OR_UNKNOWN; + final int level = (levelDbm < levelEcio) ? levelDbm : levelEcio; + return level; + } + /** + * Get Evdo as level 0..4 + */ + private static int getEvdoLevel(final SignalStrength signalStrength) { + final int evdoDbm = signalStrength.getEvdoDbm(); + final int evdoSnr = signalStrength.getEvdoSnr(); + int levelEvdoDbm; + int levelEvdoSnr; + if (evdoDbm >= -65) levelEvdoDbm = SIGNAL_STRENGTH_GREAT; + else if (evdoDbm >= -75) levelEvdoDbm = SIGNAL_STRENGTH_GOOD; + else if (evdoDbm >= -90) levelEvdoDbm = SIGNAL_STRENGTH_MODERATE; + else if (evdoDbm >= -105) levelEvdoDbm = SIGNAL_STRENGTH_POOR; + else levelEvdoDbm = SIGNAL_STRENGTH_NONE_OR_UNKNOWN; + if (evdoSnr >= 7) levelEvdoSnr = SIGNAL_STRENGTH_GREAT; + else if (evdoSnr >= 5) levelEvdoSnr = SIGNAL_STRENGTH_GOOD; + else if (evdoSnr >= 3) levelEvdoSnr = SIGNAL_STRENGTH_MODERATE; + else if (evdoSnr >= 1) levelEvdoSnr = SIGNAL_STRENGTH_POOR; + else levelEvdoSnr = SIGNAL_STRENGTH_NONE_OR_UNKNOWN; + final int level = (levelEvdoDbm < levelEvdoSnr) ? levelEvdoDbm : levelEvdoSnr; + return level; + } +} diff --git a/src/com/android/messaging/util/ContactRecipientEntryUtils.java b/src/com/android/messaging/util/ContactRecipientEntryUtils.java new file mode 100644 index 0000000..78c6ffd --- /dev/null +++ b/src/com/android/messaging/util/ContactRecipientEntryUtils.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import android.net.Uri; +import android.provider.ContactsContract.DisplayNameSources; +import android.text.TextUtils; + +import com.android.ex.chips.RecipientEntry; +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.BugleRecipientEntry; +import com.android.messaging.datamodel.data.ParticipantData; + +/** + * Provides utility methods around creating RecipientEntry instance specific to Bugle's needs. + */ +public class ContactRecipientEntryUtils { + /** + * A special contact id for generated contacts with no display name (number only) and avatar. + * By default, the chips UI doesn't load any avatar for chips with no display name, or where + * the display name is the same as phone number (which is true for unknown contacts). + * Since Bugle always generate a default avatar for all contacts, this is used to replace + * those default generated chips with a phone number and no avatars. + */ + private static final long CONTACT_ID_NUMBER_WITH_AVATAR = -1000; + + /** + * A generated special contact which says "Send to xxx" in the contact list, which allows + * a user to direct send an SMS to a number that was manually typed in. + */ + private static final long CONTACT_ID_SENDTO_DESTINATION = -1001; + + /** + * Construct a special "Send to xxx" entry for a given destination. + */ + public static RecipientEntry constructSendToDestinationEntry(final String destination) { + return constructSpecialRecipientEntry(destination, CONTACT_ID_SENDTO_DESTINATION); + } + + /** + * Construct a generated contact entry but with rendered avatar. + */ + public static RecipientEntry constructNumberWithAvatarEntry(final String destination) { + return constructSpecialRecipientEntry(destination, CONTACT_ID_NUMBER_WITH_AVATAR); + } + + private static RecipientEntry constructSpecialRecipientEntry(final String destination, + final long contactId) { + // For the send-to-destination (e.g. "Send to xxx" in the auto-complete drop-down) + // we want to show a default avatar with a static background so that it doesn't flicker + // as the user types. + final Uri avatarUri = contactId == CONTACT_ID_SENDTO_DESTINATION ? + AvatarUriUtil.DEFAULT_BACKGROUND_AVATAR : null; + return BugleRecipientEntry.constructTopLevelEntry(null, DisplayNameSources.STRUCTURED_NAME, + destination, RecipientEntry.INVALID_DESTINATION_TYPE, null, contactId, + null, contactId, avatarUri, true, null); + } + + /** + * Gets the display name for contact list only. For most cases this is the same as the normal + * contact name, but there are cases where these two differ. For example, for the + * send to typed number item, we'd like to show "Send to xxx" in the contact list. However, + * when this item is actually added to the chips edit box, we would like to show just the + * phone number (i.e. no display name). + */ + public static String getDisplayNameForContactList(final RecipientEntry entry) { + if (entry.getContactId() == CONTACT_ID_SENDTO_DESTINATION) { + return Factory.get().getApplicationContext().getResources().getString( + R.string.contact_list_send_to_text, formatDestination(entry)); + } else if (!TextUtils.isEmpty(entry.getDisplayName())) { + return entry.getDisplayName(); + } else { + return formatDestination(entry); + } + } + + public static String formatDestination(final RecipientEntry entry) { + return PhoneUtils.getDefault().formatForDisplay(entry.getDestination()); + } + + /** + * Returns true if the given entry has only avatar and number + */ + public static boolean isAvatarAndNumberOnlyContact(final RecipientEntry entry) { + return entry.getContactId() == CONTACT_ID_NUMBER_WITH_AVATAR; + } + + /** + * Returns true if the given entry is a special send to number item. + */ + public static boolean isSendToDestinationContact(final RecipientEntry entry) { + return entry.getContactId() == CONTACT_ID_SENDTO_DESTINATION; + } + + /** + * Returns true if the given participant is a special send to number item. + */ + public static boolean isSendToDestinationContact(final ParticipantData participant) { + return participant.getContactId() == CONTACT_ID_SENDTO_DESTINATION; + } +} diff --git a/src/com/android/messaging/util/ContactUtil.java b/src/com/android/messaging/util/ContactUtil.java new file mode 100644 index 0000000..8555889 --- /dev/null +++ b/src/com/android/messaging/util/ContactUtil.java @@ -0,0 +1,525 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Email; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.StructuredName; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Directory; +import android.provider.ContactsContract.DisplayNameSources; +import android.provider.ContactsContract.PhoneLookup; +import android.provider.ContactsContract.Profile; +import android.text.TextUtils; +import android.view.View; + +import com.android.ex.chips.RecipientEntry; +import com.android.messaging.Factory; +import com.android.messaging.datamodel.CursorQueryData; +import com.android.messaging.datamodel.FrequentContactsCursorQueryData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsSmsUtils; +import com.android.messaging.ui.contact.AddContactsConfirmationDialog; +import com.google.common.annotations.VisibleForTesting; + +/** + * Utility class including logic to list, filter, and lookup phone and emails in CP2. + */ +@VisibleForTesting +public class ContactUtil { + + /** + * Index of different columns in phone or email queries. All queries below should confirm to + * this column content and ordering so that caller can use the uniformed way to process + * returned cursors. + */ + public static final int INDEX_CONTACT_ID = 0; + public static final int INDEX_DISPLAY_NAME = 1; + public static final int INDEX_PHOTO_URI = 2; + public static final int INDEX_PHONE_EMAIL = 3; + public static final int INDEX_PHONE_EMAIL_TYPE = 4; + public static final int INDEX_PHONE_EMAIL_LABEL = 5; + + // An optional lookup_id column used by PhoneLookupQuery that is needed when querying for + // contact information. + public static final int INDEX_LOOKUP_KEY = 6; + + // An optional _id column to query results that need to be displayed in a list view. + public static final int INDEX_DATA_ID = 7; + + // An optional sort_key column for displaying contact section labels. + public static final int INDEX_SORT_KEY = 8; + + // Lookup key column index specific to frequent contacts query. + public static final int INDEX_LOOKUP_KEY_FREQUENT = 3; + + /** + * Constants for listing and filtering phones. + */ + public static class PhoneQuery { + public static final String SORT_KEY = Phone.SORT_KEY_PRIMARY; + + public static final String[] PROJECTION = new String[] { + Phone.CONTACT_ID, // 0 + Phone.DISPLAY_NAME_PRIMARY, // 1 + Phone.PHOTO_THUMBNAIL_URI, // 2 + Phone.NUMBER, // 3 + Phone.TYPE, // 4 + Phone.LABEL, // 5 + Phone.LOOKUP_KEY, // 6 + Phone._ID, // 7 + PhoneQuery.SORT_KEY, // 8 + }; + } + + /** + * Constants for looking up phone numbers. + */ + public static class PhoneLookupQuery { + public static final String[] PROJECTION = new String[] { + // The _ID field points to the contact id of the content + PhoneLookup._ID, // 0 + PhoneLookup.DISPLAY_NAME, // 1 + PhoneLookup.PHOTO_THUMBNAIL_URI, // 2 + PhoneLookup.NUMBER, // 3 + PhoneLookup.TYPE, // 4 + PhoneLookup.LABEL, // 5 + PhoneLookup.LOOKUP_KEY, // 6 + // The data id is not included as part of the projection since it's not part of + // PhoneLookup. This is okay because the _id field serves as both the data id and + // contact id. Also we never show the results directly in a list view so we are not + // concerned about duplicated _id's (namely, the same contact has two same phone + // numbers) + }; + } + + public static class FrequentContactQuery { + public static final String[] PROJECTION = new String[] { + Contacts._ID, // 0 + Contacts.DISPLAY_NAME, // 1 + Contacts.PHOTO_URI, // 2 + Phone.LOOKUP_KEY, // 3 + }; + } + + /** + * Constants for listing and filtering emails. + */ + public static class EmailQuery { + public static final String SORT_KEY = Email.SORT_KEY_PRIMARY; + + public static final String[] PROJECTION = new String[] { + Email.CONTACT_ID, // 0 + Email.DISPLAY_NAME_PRIMARY, // 1 + Email.PHOTO_THUMBNAIL_URI, // 2 + Email.ADDRESS, // 3 + Email.TYPE, // 4 + Email.LABEL, // 5 + Email.LOOKUP_KEY, // 6 + Email._ID, // 7 + EmailQuery.SORT_KEY, // 8 + }; + } + + public static final int INDEX_SELF_QUERY_LOOKUP_KEY = 3; + + /** + * Constants for querying self from CP2. + */ + public static class SelfQuery { + public static final String[] PROJECTION = new String[] { + Profile._ID, // 0 + Profile.DISPLAY_NAME_PRIMARY, // 1 + Profile.PHOTO_THUMBNAIL_URI, // 2 + Profile.LOOKUP_KEY // 3 + // Phone number, type, label and data_id is not provided in this projection since + // Profile CONTENT_URI doesn't include this information. Also, we don't need it + // we just need the name and avatar url. + }; + } + + public static class StructuredNameQuery { + public static final String[] PROJECTION = new String[] { + StructuredName.DISPLAY_NAME, + StructuredName.GIVEN_NAME, + StructuredName.FAMILY_NAME, + StructuredName.PREFIX, + StructuredName.MIDDLE_NAME, + StructuredName.SUFFIX + }; + } + + public static final int INDEX_STRUCTURED_NAME_DISPLAY_NAME = 0; + public static final int INDEX_STRUCTURED_NAME_GIVEN_NAME = 1; + public static final int INDEX_STRUCTURED_NAME_FAMILY_NAME = 2; + public static final int INDEX_STRUCTURED_NAME_PREFIX = 3; + public static final int INDEX_STRUCTURED_NAME_MIDDLE_NAME = 4; + public static final int INDEX_STRUCTURED_NAME_SUFFIX = 5; + + public static final long INVALID_CONTACT_ID = -1; + + /** + * This class is static. No need to create an instance. + */ + private ContactUtil() { + } + + /** + * Shows a contact card or add to contacts dialog for the given contact info + * @param view The view whose click triggered this to show + * @param contactId The id of the contact in the android contacts DB + * @param contactLookupKey The lookup key from contacts DB + * @param avatarUri Uri to the avatar image if available + * @param normalizedDestination The normalized phone number or email + */ + public static void showOrAddContact(final View view, final long contactId, + final String contactLookupKey, final Uri avatarUri, + final String normalizedDestination) { + if (contactId > ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED + && !TextUtils.isEmpty(contactLookupKey)) { + final Uri lookupUri = + ContactsContract.Contacts.getLookupUri(contactId, contactLookupKey); + ContactsContract.QuickContact.showQuickContact(view.getContext(), view, lookupUri, + ContactsContract.QuickContact.MODE_LARGE, null); + } else if (!TextUtils.isEmpty(normalizedDestination) && !TextUtils.equals( + normalizedDestination, ParticipantData.getUnknownSenderDestination())) { + final AddContactsConfirmationDialog dialog = new AddContactsConfirmationDialog( + view.getContext(), avatarUri, normalizedDestination); + dialog.show(); + } + } + + @VisibleForTesting + public static CursorQueryData getSelf(final Context context) { + if (!ContactUtil.hasReadContactsPermission()) { + return CursorQueryData.getEmptyQueryData(); + } + return new CursorQueryData(context, Profile.CONTENT_URI, SelfQuery.PROJECTION, null, null, + null); + } + + /** + * Get a list of phones sorted by contact name. One contact may have multiple phones. + * In that case, each phone will be returned as a separate record in the result cursor. + */ + @VisibleForTesting + public static CursorQueryData getPhones(final Context context) { + if (!ContactUtil.hasReadContactsPermission()) { + return CursorQueryData.getEmptyQueryData(); + } + + // The AOSP Contacts provider allows adding a ContactsContract.REMOVE_DUPLICATE_ENTRIES + // query parameter that removes duplicate (raw) numbers. Unfortunately, we can't use that + // because it causes the some phones' contacts provider to return incorrect sections. + final Uri uri = Phone.CONTENT_URI.buildUpon().appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT)) + .appendQueryParameter(Contacts.EXTRA_ADDRESS_BOOK_INDEX, "true") + .build(); + + return new CursorQueryData(context, uri, PhoneQuery.PROJECTION, null, null, + PhoneQuery.SORT_KEY); + } + + /** + * Lookup a destination (phone, email). Supplied destination should be a relatively complete + * one for this to succeed. PhoneLookup / EmailLookup URI will apply some smartness to do a + * loose match to see whether there is a contact that matches this destination. + */ + public static CursorQueryData lookupDestination(final Context context, + final String destination) { + if (MmsSmsUtils.isEmailAddress(destination)) { + return ContactUtil.lookupEmail(context, destination); + } else { + return ContactUtil.lookupPhone(context, destination); + } + } + + /** + * Returns whether the search text indicates an email based search or a phone number based one. + */ + private static boolean shouldFilterForEmail(final String searchText) { + return searchText != null && searchText.contains("@"); + } + + /** + * Get a list of destinations (phone, email) matching the partial destination. + */ + public static CursorQueryData filterDestination(final Context context, + final String destination) { + if (shouldFilterForEmail(destination)) { + return ContactUtil.filterEmails(context, destination); + } else { + return ContactUtil.filterPhones(context, destination); + } + } + + /** + * Get a list of phones matching a search criteria. The search may be on contact name or + * phone number. In case search is on contact name, all matching contact's phone number + * will be returned. + * NOTE: This is visible for testing only, clients should only call filterDestination() since + * we support email addresses as well. + */ + @VisibleForTesting + public static CursorQueryData filterPhones(final Context context, final String query) { + if (!ContactUtil.hasReadContactsPermission()) { + return CursorQueryData.getEmptyQueryData(); + } + + final Uri uri = Phone.CONTENT_FILTER_URI.buildUpon() + .appendPath(query).appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT)) + .build(); + + return new CursorQueryData(context, uri, PhoneQuery.PROJECTION, null, null, + PhoneQuery.SORT_KEY); + } + + /** + * Lookup a phone based on a phone number. Supplied phone should be a relatively complete + * phone number for this to succeed. PhoneLookup URI will apply some smartness to do a + * loose match to see whether there is a contact that matches this phone. + * NOTE: This is visible for testing only, clients should only call lookupDestination() since + * we support email addresses as well. + */ + @VisibleForTesting + public static CursorQueryData lookupPhone(final Context context, final String phone) { + if (!ContactUtil.hasReadContactsPermission()) { + return CursorQueryData.getEmptyQueryData(); + } + + final Uri uri = getPhoneLookupUri().buildUpon() + .appendPath(phone).build(); + + return new CursorQueryData(context, uri, PhoneLookupQuery.PROJECTION, null, null, null); + } + + /** + * Get frequently contacted people. This queries for Contacts.CONTENT_STREQUENT_URI, which + * includes both starred or frequently contacted people. + */ + public static CursorQueryData getFrequentContacts(final Context context) { + if (!ContactUtil.hasReadContactsPermission()) { + return CursorQueryData.getEmptyQueryData(); + } + + return new FrequentContactsCursorQueryData(context, FrequentContactQuery.PROJECTION, + null, null, null); + } + + /** + * Get a list of emails matching a search criteria. In Bugle, since email is not a common + * usage scenario, we should only do email search after user typed in a query indicating + * an intention to search by email (for example, "joe@"). + * NOTE: This is visible for testing only, clients should only call filterDestination() since + * we support email addresses as well. + */ + @VisibleForTesting + public static CursorQueryData filterEmails(final Context context, final String query) { + if (!ContactUtil.hasReadContactsPermission()) { + return CursorQueryData.getEmptyQueryData(); + } + + final Uri uri = Email.CONTENT_FILTER_URI.buildUpon() + .appendPath(query).appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT)) + .build(); + + return new CursorQueryData(context, uri, EmailQuery.PROJECTION, null, null, + EmailQuery.SORT_KEY); + } + + /** + * Lookup emails based a complete email address. Since there is no special logic needed for + * email lookup, this simply calls filterEmails. + * NOTE: This is visible for testing only, clients should only call lookupDestination() since + * we support email addresses as well. + */ + @VisibleForTesting + public static CursorQueryData lookupEmail(final Context context, final String email) { + if (!ContactUtil.hasReadContactsPermission()) { + return CursorQueryData.getEmptyQueryData(); + } + + final Uri uri = getEmailContentLookupUri().buildUpon() + .appendPath(email).appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT)) + .build(); + + return new CursorQueryData(context, uri, EmailQuery.PROJECTION, null, null, + EmailQuery.SORT_KEY); + } + + /** + * Looks up the structured name for a contact. + * + * @param primaryOnly If there are multiple raw contacts, set this flag to return only the + * name used as the primary display name. Otherwise, this method returns all names. + */ + private static CursorQueryData lookupStructuredName(final Context context, final long contactId, + final boolean primaryOnly) { + if (!ContactUtil.hasReadContactsPermission()) { + return CursorQueryData.getEmptyQueryData(); + } + + // TODO: Handle enterprise contacts + final Uri uri = ContactsContract.Contacts.CONTENT_URI.buildUpon() + .appendPath(String.valueOf(contactId)) + .appendPath(ContactsContract.Contacts.Data.CONTENT_DIRECTORY).build(); + + String selection = ContactsContract.Data.MIMETYPE + "=?"; + final String[] selectionArgs = { + StructuredName.CONTENT_ITEM_TYPE + }; + if (primaryOnly) { + selection += " AND " + Contacts.DISPLAY_NAME_PRIMARY + "=" + + StructuredName.DISPLAY_NAME; + } + + return new CursorQueryData(context, uri, + StructuredNameQuery.PROJECTION, selection, selectionArgs, null); + } + + /** + * Looks up the first name for a contact. If there are multiple raw + * contacts, this returns the name that is associated with the contact's + * primary display name. The name is null when contact id does not exist + * (possibly because it is a corp contact) or it does not have a first name. + */ + public static String lookupFirstName(final Context context, final long contactId) { + if (isEnterpriseContactId(contactId)) { + return null; + } + String firstName = null; + Cursor nameCursor = null; + try { + nameCursor = ContactUtil.lookupStructuredName(context, contactId, true) + .performSynchronousQuery(); + if (nameCursor != null && nameCursor.moveToFirst()) { + firstName = nameCursor.getString(ContactUtil.INDEX_STRUCTURED_NAME_GIVEN_NAME); + } + } finally { + if (nameCursor != null) { + nameCursor.close(); + } + } + return firstName; + } + + /** + * Creates a RecipientEntry from the provided data fields (from the contacts cursor). + * @param firstLevel whether this item is the first entry of this contact in the list. + */ + public static RecipientEntry createRecipientEntry(final String displayName, + final int displayNameSource, final String destination, final int destinationType, + final String destinationLabel, final long contactId, final String lookupKey, + final long dataId, final String photoThumbnailUri, final boolean firstLevel) { + if (firstLevel) { + return RecipientEntry.constructTopLevelEntry(displayName, displayNameSource, + destination, destinationType, destinationLabel, contactId, null, dataId, + photoThumbnailUri, true, lookupKey); + } else { + return RecipientEntry.constructSecondLevelEntry(displayName, displayNameSource, + destination, destinationType, destinationLabel, contactId, null, dataId, + photoThumbnailUri, true, lookupKey); + } + } + + /** + * Creates a RecipientEntry for PhoneQuery result. The result is then displayed in the + * contact search drop down or as replacement chips in the chips edit box. + */ + public static RecipientEntry createRecipientEntryForPhoneQuery(final Cursor cursor, + final boolean isFirstLevel) { + final long contactId = cursor.getLong(ContactUtil.INDEX_CONTACT_ID); + final String displayName = cursor.getString( + ContactUtil.INDEX_DISPLAY_NAME); + final String photoThumbnailUri = cursor.getString( + ContactUtil.INDEX_PHOTO_URI); + final String destination = cursor.getString( + ContactUtil.INDEX_PHONE_EMAIL); + final int destinationType = cursor.getInt( + ContactUtil.INDEX_PHONE_EMAIL_TYPE); + final String destinationLabel = cursor.getString( + ContactUtil.INDEX_PHONE_EMAIL_LABEL); + final String lookupKey = cursor.getString( + ContactUtil.INDEX_LOOKUP_KEY); + + // PhoneQuery uses the contact id as the data id ("_id"). + final long dataId = contactId; + + return createRecipientEntry(displayName, + DisplayNameSources.STRUCTURED_NAME, destination, destinationType, + destinationLabel, contactId, lookupKey, dataId, photoThumbnailUri, + isFirstLevel); + } + + /** + * Returns if a given contact id is valid. + */ + public static boolean isValidContactId(final long contactId) { + return contactId >= 0; + } + + /** + * Returns if a given contact id belongs to managed profile. + */ + public static boolean isEnterpriseContactId(final long contactId) { + return isWorkProfileSupported() + && ContactsContract.Contacts.isEnterpriseContactId(contactId); + } + + /** + * Returns if managed profile is supported. + */ + public static boolean isWorkProfileSupported() { + final PackageManager pm = Factory.get().getApplicationContext().getPackageManager(); + return pm.hasSystemFeature(PackageManager.FEATURE_MANAGED_USERS); + } + + /** + * Returns Email lookup uri that will query both primary and corp profile + */ + private static Uri getEmailContentLookupUri() { + if (isWorkProfileSupported() && OsUtil.isAtLeastM()) { + // TODO: use Email.ENTERPRISE_CONTENT_LOOKUP_URI, which will be available in M SDK API + return Uri.parse("content://com.android.contacts/data/emails/lookup_enterprise"); + } + return Email.CONTENT_LOOKUP_URI; + } + + /** + * Returns PhoneLookup URI. + */ + public static Uri getPhoneLookupUri() { + // Apply it to M only + if (isWorkProfileSupported() && OsUtil.isAtLeastM()) { + return PhoneLookup.ENTERPRISE_CONTENT_FILTER_URI; + } + return PhoneLookup.CONTENT_FILTER_URI; + } + + public static boolean hasReadContactsPermission() { + return OsUtil.hasPermission(Manifest.permission.READ_CONTACTS); + } +} diff --git a/src/com/android/messaging/util/ContentType.java b/src/com/android/messaging/util/ContentType.java new file mode 100644 index 0000000..bb4a7b2 --- /dev/null +++ b/src/com/android/messaging/util/ContentType.java @@ -0,0 +1,185 @@ +/* + * Copyright (C) 2007-2008 Esmertec AG. + * Copyright (C) 2007-2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.webkit.MimeTypeMap; + +public final class ContentType { + public static String THREE_GPP_EXTENSION = "3gp"; + public static String VIDEO_MP4_EXTENSION = "mp4"; + // Default extension used when we don't know one. + public static String DEFAULT_EXTENSION = "dat"; + + public static final int TYPE_IMAGE = 0; + public static final int TYPE_VIDEO = 1; + public static final int TYPE_AUDIO = 2; + public static final int TYPE_VCARD = 3; + public static final int TYPE_OTHER = 4; + + public static final String ANY_TYPE = "*/*"; + public static final String MMS_MESSAGE = "application/vnd.wap.mms-message"; + // The phony content type for generic PDUs (e.g. ReadOrig.ind, + // Notification.ind, Delivery.ind). + public static final String MMS_GENERIC = "application/vnd.wap.mms-generic"; + public static final String MMS_MULTIPART_MIXED = "application/vnd.wap.multipart.mixed"; + public static final String MMS_MULTIPART_RELATED = "application/vnd.wap.multipart.related"; + public static final String MMS_MULTIPART_ALTERNATIVE = + "application/vnd.wap.multipart.alternative"; + + public static final String TEXT_PLAIN = "text/plain"; + public static final String TEXT_HTML = "text/html"; + public static final String TEXT_VCALENDAR = "text/x-vCalendar"; + public static final String TEXT_VCARD = "text/x-vCard"; + + public static final String IMAGE_PREFIX = "image/"; + public static final String IMAGE_UNSPECIFIED = "image/*"; + public static final String IMAGE_JPEG = "image/jpeg"; + public static final String IMAGE_JPG = "image/jpg"; + public static final String IMAGE_GIF = "image/gif"; + public static final String IMAGE_WBMP = "image/vnd.wap.wbmp"; + public static final String IMAGE_PNG = "image/png"; + public static final String IMAGE_X_MS_BMP = "image/x-ms-bmp"; + + public static final String AUDIO_UNSPECIFIED = "audio/*"; + public static final String AUDIO_AAC = "audio/aac"; + public static final String AUDIO_AMR = "audio/amr"; + public static final String AUDIO_IMELODY = "audio/imelody"; + public static final String AUDIO_MID = "audio/mid"; + public static final String AUDIO_MIDI = "audio/midi"; + public static final String AUDIO_MP3 = "audio/mp3"; + public static final String AUDIO_MPEG3 = "audio/mpeg3"; + public static final String AUDIO_MPEG = "audio/mpeg"; + public static final String AUDIO_MPG = "audio/mpg"; + public static final String AUDIO_MP4 = "audio/mp4"; + public static final String AUDIO_MP4_LATM = "audio/mp4-latm"; + public static final String AUDIO_X_MID = "audio/x-mid"; + public static final String AUDIO_X_MIDI = "audio/x-midi"; + public static final String AUDIO_X_MP3 = "audio/x-mp3"; + public static final String AUDIO_X_MPEG3 = "audio/x-mpeg3"; + public static final String AUDIO_X_MPEG = "audio/x-mpeg"; + public static final String AUDIO_X_MPG = "audio/x-mpg"; + public static final String AUDIO_3GPP = "audio/3gpp"; + public static final String AUDIO_X_WAV = "audio/x-wav"; + public static final String AUDIO_OGG = "application/ogg"; + + public static final String MULTIPART_MIXED = "multipart/mixed"; + + public static final String VIDEO_UNSPECIFIED = "video/*"; + public static final String VIDEO_3GP = "video/3gp"; + public static final String VIDEO_3GPP = "video/3gpp"; + public static final String VIDEO_3G2 = "video/3gpp2"; + public static final String VIDEO_H263 = "video/h263"; + public static final String VIDEO_M4V = "video/m4v"; + public static final String VIDEO_MP4 = "video/mp4"; + public static final String VIDEO_MPEG = "video/mpeg"; + public static final String VIDEO_MPEG4 = "video/mpeg4"; + public static final String VIDEO_WEBM = "video/webm"; + + public static final String APP_SMIL = "application/smil"; + public static final String APP_WAP_XHTML = "application/vnd.wap.xhtml+xml"; + public static final String APP_XHTML = "application/xhtml+xml"; + + public static final String APP_DRM_CONTENT = "application/vnd.oma.drm.content"; + public static final String APP_DRM_MESSAGE = "application/vnd.oma.drm.message"; + + // This class should never be instantiated. + private ContentType() { + } + + public static boolean isTextType(final String contentType) { + return TEXT_PLAIN.equals(contentType) + || TEXT_HTML.equals(contentType) + || APP_WAP_XHTML.equals(contentType); + } + + public static boolean isMediaType(final String contentType) { + return isImageType(contentType) + || isVideoType(contentType) + || isAudioType(contentType) + || isVCardType(contentType); + } + + public static boolean isImageType(final String contentType) { + return (null != contentType) && contentType.startsWith(IMAGE_PREFIX); + } + + public static boolean isAudioType(final String contentType) { + return (null != contentType) && + (contentType.startsWith("audio/") || contentType.equalsIgnoreCase(AUDIO_OGG)); + } + + public static boolean isVideoType(final String contentType) { + return (null != contentType) && contentType.startsWith("video/"); + } + + public static boolean isVCardType(final String contentType) { + return (null != contentType) && contentType.equalsIgnoreCase(TEXT_VCARD); + } + + public static boolean isDrmType(final String contentType) { + return (null != contentType) + && (contentType.equals(APP_DRM_CONTENT) + || contentType.equals(APP_DRM_MESSAGE)); + } + + public static boolean isUnspecified(final String contentType) { + return (null != contentType) && contentType.endsWith("*"); + } + + /** + * If the content type is a type which can be displayed in the conversation list as a preview. + */ + public static boolean isConversationListPreviewableType(final String contentType) { + return ContentType.isAudioType(contentType) || ContentType.isVideoType(contentType) || + ContentType.isImageType(contentType) || ContentType.isVCardType(contentType); + } + + /** + * Given a filename, look at the extension and try and determine the mime type. + * + * @param fileName a filename to determine the type from, such as img1231.jpg + * @param contentTypeDefault type to use when the content type can't be determined from the file + * extension. It can be null or a type such as ContentType.IMAGE_UNSPECIFIED + * @return Content type of the extension. + */ + public static String getContentTypeFromExtension(final String fileName, + final String contentTypeDefault) { + final MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton(); + final String extension = MimeTypeMap.getFileExtensionFromUrl(fileName); + String contentType = mimeTypeMap.getMimeTypeFromExtension(extension); + if (contentType == null) { + contentType = contentTypeDefault; + } + return contentType; + } + + /** + * Get the common file extension for a given content type + * @param contentType The content type + * @return The extension without the . + */ + public static String getExtension(final String contentType) { + if (VIDEO_MP4.equals(contentType)) { + return VIDEO_MP4_EXTENSION; + } else if (VIDEO_3GPP.equals(contentType)) { + return THREE_GPP_EXTENSION; + } else { + return DEFAULT_EXTENSION; + } + } +} diff --git a/src/com/android/messaging/util/ConversationIdSet.java b/src/com/android/messaging/util/ConversationIdSet.java new file mode 100644 index 0000000..75bf634 --- /dev/null +++ b/src/com/android/messaging/util/ConversationIdSet.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; + +/** + * Utility class to make it easy to store multiple conversation id strings in a single string + * with delimeters. + */ +public class ConversationIdSet extends HashSet { + private static final String JOIN_DELIMITER = "|"; + private static final String SPLIT_DELIMITER = "\\|"; + + public ConversationIdSet() { + super(); + } + + public ConversationIdSet(final Collection asList) { + super(asList); + } + + public String first() { + if (size() > 0) { + return iterator().next(); + } else { + return null; + } + } + + public static ConversationIdSet createSet(final String conversationIdSetString) { + ConversationIdSet set = null; + if (conversationIdSetString != null) { + set = new ConversationIdSet(Arrays.asList(conversationIdSetString.split( + SPLIT_DELIMITER))); + } + return set; + } + + public String getDelimitedString() { + return OsUtil.joinFromSetWithDelimiter(this, JOIN_DELIMITER); + } + + public static String join(final String conversationIdSet1, final String conversationIdSet2) { + String joined = null; + if (conversationIdSet1 == null) { + joined = conversationIdSet2; + } else if (conversationIdSet2 != null) { + joined = conversationIdSet1 + JOIN_DELIMITER + conversationIdSet2; + } + return joined; + } + +} diff --git a/src/com/android/messaging/util/CubicBezierInterpolator.java b/src/com/android/messaging/util/CubicBezierInterpolator.java new file mode 100644 index 0000000..317b3e6 --- /dev/null +++ b/src/com/android/messaging/util/CubicBezierInterpolator.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.view.animation.Interpolator; + +/** + * Class that acts as an interpolator to match the cubic-bezier css timing function where p0 is + * fixed at 0,0 and p3 is fixed at 1,1 + */ +public class CubicBezierInterpolator implements Interpolator { + private final float mX1; + private final float mY1; + private final float mX2; + private final float mY2; + + public CubicBezierInterpolator(final float x1, final float y1, final float x2, final float y2) { + mX1 = x1; + mY1 = y1; + mX2 = x2; + mY2 = y2; + } + + @Override + public float getInterpolation(float v) { + return getY(getTForXValue(v)); + } + + private float getX(final float t) { + return getCoordinate(t, mX1, mX2); + } + + private float getY(final float t) { + return getCoordinate(t, mY1, mY2); + } + + private float getCoordinate(float t, float p1, float p2) { + // Special case start and end. + if (t == 0.0f || t == 1.0f) { + return t; + } + + // Step one - from 4 points to 3. + float ip0 = linearInterpolate(0, p1, t); + float ip1 = linearInterpolate(p1, p2, t); + float ip2 = linearInterpolate(p2, 1, t); + + // Step two - from 3 points to 2. + ip0 = linearInterpolate(ip0, ip1, t); + ip1 = linearInterpolate(ip1, ip2, t); + + // Final step - last point. + return linearInterpolate(ip0, ip1, t); + } + + private float linearInterpolate(float a, float b, float progress) { + return a + (b - a) * progress; + } + + private float getTForXValue(final float x) { + final float epsilon = 1e-6f; + final int iterations = 8; + + if (x <= 0.0f) { + return 0.0f; + } else if (x >= 1.0f) { + return 1.0f; + } + + // Try gradient descent to solve for t. If it works, it is very fast. + float t = x; + float minT = 0.0f; + float maxT = 1.0f; + float value = 0.0f; + for (int i = 0; i < iterations; i++) { + value = getX(t); + double derivative = (getX(t + epsilon) - value) / epsilon; + if (Math.abs(value - x) < epsilon) { + return t; + } else if (Math.abs(derivative) < epsilon) { + break; + } else { + if (value < x) { + minT = t; + } else { + maxT = t; + } + t -= (value - x) / derivative; + } + } + + // If the gradient descent got stuck in a local minimum, e.g. because the + // derivative was close to 0, use an interval bisection instead. + for (int i = 0; Math.abs(value - x) > epsilon && i < iterations; i++) { + if (value < x) { + minT = t; + t = (t + maxT) / 2.0f; + } else { + maxT = t; + t = (t + minT) / 2.0f; + } + value = getX(t); + } + return t; + } +} diff --git a/src/com/android/messaging/util/Dates.java b/src/com/android/messaging/util/Dates.java new file mode 100644 index 0000000..d012dfd --- /dev/null +++ b/src/com/android/messaging/util/Dates.java @@ -0,0 +1,280 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.content.Context; +import android.text.format.DateUtils; +import android.text.format.Time; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.google.common.annotations.VisibleForTesting; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +/** + * Collection of date utilities. + */ +public class Dates { + public static final long SECOND_IN_MILLIS = 1000; + public static final long MINUTE_IN_MILLIS = SECOND_IN_MILLIS * 60; + public static final long HOUR_IN_MILLIS = MINUTE_IN_MILLIS * 60; + public static final long DAY_IN_MILLIS = HOUR_IN_MILLIS * 24; + public static final long WEEK_IN_MILLIS = DAY_IN_MILLIS * 7; + + // Flags to specify whether or not to use 12 or 24 hour mode. + // Callers of methods in this class should never have to specify these; this is really + // intended only for unit tests. + @SuppressWarnings("deprecation") + @VisibleForTesting public static final int FORCE_12_HOUR = DateUtils.FORMAT_12HOUR; + @SuppressWarnings("deprecation") + @VisibleForTesting public static final int FORCE_24_HOUR = DateUtils.FORMAT_24HOUR; + + /** + * Private default constructor + */ + private Dates() { + } + + private static Context getContext() { + return Factory.get().getApplicationContext(); + } + /** + * Get the relative time as a string + * + * @param time The time + * + * @return The relative time + */ + public static CharSequence getRelativeTimeSpanString(final long time) { + final long now = System.currentTimeMillis(); + if (now - time < DateUtils.MINUTE_IN_MILLIS) { + // Also fixes bug where posts appear in the future + return getContext().getResources().getText(R.string.posted_just_now); + } + + // Workaround for b/5657035. The platform method {@link DateUtils#getRelativeTimeSpan()} + // passes a null context to other platform methods. However, on some devices, this + // context is dereferenced when it shouldn't be and an NPE is thrown. We catch that + // here and use a slightly less precise time. + try { + return DateUtils.getRelativeTimeSpanString(time, now, DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE).toString(); + } catch (final NullPointerException npe) { + return getShortRelativeTimeSpanString(time); + } + } + + public static CharSequence getConversationTimeString(final long time) { + return getTimeString(time, true /*abbreviated*/, false /*minPeriodToday*/); + } + + public static CharSequence getMessageTimeString(final long time) { + return getTimeString(time, false /*abbreviated*/, false /*minPeriodToday*/); + } + + public static CharSequence getWidgetTimeString(final long time, final boolean abbreviated) { + return getTimeString(time, abbreviated, true /*minPeriodToday*/); + } + + public static CharSequence getFastScrollPreviewTimeString(final long time) { + return getTimeString(time, true /* abbreviated */, true /* minPeriodToday */); + } + + public static CharSequence getMessageDetailsTimeString(final long time) { + final Context context = getContext(); + int flags; + if (android.text.format.DateFormat.is24HourFormat(context)) { + flags = FORCE_24_HOUR; + } else { + flags = FORCE_12_HOUR; + } + return getOlderThanAYearTimestamp(time, + context.getResources().getConfiguration().locale, false /*abbreviated*/, + flags); + } + + private static CharSequence getTimeString(final long time, final boolean abbreviated, + final boolean minPeriodToday) { + final Context context = getContext(); + int flags; + if (android.text.format.DateFormat.is24HourFormat(context)) { + flags = FORCE_24_HOUR; + } else { + flags = FORCE_12_HOUR; + } + return getTimestamp(time, System.currentTimeMillis(), abbreviated, + context.getResources().getConfiguration().locale, flags, minPeriodToday); + } + + @VisibleForTesting + public static CharSequence getTimestamp(final long time, final long now, + final boolean abbreviated, final Locale locale, final int flags, + final boolean minPeriodToday) { + final long timeDiff = now - time; + + if (!minPeriodToday && timeDiff < DateUtils.MINUTE_IN_MILLIS) { + return getLessThanAMinuteOldTimeString(abbreviated); + } else if (!minPeriodToday && timeDiff < DateUtils.HOUR_IN_MILLIS) { + return getLessThanAnHourOldTimeString(timeDiff, flags); + } else if (getNumberOfDaysPassed(time, now) == 0) { + return getTodayTimeStamp(time, flags); + } else if (timeDiff < DateUtils.WEEK_IN_MILLIS) { + return getThisWeekTimestamp(time, locale, abbreviated, flags); + } else if (timeDiff < DateUtils.YEAR_IN_MILLIS) { + return getThisYearTimestamp(time, locale, abbreviated, flags); + } else { + return getOlderThanAYearTimestamp(time, locale, abbreviated, flags); + } + } + + private static CharSequence getLessThanAMinuteOldTimeString( + final boolean abbreviated) { + return getContext().getResources().getText( + abbreviated ? R.string.posted_just_now : R.string.posted_now); + } + + private static CharSequence getLessThanAnHourOldTimeString(final long timeDiff, + final int flags) { + final long count = (timeDiff / MINUTE_IN_MILLIS); + final String format = getContext().getResources().getQuantityString( + R.plurals.num_minutes_ago, (int) count); + return String.format(format, count); + } + + private static CharSequence getTodayTimeStamp(final long time, final int flags) { + return DateUtils.formatDateTime(getContext(), time, + DateUtils.FORMAT_SHOW_TIME | flags); + } + + private static CharSequence getExplicitFormattedTime(final long time, final int flags, + final String format24, final String format12) { + SimpleDateFormat formatter; + if ((flags & FORCE_24_HOUR) == FORCE_24_HOUR) { + formatter = new SimpleDateFormat(format24); + } else { + formatter = new SimpleDateFormat(format12); + } + return formatter.format(new Date(time)); + } + + private static CharSequence getThisWeekTimestamp(final long time, + final Locale locale, final boolean abbreviated, final int flags) { + final Context context = getContext(); + if (abbreviated) { + return DateUtils.formatDateTime(context, time, + DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_ABBREV_WEEKDAY | flags); + } else { + if (locale.equals(Locale.US)) { + return getExplicitFormattedTime(time, flags, "EEE HH:mm", "EEE h:mmaa"); + } else { + return DateUtils.formatDateTime(context, time, + DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_SHOW_TIME + | DateUtils.FORMAT_ABBREV_WEEKDAY + | flags); + } + } + } + + private static CharSequence getThisYearTimestamp(final long time, final Locale locale, + final boolean abbreviated, final int flags) { + final Context context = getContext(); + if (abbreviated) { + return DateUtils.formatDateTime(context, time, + DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_ABBREV_MONTH + | DateUtils.FORMAT_NO_YEAR | flags); + } else { + if (locale.equals(Locale.US)) { + return getExplicitFormattedTime(time, flags, "MMM d, HH:mm", "MMM d, h:mmaa"); + } else { + return DateUtils.formatDateTime(context, time, + DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_TIME + | DateUtils.FORMAT_ABBREV_MONTH + | DateUtils.FORMAT_NO_YEAR + | flags); + } + } + } + + private static CharSequence getOlderThanAYearTimestamp(final long time, + final Locale locale, final boolean abbreviated, final int flags) { + final Context context = getContext(); + if (abbreviated) { + if (locale.equals(Locale.US)) { + return getExplicitFormattedTime(time, flags, "M/d/yy", "M/d/yy"); + } else { + return DateUtils.formatDateTime(context, time, + DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR + | DateUtils.FORMAT_NUMERIC_DATE); + } + } else { + if (locale.equals(Locale.US)) { + return getExplicitFormattedTime(time, flags, "M/d/yy, HH:mm", "M/d/yy, h:mmaa"); + } else { + return DateUtils.formatDateTime(context, time, + DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_TIME + | DateUtils.FORMAT_NUMERIC_DATE | DateUtils.FORMAT_SHOW_YEAR + | flags); + } + } + } + + public static CharSequence getShortRelativeTimeSpanString(final long time) { + final long now = System.currentTimeMillis(); + final long duration = Math.abs(now - time); + + int resId; + long count; + + final Context context = getContext(); + + if (duration < HOUR_IN_MILLIS) { + count = duration / MINUTE_IN_MILLIS; + resId = R.plurals.num_minutes_ago; + } else if (duration < DAY_IN_MILLIS) { + count = duration / HOUR_IN_MILLIS; + resId = R.plurals.num_hours_ago; + } else if (duration < WEEK_IN_MILLIS) { + count = getNumberOfDaysPassed(time, now); + resId = R.plurals.num_days_ago; + } else { + // Although we won't be showing a time, there is a bug on some devices that use + // the passed in context. On these devices, passing in a {@code null} context + // here will generate an NPE. See b/5657035. + return DateUtils.formatDateRange(context, time, time, + DateUtils.FORMAT_ABBREV_MONTH | DateUtils.FORMAT_ABBREV_RELATIVE); + } + + final String format = context.getResources().getQuantityString(resId, (int) count); + return String.format(format, count); + } + + private static synchronized long getNumberOfDaysPassed(final long date1, final long date2) { + if (sThenTime == null) { + sThenTime = new Time(); + } + sThenTime.set(date1); + final int day1 = Time.getJulianDay(date1, sThenTime.gmtoff); + sThenTime.set(date2); + final int day2 = Time.getJulianDay(date2, sThenTime.gmtoff); + return Math.abs(day2 - day1); + } + + private static Time sThenTime; +} diff --git a/src/com/android/messaging/util/DebugUtils.java b/src/com/android/messaging/util/DebugUtils.java new file mode 100644 index 0000000..f2c1d65 --- /dev/null +++ b/src/com/android/messaging/util/DebugUtils.java @@ -0,0 +1,425 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.FragmentManager; +import android.app.FragmentTransaction; +import android.content.Context; +import android.content.DialogInterface; +import android.media.MediaPlayer; +import android.os.Environment; +import android.telephony.SmsMessage; +import android.text.TextUtils; +import android.widget.ArrayAdapter; + +import com.android.messaging.R; +import com.android.messaging.datamodel.SyncManager; +import com.android.messaging.datamodel.action.DumpDatabaseAction; +import com.android.messaging.datamodel.action.LogTelephonyDatabaseAction; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.debug.DebugSmsMmsFromDumpFileDialogFragment; +import com.google.common.io.ByteStreams; + +import java.io.BufferedInputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FilenameFilter; +import java.io.IOException; +import java.io.StreamCorruptedException; + +public class DebugUtils { + private static final String TAG = "bugle.util.DebugUtils"; + + private static boolean sDebugNoise; + private static boolean sDebugClassZeroSms; + private static MediaPlayer [] sMediaPlayer; + private static final Object sLock = new Object(); + + public static final int DEBUG_SOUND_SERVER_REQUEST = 0; + public static final int DEBUG_SOUND_DB_OP = 1; + + public static void maybePlayDebugNoise(final Context context, final int sound) { + if (sDebugNoise) { + synchronized (sLock) { + try { + if (sMediaPlayer == null) { + sMediaPlayer = new MediaPlayer[2]; + sMediaPlayer[DEBUG_SOUND_SERVER_REQUEST] = + MediaPlayer.create(context, R.raw.server_request_debug); + sMediaPlayer[DEBUG_SOUND_DB_OP] = + MediaPlayer.create(context, R.raw.db_op_debug); + sMediaPlayer[DEBUG_SOUND_DB_OP].setVolume(1.0F, 1.0F); + sMediaPlayer[DEBUG_SOUND_SERVER_REQUEST].setVolume(0.3F, 0.3F); + } + if (sMediaPlayer[sound] != null) { + sMediaPlayer[sound].start(); + } + } catch (final IllegalArgumentException e) { + LogUtil.e(TAG, "MediaPlayer exception", e); + } catch (final SecurityException e) { + LogUtil.e(TAG, "MediaPlayer exception", e); + } catch (final IllegalStateException e) { + LogUtil.e(TAG, "MediaPlayer exception", e); + } + } + } + } + + public static boolean isDebugEnabled() { + return BugleGservices.get().getBoolean(BugleGservicesKeys.ENABLE_DEBUGGING_FEATURES, + BugleGservicesKeys.ENABLE_DEBUGGING_FEATURES_DEFAULT); + } + + public abstract static class DebugAction { + String mTitle; + public DebugAction(final String title) { + mTitle = title; + } + + @Override + public String toString() { + return mTitle; + } + + public abstract void run(); + } + + public static void showDebugOptions(final Activity host) { + final AlertDialog.Builder builder = new AlertDialog.Builder(host); + + final ArrayAdapter arrayAdapter = new ArrayAdapter( + host, android.R.layout.simple_list_item_1); + + arrayAdapter.add(new DebugAction("Dump Database") { + @Override + public void run() { + DumpDatabaseAction.dumpDatabase(); + } + }); + + arrayAdapter.add(new DebugAction("Log Telephony Data") { + @Override + public void run() { + LogTelephonyDatabaseAction.dumpDatabase(); + } + }); + + arrayAdapter.add(new DebugAction("Toggle Noise") { + @Override + public void run() { + sDebugNoise = !sDebugNoise; + } + }); + + arrayAdapter.add(new DebugAction("Force sync SMS") { + @Override + public void run() { + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + prefs.putLong(BuglePrefsKeys.LAST_FULL_SYNC_TIME, -1); + SyncManager.forceSync(); + } + }); + + arrayAdapter.add(new DebugAction("Sync SMS") { + @Override + public void run() { + SyncManager.sync(); + } + }); + + arrayAdapter.add(new DebugAction("Load SMS/MMS from dump file") { + @Override + public void run() { + new DebugSmsMmsDumpTask(host, + DebugSmsMmsFromDumpFileDialogFragment.ACTION_LOAD).executeOnThreadPool(); + } + }); + + arrayAdapter.add(new DebugAction("Email SMS/MMS dump file") { + @Override + public void run() { + new DebugSmsMmsDumpTask(host, + DebugSmsMmsFromDumpFileDialogFragment.ACTION_EMAIL).executeOnThreadPool(); + } + }); + + arrayAdapter.add(new DebugAction("MMS Config...") { + @Override + public void run() { + UIIntents.get().launchDebugMmsConfigActivity(host); + } + }); + + arrayAdapter.add(new DebugAction(sDebugClassZeroSms ? "Turn off Class 0 sms test" : + "Turn on Class Zero test") { + @Override + public void run() { + sDebugClassZeroSms = !sDebugClassZeroSms; + } + }); + + builder.setAdapter(arrayAdapter, + new android.content.DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface arg0, final int pos) { + arrayAdapter.getItem(pos).run(); + } + }); + + builder.create().show(); + } + + /** + * Task to list all the dump files and perform an action on it + */ + private static class DebugSmsMmsDumpTask extends SafeAsyncTask { + private final String mAction; + private final Activity mHost; + + public DebugSmsMmsDumpTask(final Activity host, final String action) { + mHost = host; + mAction = action; + } + + @Override + protected void onPostExecute(final String[] result) { + if (result == null || result.length < 1) { + return; + } + final FragmentManager fragmentManager = mHost.getFragmentManager(); + final FragmentTransaction ft = fragmentManager.beginTransaction(); + final DebugSmsMmsFromDumpFileDialogFragment dialog = + DebugSmsMmsFromDumpFileDialogFragment.newInstance(result, mAction); + dialog.show(fragmentManager, ""/*tag*/); + } + + @Override + protected String[] doInBackgroundTimed(final Void... params) { + final File dir = DebugUtils.getDebugFilesDir(); + return dir.list(new FilenameFilter() { + @Override + public boolean accept(final File dir, final String filename) { + return filename != null + && ((mAction == DebugSmsMmsFromDumpFileDialogFragment.ACTION_EMAIL + && filename.equals(DumpDatabaseAction.DUMP_NAME)) + || filename.startsWith(MmsUtils.MMS_DUMP_PREFIX) + || filename.startsWith(MmsUtils.SMS_DUMP_PREFIX)); + } + }); + } + } + + /** + * Dump the received raw SMS data into a file on external storage + * + * @param id The ID to use as part of the dump file name + * @param messages The raw SMS data + */ + public static void dumpSms(final long id, final android.telephony.SmsMessage[] messages, + final String format) { + try { + final String dumpFileName = MmsUtils.SMS_DUMP_PREFIX + Long.toString(id); + final File dumpFile = DebugUtils.getDebugFile(dumpFileName, true); + if (dumpFile != null) { + final FileOutputStream fos = new FileOutputStream(dumpFile); + final DataOutputStream dos = new DataOutputStream(fos); + try { + final int chars = (TextUtils.isEmpty(format) ? 0 : format.length()); + dos.writeInt(chars); + if (chars > 0) { + dos.writeUTF(format); + } + dos.writeInt(messages.length); + for (final android.telephony.SmsMessage message : messages) { + final byte[] pdu = message.getPdu(); + dos.writeInt(pdu.length); + dos.write(pdu, 0, pdu.length); + } + dos.flush(); + } finally { + dos.close(); + ensureReadable(dumpFile); + } + } + } catch (final IOException e) { + LogUtil.e(LogUtil.BUGLE_TAG, "dumpSms: " + e, e); + } + } + + /** + * Load MMS/SMS from the dump file + */ + public static SmsMessage[] retreiveSmsFromDumpFile(final String dumpFileName) { + SmsMessage[] messages = null; + final File inputFile = DebugUtils.getDebugFile(dumpFileName, false); + if (inputFile != null) { + FileInputStream fis = null; + DataInputStream dis = null; + try { + fis = new FileInputStream(inputFile); + dis = new DataInputStream(fis); + + // SMS dump + final int chars = dis.readInt(); + if (chars > 0) { + final String format = dis.readUTF(); + } + final int count = dis.readInt(); + final SmsMessage[] messagesTemp = new SmsMessage[count]; + for (int i = 0; i < count; i++) { + final int length = dis.readInt(); + final byte[] pdu = new byte[length]; + dis.read(pdu, 0, length); + messagesTemp[i] = SmsMessage.createFromPdu(pdu); + } + messages = messagesTemp; + } catch (final FileNotFoundException e) { + // Nothing to do + } catch (final StreamCorruptedException e) { + // Nothing to do + } catch (final IOException e) { + // Nothing to do + } finally { + if (dis != null) { + try { + dis.close(); + } catch (final IOException e) { + // Nothing to do + } + } + } + } + return messages; + } + + public static File getDebugFile(final String fileName, final boolean create) { + final File dir = getDebugFilesDir(); + final File file = new File(dir, fileName); + if (create && file.exists()) { + file.delete(); + } + return file; + } + + public static File getDebugFilesDir() { + final File dir = Environment.getExternalStorageDirectory(); + return dir; + } + + /** + * Load MMS/SMS from the dump file + */ + public static byte[] receiveFromDumpFile(final String dumpFileName) { + byte[] data = null; + try { + final File inputFile = getDebugFile(dumpFileName, false); + if (inputFile != null) { + final FileInputStream fis = new FileInputStream(inputFile); + final BufferedInputStream bis = new BufferedInputStream(fis); + try { + // dump file + data = ByteStreams.toByteArray(bis); + if (data == null || data.length < 1) { + LogUtil.e(LogUtil.BUGLE_TAG, "receiveFromDumpFile: empty data"); + } + } finally { + bis.close(); + } + } + } catch (final IOException e) { + LogUtil.e(LogUtil.BUGLE_TAG, "receiveFromDumpFile: " + e, e); + } + return data; + } + + public static void ensureReadable(final File file) { + if (file.exists()){ + file.setReadable(true, false); + } + } + + /** + * Logs the name of the method that is currently executing, e.g. "MyActivity.onCreate". This is + * useful for surgically adding logs for tracing execution while debugging. + *

+ * NOTE: This method retrieves the current thread's stack trace, which adds runtime overhead. + * However, this method is only executed on eng builds if DEBUG logs are loggable. + */ + public static void logCurrentMethod(String tag) { + if (!LogUtil.isLoggable(tag, LogUtil.DEBUG)) { + return; + } + StackTraceElement caller = getCaller(1); + if (caller == null) { + return; + } + String className = caller.getClassName(); + // Strip off the package name + int lastDot = className.lastIndexOf('.'); + if (lastDot > -1) { + className = className.substring(lastDot + 1); + } + LogUtil.d(tag, className + "." + caller.getMethodName()); + } + + /** + * Returns info about the calling method. The {@code depth} parameter controls how far back to + * go. For example, if foo() calls bar(), and bar() calls getCaller(0), it returns info about + * bar(). If bar() instead called getCaller(1), it would return info about foo(). And so on. + *

+ * NOTE: This method retrieves the current thread's stack trace, which adds runtime overhead. + * It should only be used in production where necessary to gather context about an error or + * unexpected event (e.g. the {@link Assert} class uses it). + * + * @return stack frame information for the caller (if found); otherwise {@code null}. + */ + public static StackTraceElement getCaller(int depth) { + // If the signature of this method is changed, proguard.flags must be updated! + if (depth < 0) { + throw new IllegalArgumentException("depth cannot be negative"); + } + StackTraceElement[] trace = Thread.currentThread().getStackTrace(); + if (trace == null || trace.length < (depth + 2)) { + return null; + } + // The stack trace includes some methods we don't care about (e.g. this method). + // Walk down until we find this method, and then back up to the caller we're looking for. + for (int i = 0; i < trace.length - 1; i++) { + String methodName = trace[i].getMethodName(); + if ("getCaller".equals(methodName)) { + return trace[i + depth + 1]; + } + } + // Never found ourself in the stack?! + return null; + } + + /** + * Returns a boolean indicating whether ClassZero debugging is enabled. If enabled, any received + * sms is treated as if it were a class zero message and displayed by the ClassZeroActivity. + */ + public static boolean debugClassZeroSmsEnabled() { + return sDebugClassZeroSms; + } +} diff --git a/src/com/android/messaging/util/EmailAddress.java b/src/com/android/messaging/util/EmailAddress.java new file mode 100644 index 0000000..0c0dab9 --- /dev/null +++ b/src/com/android/messaging/util/EmailAddress.java @@ -0,0 +1,272 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import com.google.common.base.CharMatcher; + +/** + * Parsing the email address + */ +public final class EmailAddress { + private static final CharMatcher ANY_WHITESPACE = CharMatcher.anyOf( + " \t\n\r\f\u000B\u0085\u2028\u2029\u200D\uFFEF\uFFFD\uFFFE\uFFFF"); + private static final CharMatcher EMAIL_ALLOWED_CHARS = CharMatcher.inRange((char) 0, (char) 31) + .or(CharMatcher.is((char) 127)) + .or(CharMatcher.anyOf(" @,:<>")) + .negate(); + + /** + * Helper method that checks whether the input text is valid email address. + * TODO: This creates a new EmailAddress object each time + * Need to make it more lightweight by pulling out the validation code into a static method. + */ + public static boolean isValidEmail(final String emailText) { + return new EmailAddress(emailText).isValid(); + } + + /** + * Parses the specified email address. Internationalized addresses are treated as invalid. + * + * @param emailString A string representing just an email address. It should + * not contain any other tokens. "Name<foo@example.org>" won't be valid. + */ + public EmailAddress(final String emailString) { + this(emailString, false); + } + + /** + * Parses the specified email address. + * + * @param emailString A string representing just an email address. It should + * not contain any other tokens. "Name<foo@example.org>" won't be valid. + * @param i18n Accept an internationalized address if it is true. + */ + public EmailAddress(final String emailString, final boolean i18n) { + allowI18n = i18n; + valid = parseEmail(emailString); + } + + /** + * Parses the specified email address. Internationalized addresses are treated as invalid. + * + * @param user A string representing the username in the email prior to the '@' symbol + * @param host A string representing the host following the '@' symbol + */ + public EmailAddress(final String user, final String host) { + this(user, host, false); + } + + /** + * Parses the specified email address. + * + * @param user A string representing the username in the email prior to the '@' symbol + * @param host A string representing the host following the '@' symbol + * @param i18n Accept an internationalized address if it is true. + */ + public EmailAddress(final String user, final String host, final boolean i18n) { + allowI18n = i18n; + this.user = user; + setHost(host); + } + + protected boolean parseEmail(final String emailString) { + // check for null + if (emailString == null) { + return false; + } + + // Check for an '@' character. Get the last one, in case the local part is + // quoted. See http://b/1944742. + final int atIndex = emailString.lastIndexOf('@'); + if ((atIndex <= 0) || // no '@' character in the email address + // or @ on the first position + (atIndex == (emailString.length() - 1))) { // last character, no host + return false; + } + + user = emailString.substring(0, atIndex); + host = emailString.substring(atIndex + 1); + + return isValidInternal(); + } + + @Override + public String toString() { + return user + "@" + host; + } + + /** + * Ensure the email address is valid, conforming to current RFC2821 and + * RFC2822 guidelines (although some iffy characters, like ! and ;, are + * allowed because they are not technically prohibited in the RFC) + */ + private boolean isValidInternal() { + if ((user == null) || (host == null)) { + return false; + } + + if ((user.length() == 0) || (host.length() == 0)) { + return false; + } + + // check for white space in the host + if (ANY_WHITESPACE.indexIn(host) >= 0) { + return false; + } + + // ensure the host is above the minimum length + if (host.length() < 4) { + return false; + } + + final int firstDot = host.indexOf('.'); + + // ensure host contains at least one dot + if (firstDot == -1) { + return false; + } + + // check if the host contains two continuous dots. + if (host.indexOf("..") >= 0) { + return false; + } + + // check if the first host char is a dot. + if (host.charAt(0) == '.') { + return false; + } + + final int secondDot = host.indexOf(".", firstDot + 1); + + // if there's a dot at the end, there needs to be a second dot + if (host.charAt(host.length() - 1) == '.' && secondDot == -1) { + return false; + } + + // Host must not have any disallowed characters; allowI18n dictates whether + // host must be ASCII. + if (!EMAIL_ALLOWED_CHARS.matchesAllOf(host) + || (!allowI18n && !CharMatcher.ASCII.matchesAllOf(host))) { + return false; + } + + if (user.startsWith("\"")) { + if (!isQuotedUserValid()) { + return false; + } + } else { + // check for white space in the user + if (ANY_WHITESPACE.indexIn(user) >= 0) { + return false; + } + + // the user cannot contain two continuous dots + if (user.indexOf("..") >= 0) { + return false; + } + + // User must not have any disallowed characters; allow I18n dictates whether + // user must be ASCII. + if (!EMAIL_ALLOWED_CHARS.matchesAllOf(user) + || (!allowI18n && !CharMatcher.ASCII.matchesAllOf(user))) { + return false; + } + } + return true; + } + + private boolean isQuotedUserValid() { + final int limit = user.length() - 1; + if (limit < 1 || !user.endsWith("\"")) { + return false; + } + + // Unusual loop bounds (looking only at characters between the outer quotes, + // not at either quote character). Plus, i is manipulated within the loop. + for (int i = 1; i < limit; ++i) { + final char ch = user.charAt(i); + if (ch == '"' || ch == 127 + // No non-whitespace control chars: + || (ch < 32 && !ANY_WHITESPACE.matches(ch)) + // No non-ASCII chars, unless i18n is in effect: + || (ch >= 128 && !allowI18n)) { + return false; + } else if (ch == '\\') { + if (i + 1 < limit) { + ++i; // Skip the quoted character + } else { + // We have a trailing backslash -- so it can't be quoting anything. + return false; + } + } + } + + return true; + } + + @Override + public boolean equals(final Object otherObject) { + // Do an instance check first as an optimization. + if (this == otherObject) { + return true; + } + if (otherObject instanceof EmailAddress) { + final EmailAddress otherAddress = (EmailAddress) otherObject; + return toString().equals(otherAddress.toString()); + } + return false; + } + + @Override + public int hashCode() { + // Arbitrary hash code as a function of both host and user. + return toString().hashCode(); + } + + // accessors + public boolean isValid() { + return valid; + } + + public String getUser() { + return user; + } + + public String getHost() { + return host; + } + + // used to change the host on an email address and rechecks validity + + /** + * Changes the host name of the email address and rechecks the address' + * validity. Exercise caution when storing EmailAddress instances in + * hash-keyed collections. Calling setHost() with a different host name will + * change the return value of hashCode. + * + * @param hostName The new host name of the email address. + */ + public void setHost(final String hostName) { + host = hostName; + valid = isValidInternal(); + } + + protected boolean valid = false; + protected String user = null; + protected String host = null; + protected boolean allowI18n = false; +} diff --git a/src/com/android/messaging/util/FallbackStrategies.java b/src/com/android/messaging/util/FallbackStrategies.java new file mode 100644 index 0000000..57c208f --- /dev/null +++ b/src/com/android/messaging/util/FallbackStrategies.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import java.util.ArrayList; +import java.util.List; + +/** + * Provides a generic and loose-coupled framework to execute one primary and multiple fallback + * strategies for solving a given task.

+ * Basically, what would have been a nasty try-catch that's hard to separate and maintain: + *


+ * try {
+ *     // doSomething() that may fail.
+ * } catch (Exception ex) {
+ *     try {
+ *         // fallback1() that may fail.
+ *     } catch (Exception ex2) {
+ *         try {
+ *             // fallback2() that may fail.
+ *         } catch (Exception ex3) {
+ *             // ...
+ *         }
+ *     }
+ * }
+ * 
+ * Now becomes:
+ *

+ * FallbackStrategies
+ *      .startWith(something)
+ *      .thenTry(fallback1)
+ *      .thenTry(fallback2)
+ *      .execute();
+ * 
+ */ +public class FallbackStrategies { + public interface Strategy { + Output execute(Input params) throws Exception; + } + + private final List> mChainedStrategies; + + private FallbackStrategies(final Strategy primaryStrategy) { + mChainedStrategies = new ArrayList>(); + mChainedStrategies.add(primaryStrategy); + } + + public static FallbackStrategies startWith( + final Strategy primaryStrategy) { + return new FallbackStrategies(primaryStrategy); + } + + public FallbackStrategies thenTry(final Strategy strategy) { + Assert.isFalse(mChainedStrategies.isEmpty()); + mChainedStrategies.add(strategy); + return this; + } + + public Output execute(final Input params) { + final int count = mChainedStrategies.size(); + for (int i = 0; i < count; i++) { + final Strategy strategy = mChainedStrategies.get(i); + try { + // If succeeds, this will directly return. + return strategy.execute(params); + } catch (Exception ex) { + LogUtil.e(LogUtil.BUGLE_TAG, "Exceptions occured when executing strategy " + + strategy + (i < count - 1 ? + ", attempting fallback " + mChainedStrategies.get(i + 1) : + ", and running out of fallbacks."), ex); + // This will fall through and continue with the next strategy (if any). + } + } + // Running out of strategies, return null. + // TODO: Should this accept user-defined fallback value other than null? + return null; + } +} diff --git a/src/com/android/messaging/util/FileUtil.java b/src/com/android/messaging/util/FileUtil.java new file mode 100644 index 0000000..7c47ae9 --- /dev/null +++ b/src/com/android/messaging/util/FileUtil.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.content.Context; +import android.webkit.MimeTypeMap; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.google.common.io.Files; + +import java.io.File; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +public class FileUtil { + /** Returns a new file name, ensuring that such a file does not already exist. */ + private static synchronized File getNewFile(File directory, String extension, + String fileNameFormat) throws IOException { + final Date date = new Date(System.currentTimeMillis()); + final SimpleDateFormat dateFormat = new SimpleDateFormat(fileNameFormat); + final String numberedFileNameFormat = dateFormat.format(date) + "_%02d" + "." + extension; + for (int i = 1; i <= 99; i++) { // Only save 99 of the same file name. + final String newName = String.format(Locale.US, numberedFileNameFormat, i); + File testFile = new File(directory, newName); + if (!testFile.exists()) { + testFile.createNewFile(); + return testFile; + } + } + LogUtil.e(LogUtil.BUGLE_TAG, "Too many duplicate file names: " + numberedFileNameFormat); + return null; + } + + /** + * Creates an unused name to use for creating a new file. The format happens to be similar + * to that used by the Android camera application. + * + * @param directory directory that the file should be saved to + * @param contentType of the media being saved + * @return file name to be used for creating the new file. The caller is responsible for + * actually creating the file. + */ + public static File getNewFile(File directory, String contentType) throws IOException { + MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton(); + String fileExtension = mimeTypeMap.getExtensionFromMimeType(contentType); + + final Context context = Factory.get().getApplicationContext(); + String fileNameFormat = context.getString(ContentType.isImageType(contentType) + ? R.string.new_image_file_name_format : R.string.new_file_name_format); + return getNewFile(directory, fileExtension, fileNameFormat); + } + + /** Delete everything below and including root */ + public static void removeFileOrDirectory(File root) { + removeFileOrDirectoryExcept(root, null); + } + + /** Delete everything below and including root except for the given file */ + public static void removeFileOrDirectoryExcept(File root, File exclude) { + if (root.exists()) { + if (root.isDirectory()) { + for (File file : root.listFiles()) { + if (exclude == null || !file.equals(exclude)) { + removeFileOrDirectoryExcept(file, exclude); + } + } + root.delete(); + } else if (root.isFile()) { + root.delete(); + } + } + } + + /** + * Move all files and folders under a directory into the target. + */ + public static void moveAllContentUnderDirectory(File sourceDir, File targetDir) { + if (sourceDir.isDirectory() && targetDir.isDirectory()) { + if (isSameOrSubDirectory(sourceDir, targetDir)) { + LogUtil.e(LogUtil.BUGLE_TAG, "Can't move directory content since the source " + + "directory is a parent of the target"); + return; + } + for (File file : sourceDir.listFiles()) { + if (file.isDirectory()) { + final File dirTarget = new File(targetDir, file.getName()); + dirTarget.mkdirs(); + moveAllContentUnderDirectory(file, dirTarget); + } else { + try { + final File fileTarget = new File(targetDir, file.getName()); + Files.move(file, fileTarget); + } catch (IOException e) { + LogUtil.e(LogUtil.BUGLE_TAG, "Failed to move files", e); + // Try proceed with the next file. + } + } + } + } + } + + /** + * Checks, whether the child directory is the same as, or a sub-directory of the base + * directory. + */ + private static boolean isSameOrSubDirectory(File base, File child) { + try { + base = base.getCanonicalFile(); + child = child.getCanonicalFile(); + File parentFile = child; + while (parentFile != null) { + if (base.equals(parentFile)) { + return true; + } + parentFile = parentFile.getParentFile(); + } + return false; + } catch (IOException ex) { + LogUtil.e(LogUtil.BUGLE_TAG, "Error while accessing file", ex); + return false; + } + } +} diff --git a/src/com/android/messaging/util/GifTranscoder.java b/src/com/android/messaging/util/GifTranscoder.java new file mode 100644 index 0000000..65413a0 --- /dev/null +++ b/src/com/android/messaging/util/GifTranscoder.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.content.Context; +import android.text.format.Formatter; + +import com.google.common.base.Stopwatch; + +import java.io.File; +import java.util.concurrent.TimeUnit; + +/** + * Compresses a GIF so it can be sent via MMS. + *

+ * The entry point lives in its own class, we can defer loading the native GIF transcoding library + * into memory until we actually need it. + */ +public class GifTranscoder { + private static final String TAG = LogUtil.BUGLE_TAG; + + private static int MIN_HEIGHT = 100; + private static int MIN_WIDTH = 100; + + static { + System.loadLibrary("giftranscode"); + } + + public static boolean transcode(Context context, String filePath, String outFilePath) { + if (!isEnabled()) { + return false; + } + final long inputSize = new File(filePath).length(); + Stopwatch stopwatch = Stopwatch.createStarted(); + final boolean success = transcodeInternal(filePath, outFilePath); + stopwatch.stop(); + final long elapsedMs = stopwatch.elapsed(TimeUnit.MILLISECONDS); + final long outputSize = new File(outFilePath).length(); + final float compression = (inputSize > 0) ? ((float) outputSize / inputSize) : 0; + + if (success) { + LogUtil.i(TAG, String.format("Resized GIF (%s) in %d ms, %s => %s (%.0f%%)", + LogUtil.sanitizePII(filePath), + elapsedMs, + Formatter.formatShortFileSize(context, inputSize), + Formatter.formatShortFileSize(context, outputSize), + compression * 100.0f)); + } + return success; + } + + private static native boolean transcodeInternal(String filePath, String outFilePath); + + /** + * Estimates the size of a GIF transcoded from a GIF with the specified size. + */ + public static long estimateFileSizeAfterTranscode(long fileSize) { + // I tested transcoding on ~70 GIFs and found that the transcoded files are in general + // about 25-35% the size of the original. This compression ratio is very consistent for the + // class of GIFs we care about most: those converted from video clips and 1-3 MB in size. + return (long) (fileSize * 0.35f); + } + + public static boolean canBeTranscoded(int width, int height) { + if (!isEnabled()) { + return false; + } + return width >= MIN_WIDTH && height >= MIN_HEIGHT; + } + + private static boolean isEnabled() { + final boolean enabled = BugleGservices.get().getBoolean( + BugleGservicesKeys.ENABLE_GIF_TRANSCODING, + BugleGservicesKeys.ENABLE_GIF_TRANSCODING_DEFAULT); + if (!enabled) { + LogUtil.w(TAG, "GIF transcoding is disabled"); + } + return enabled; + } +} diff --git a/src/com/android/messaging/util/ImageUtils.java b/src/com/android/messaging/util/ImageUtils.java new file mode 100644 index 0000000..05d3678 --- /dev/null +++ b/src/com/android/messaging/util/ImageUtils.java @@ -0,0 +1,908 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import android.app.ActivityManager; +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.BitmapShader; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Shader.TileMode; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.provider.MediaStore; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.view.View; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.media.ImageRequest; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.exif.ExifInterface; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.io.Files; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.Arrays; + +public class ImageUtils { + private static final String TAG = LogUtil.BUGLE_TAG; + private static final int MAX_OOM_COUNT = 1; + private static final byte[] GIF87_HEADER = "GIF87a".getBytes(Charset.forName("US-ASCII")); + private static final byte[] GIF89_HEADER = "GIF89a".getBytes(Charset.forName("US-ASCII")); + + // Used for drawBitmapWithCircleOnCanvas. + // Default color is transparent for both circle background and stroke. + public static final int DEFAULT_CIRCLE_BACKGROUND_COLOR = 0; + public static final int DEFAULT_CIRCLE_STROKE_COLOR = 0; + + private static volatile ImageUtils sInstance; + + public static ImageUtils get() { + if (sInstance == null) { + synchronized (ImageUtils.class) { + if (sInstance == null) { + sInstance = new ImageUtils(); + } + } + } + return sInstance; + } + + @VisibleForTesting + public static void set(final ImageUtils imageUtils) { + sInstance = imageUtils; + } + + /** + * Transforms a bitmap into a byte array. + * + * @param quality Value between 0 and 100 that the compressor uses to discern what quality the + * resulting bytes should be + * @param bitmap Bitmap to convert into bytes + * @return byte array of bitmap + */ + public static byte[] bitmapToBytes(final Bitmap bitmap, final int quality) + throws OutOfMemoryError { + boolean done = false; + int oomCount = 0; + byte[] imageBytes = null; + while (!done) { + try { + final ByteArrayOutputStream os = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.JPEG, quality, os); + imageBytes = os.toByteArray(); + done = true; + } catch (final OutOfMemoryError e) { + LogUtil.w(TAG, "OutOfMemory converting bitmap to bytes."); + oomCount++; + if (oomCount <= MAX_OOM_COUNT) { + Factory.get().reclaimMemory(); + } else { + done = true; + LogUtil.w(TAG, "Failed to convert bitmap to bytes. Out of Memory."); + } + throw e; + } + } + return imageBytes; + } + + /** + * Given the source bitmap and a canvas, draws the bitmap through a circular + * mask. Only draws a circle with diameter equal to the destination width. + * + * @param bitmap The source bitmap to draw. + * @param canvas The canvas to draw it on. + * @param source The source bound of the bitmap. + * @param dest The destination bound on the canvas. + * @param bitmapPaint Optional Paint object for the bitmap + * @param fillBackground when set, fill the circle with backgroundColor + * @param strokeColor draw a border outside the circle with strokeColor + */ + public static void drawBitmapWithCircleOnCanvas(final Bitmap bitmap, final Canvas canvas, + final RectF source, final RectF dest, @Nullable Paint bitmapPaint, + final boolean fillBackground, final int backgroundColor, int strokeColor) { + // Draw bitmap through shader first. + final BitmapShader shader = new BitmapShader(bitmap, TileMode.CLAMP, TileMode.CLAMP); + final Matrix matrix = new Matrix(); + + // Fit bitmap to bounds. + matrix.setRectToRect(source, dest, Matrix.ScaleToFit.CENTER); + + shader.setLocalMatrix(matrix); + + if (bitmapPaint == null) { + bitmapPaint = new Paint(); + } + + bitmapPaint.setAntiAlias(true); + if (fillBackground) { + bitmapPaint.setColor(backgroundColor); + canvas.drawCircle(dest.centerX(), dest.centerX(), dest.width() / 2f, bitmapPaint); + } + + bitmapPaint.setShader(shader); + canvas.drawCircle(dest.centerX(), dest.centerX(), dest.width() / 2f, bitmapPaint); + bitmapPaint.setShader(null); + + if (strokeColor != 0) { + final Paint stroke = new Paint(); + stroke.setAntiAlias(true); + stroke.setColor(strokeColor); + stroke.setStyle(Paint.Style.STROKE); + final float strokeWidth = 6f; + stroke.setStrokeWidth(strokeWidth); + canvas.drawCircle(dest.centerX(), + dest.centerX(), + dest.width() / 2f - stroke.getStrokeWidth() / 2f, + stroke); + } + } + + /** + * Sets a drawable to the background of a view. setBackgroundDrawable() is deprecated since + * JB and replaced by setBackground(). + */ + @SuppressWarnings("deprecation") + public static void setBackgroundDrawableOnView(final View view, final Drawable drawable) { + if (OsUtil.isAtLeastJB()) { + view.setBackground(drawable); + } else { + view.setBackgroundDrawable(drawable); + } + } + + /** + * Based on the input bitmap bounds given by BitmapFactory.Options, compute the required + * sub-sampling size for loading a scaled down version of the bitmap to the required size + * @param options a BitmapFactory.Options instance containing the bounds info of the bitmap + * @param reqWidth the desired width of the bitmap. Can be ImageRequest.UNSPECIFIED_SIZE. + * @param reqHeight the desired height of the bitmap. Can be ImageRequest.UNSPECIFIED_SIZE. + * @return + */ + public int calculateInSampleSize( + final BitmapFactory.Options options, final int reqWidth, final int reqHeight) { + // Raw height and width of image + final int height = options.outHeight; + final int width = options.outWidth; + int inSampleSize = 1; + + final boolean checkHeight = reqHeight != ImageRequest.UNSPECIFIED_SIZE; + final boolean checkWidth = reqWidth != ImageRequest.UNSPECIFIED_SIZE; + if ((checkHeight && height > reqHeight) || + (checkWidth && width > reqWidth)) { + + final int halfHeight = height / 2; + final int halfWidth = width / 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 ((!checkHeight || (halfHeight / inSampleSize) > reqHeight) + && (!checkWidth || (halfWidth / inSampleSize) > reqWidth)) { + inSampleSize *= 2; + } + } + + return inSampleSize; + } + + private static final String[] MEDIA_CONTENT_PROJECTION = new String[] { + MediaStore.MediaColumns.MIME_TYPE + }; + + private static final int INDEX_CONTENT_TYPE = 0; + + @DoesNotRunOnMainThread + public static String getContentType(final ContentResolver cr, final Uri uri) { + // Figure out the content type of media. + String contentType = null; + Cursor cursor = null; + if (UriUtil.isMediaStoreUri(uri)) { + try { + cursor = cr.query(uri, MEDIA_CONTENT_PROJECTION, null, null, null); + + if (cursor != null && cursor.moveToFirst()) { + contentType = cursor.getString(INDEX_CONTENT_TYPE); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + if (contentType == null) { + // Last ditch effort to get the content type. Look at the file extension. + contentType = ContentType.getContentTypeFromExtension(uri.toString(), + ContentType.IMAGE_UNSPECIFIED); + } + return contentType; + } + + /** + * @param context Android context + * @param uri Uri to the image data + * @return The exif orientation value for the image in the specified uri + */ + public static int getOrientation(final Context context, final Uri uri) { + try { + return getOrientation(context.getContentResolver().openInputStream(uri)); + } catch (FileNotFoundException e) { + LogUtil.e(TAG, "getOrientation couldn't open: " + uri, e); + } + return android.media.ExifInterface.ORIENTATION_UNDEFINED; + } + + /** + * @param inputStream The stream to the image file. Closed on completion + * @return The exif orientation value for the image in the specified stream + */ + public static int getOrientation(final InputStream inputStream) { + int orientation = android.media.ExifInterface.ORIENTATION_UNDEFINED; + if (inputStream != null) { + try { + final ExifInterface exifInterface = new ExifInterface(); + exifInterface.readExif(inputStream); + final Integer orientationValue = + exifInterface.getTagIntValue(ExifInterface.TAG_ORIENTATION); + if (orientationValue != null) { + orientation = orientationValue.intValue(); + } + } catch (IOException e) { + // If the image if GIF, PNG, or missing exif header, just use the defaults + } finally { + try { + if (inputStream != null) { + inputStream.close(); + } + } catch (IOException e) { + LogUtil.e(TAG, "getOrientation error closing input stream", e); + } + } + } + return orientation; + } + + /** + * Returns whether the resource is a GIF image. + */ + public static boolean isGif(String contentType, Uri contentUri) { + if (TextUtils.equals(contentType, ContentType.IMAGE_GIF)) { + return true; + } + if (ContentType.isImageType(contentType)) { + try { + ContentResolver contentResolver = Factory.get().getApplicationContext() + .getContentResolver(); + InputStream inputStream = contentResolver.openInputStream(contentUri); + return ImageUtils.isGif(inputStream); + } catch (Exception e) { + LogUtil.w(TAG, "Could not open GIF input stream", e); + } + } + // Assume anything with a non-image content type is not a GIF + return false; + } + + /** + * @param inputStream The stream to the image file. Closed on completion + * @return Whether the image stream represents a GIF + */ + public static boolean isGif(InputStream inputStream) { + if (inputStream != null) { + try { + byte[] gifHeaderBytes = new byte[6]; + int value = inputStream.read(gifHeaderBytes, 0, 6); + if (value == 6) { + return Arrays.equals(gifHeaderBytes, GIF87_HEADER) + || Arrays.equals(gifHeaderBytes, GIF89_HEADER); + } + } catch (IOException e) { + return false; + } finally { + try { + inputStream.close(); + } catch (IOException e) { + // Ignore + } + } + } + return false; + } + + /** + * Read an image and compress it to particular max dimensions and size. + * Used to ensure images can fit in an MMS. + * TODO: This uses memory very inefficiently as it processes the whole image as a unit + * (rather than slice by slice) but system JPEG functions do not support slicing and dicing. + */ + public static class ImageResizer { + + /** + * The quality parameter which is used to compress JPEG images. + */ + private static final int IMAGE_COMPRESSION_QUALITY = 95; + /** + * The minimum quality parameter which is used to compress JPEG images. + */ + private static final int MINIMUM_IMAGE_COMPRESSION_QUALITY = 50; + + /** + * Minimum factor to reduce quality value + */ + private static final double QUALITY_SCALE_DOWN_RATIO = 0.85f; + + /** + * Maximum passes through the resize loop before failing permanently + */ + private static final int NUMBER_OF_RESIZE_ATTEMPTS = 6; + + /** + * Amount to scale down the picture when it doesn't fit + */ + private static final float MIN_SCALE_DOWN_RATIO = 0.75f; + + /** + * When computing sampleSize target scaling of no more than this ratio + */ + private static final float MAX_TARGET_SCALE_FACTOR = 1.5f; + + + // Current sample size for subsampling image during initial decode + private int mSampleSize; + // Current bitmap holding initial decoded source image + private Bitmap mDecoded; + // If scaling is needed this holds the scaled bitmap (else should equal mDecoded) + private Bitmap mScaled; + // Current JPEG compression quality to use when compressing image + private int mQuality; + // Current factor to scale down decoded image before compressing + private float mScaleFactor; + // Flag keeping track of whether cache memory has been reclaimed + private boolean mHasReclaimedMemory; + + // Initial size of the image (typically provided but can be UNSPECIFIED_SIZE) + private int mWidth; + private int mHeight; + // Orientation params of image as read from EXIF data + private final ExifInterface.OrientationParams mOrientationParams; + // Matrix to undo orientation and scale at the same time + private final Matrix mMatrix; + // Size limit as provided by MMS library + private final int mWidthLimit; + private final int mHeightLimit; + private final int mByteLimit; + // Uri from which to read source image + private final Uri mUri; + // Application context + private final Context mContext; + // Cached value of bitmap factory options + private final BitmapFactory.Options mOptions; + private final String mContentType; + + private final int mMemoryClass; + + /** + * Return resized (compressed) image (else null) + * + * @param width The width of the image (if known) + * @param height The height of the image (if known) + * @param orientation The orientation of the image as an ExifInterface constant + * @param widthLimit The width limit, in pixels + * @param heightLimit The height limit, in pixels + * @param byteLimit The binary size limit, in bytes + * @param uri Uri to the image data + * @param context Needed to open the image + * @param contentType of image + * @return encoded image meeting size requirements else null + */ + public static byte[] getResizedImageData(final int width, final int height, + final int orientation, final int widthLimit, final int heightLimit, + final int byteLimit, final Uri uri, final Context context, + final String contentType) { + final ImageResizer resizer = new ImageResizer(width, height, orientation, + widthLimit, heightLimit, byteLimit, uri, context, contentType); + return resizer.resize(); + } + + /** + * Create and initialize an image resizer + */ + private ImageResizer(final int width, final int height, final int orientation, + final int widthLimit, final int heightLimit, final int byteLimit, final Uri uri, + final Context context, final String contentType) { + mWidth = width; + mHeight = height; + mOrientationParams = ExifInterface.getOrientationParams(orientation); + mMatrix = new Matrix(); + mWidthLimit = widthLimit; + mHeightLimit = heightLimit; + mByteLimit = byteLimit; + mUri = uri; + mWidth = width; + mContext = context; + mQuality = IMAGE_COMPRESSION_QUALITY; + mScaleFactor = 1.0f; + mHasReclaimedMemory = false; + mOptions = new BitmapFactory.Options(); + mOptions.inScaled = false; + mOptions.inDensity = 0; + mOptions.inTargetDensity = 0; + mOptions.inSampleSize = 1; + mOptions.inJustDecodeBounds = false; + mOptions.inMutable = false; + final ActivityManager am = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + mMemoryClass = Math.max(16, am.getMemoryClass()); + mContentType = contentType; + } + + /** + * Try to compress the image + * + * @return encoded image meeting size requirements else null + */ + private byte[] resize() { + return ImageUtils.isGif(mContentType, mUri) ? resizeGifImage() : resizeStaticImage(); + } + + private byte[] resizeGifImage() { + byte[] bytesToReturn = null; + final String inputFilePath; + if (MediaScratchFileProvider.isMediaScratchSpaceUri(mUri)) { + inputFilePath = MediaScratchFileProvider.getFileFromUri(mUri).getAbsolutePath(); + } else { + if (!TextUtils.equals(mUri.getScheme(), ContentResolver.SCHEME_FILE)) { + Assert.fail("Expected a GIF file uri, but actual uri = " + mUri.toString()); + } + inputFilePath = mUri.getPath(); + } + + if (GifTranscoder.canBeTranscoded(mWidth, mHeight)) { + // Needed to perform the transcoding so that the gif can continue to play in the + // conversation while the sending is taking place + final Uri tmpUri = MediaScratchFileProvider.buildMediaScratchSpaceUri("gif"); + final File outputFile = MediaScratchFileProvider.getFileFromUri(tmpUri); + final String outputFilePath = outputFile.getAbsolutePath(); + + final boolean success = + GifTranscoder.transcode(mContext, inputFilePath, outputFilePath); + if (success) { + try { + bytesToReturn = Files.toByteArray(outputFile); + } catch (IOException e) { + LogUtil.e(TAG, "Could not create FileInputStream with path of " + + outputFilePath, e); + } + } + + // Need to clean up the new file created to compress the gif + mContext.getContentResolver().delete(tmpUri, null, null); + } else { + // We don't want to transcode the gif because its image dimensions would be too + // small so just return the bytes of the original gif + try { + bytesToReturn = Files.toByteArray(new File(inputFilePath)); + } catch (IOException e) { + LogUtil.e(TAG, + "Could not create FileInputStream with path of " + inputFilePath, e); + } + } + + return bytesToReturn; + } + + private byte[] resizeStaticImage() { + if (!ensureImageSizeSet()) { + // Cannot read image size + return null; + } + // Find incoming image size + if (!canBeCompressed()) { + return null; + } + + // Decode image - if out of memory - reclaim memory and retry + try { + for (int attempts = 0; attempts < NUMBER_OF_RESIZE_ATTEMPTS; attempts++) { + final byte[] encoded = recodeImage(attempts); + + // Only return data within the limit + if (encoded != null && encoded.length <= mByteLimit) { + return encoded; + } else { + final int currentSize = (encoded == null ? 0 : encoded.length); + updateRecodeParameters(currentSize); + } + } + } catch (final FileNotFoundException e) { + LogUtil.e(TAG, "File disappeared during resizing"); + } finally { + // Release all bitmaps + if (mScaled != null && mScaled != mDecoded) { + mScaled.recycle(); + } + if (mDecoded != null) { + mDecoded.recycle(); + } + } + return null; + } + + /** + * Ensure that the width and height of the source image are known + * @return flag indicating whether size is known + */ + private boolean ensureImageSizeSet() { + if (mWidth == MessagingContentProvider.UNSPECIFIED_SIZE || + mHeight == MessagingContentProvider.UNSPECIFIED_SIZE) { + // First get the image data (compressed) + final ContentResolver cr = mContext.getContentResolver(); + InputStream inputStream = null; + // Find incoming image size + try { + mOptions.inJustDecodeBounds = true; + inputStream = cr.openInputStream(mUri); + BitmapFactory.decodeStream(inputStream, null, mOptions); + + mWidth = mOptions.outWidth; + mHeight = mOptions.outHeight; + mOptions.inJustDecodeBounds = false; + + return true; + } catch (final FileNotFoundException e) { + LogUtil.e(TAG, "Could not open file corresponding to uri " + mUri, e); + } catch (final NullPointerException e) { + LogUtil.e(TAG, "NPE trying to open the uri " + mUri, e); + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (final IOException e) { + // Nothing to do + } + } + } + + return false; + } + return true; + } + + /** + * Choose an initial subsamplesize that ensures the decoded image is no more than + * MAX_TARGET_SCALE_FACTOR bigger than largest supported image and that it is likely to + * compress to smaller than the target size (assuming compression down to 1 bit per pixel). + * @return whether the image can be down subsampled + */ + private boolean canBeCompressed() { + final boolean logv = LogUtil.isLoggable(LogUtil.BUGLE_IMAGE_TAG, LogUtil.VERBOSE); + + int imageHeight = mHeight; + int imageWidth = mWidth; + + // Assume can use half working memory to decode the initial image (4 bytes per pixel) + final int workingMemoryPixelLimit = (mMemoryClass * 1024 * 1024 / 8); + // Target 1 bits per pixel in final compressed image + final int finalSizePixelLimit = mByteLimit * 8; + // When choosing to halve the resolution - only do so the image will still be too big + // after scaling by MAX_TARGET_SCALE_FACTOR + final int heightLimitWithSlop = (int) (mHeightLimit * MAX_TARGET_SCALE_FACTOR); + final int widthLimitWithSlop = (int) (mWidthLimit * MAX_TARGET_SCALE_FACTOR); + final int pixelLimitWithSlop = (int) (finalSizePixelLimit * + MAX_TARGET_SCALE_FACTOR * MAX_TARGET_SCALE_FACTOR); + final int pixelLimit = Math.min(pixelLimitWithSlop, workingMemoryPixelLimit); + + int sampleSize = 1; + boolean fits = (imageHeight < heightLimitWithSlop && + imageWidth < widthLimitWithSlop && + imageHeight * imageWidth < pixelLimit); + + // Compare sizes to compute sub-sampling needed + while (!fits) { + sampleSize = sampleSize * 2; + // Note that recodeImage may try using mSampleSize * 2. Hence we use the factor of 4 + if (sampleSize >= (Integer.MAX_VALUE / 4)) { + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, String.format( + "Cannot resize image: widthLimit=%d heightLimit=%d byteLimit=%d " + + "imageWidth=%d imageHeight=%d", mWidthLimit, mHeightLimit, mByteLimit, + mWidth, mHeight)); + Assert.fail("Image cannot be resized"); // http://b/18926934 + return false; + } + if (logv) { + LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, + "computeInitialSampleSize: Increasing sampleSize to " + sampleSize + + " as h=" + imageHeight + " vs " + heightLimitWithSlop + + " w=" + imageWidth + " vs " + widthLimitWithSlop + + " p=" + imageHeight * imageWidth + " vs " + pixelLimit); + } + imageHeight = mHeight / sampleSize; + imageWidth = mWidth / sampleSize; + fits = (imageHeight < heightLimitWithSlop && + imageWidth < widthLimitWithSlop && + imageHeight * imageWidth < pixelLimit); + } + + if (logv) { + LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, + "computeInitialSampleSize: Initial sampleSize " + sampleSize + + " for h=" + imageHeight + " vs " + heightLimitWithSlop + + " w=" + imageWidth + " vs " + widthLimitWithSlop + + " p=" + imageHeight * imageWidth + " vs " + pixelLimit); + } + + mSampleSize = sampleSize; + return true; + } + + /** + * Recode the image from initial Uri to encoded JPEG + * @param attempt Attempt number + * @return encoded image + */ + private byte[] recodeImage(final int attempt) throws FileNotFoundException { + byte[] encoded = null; + try { + final ContentResolver cr = mContext.getContentResolver(); + final boolean logv = LogUtil.isLoggable(LogUtil.BUGLE_IMAGE_TAG, LogUtil.VERBOSE); + if (logv) { + LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, "getResizedImageData: attempt=" + attempt + + " limit (w=" + mWidthLimit + " h=" + mHeightLimit + ") quality=" + + mQuality + " scale=" + mScaleFactor + " sampleSize=" + mSampleSize); + } + if (mScaled == null) { + if (mDecoded == null) { + mOptions.inSampleSize = mSampleSize; + final InputStream inputStream = cr.openInputStream(mUri); + mDecoded = BitmapFactory.decodeStream(inputStream, null, mOptions); + if (mDecoded == null) { + if (logv) { + LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, + "getResizedImageData: got empty decoded bitmap"); + } + return null; + } + } + if (logv) { + LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, "getResizedImageData: decoded w,h=" + + mDecoded.getWidth() + "," + mDecoded.getHeight()); + } + // Make sure to scale the decoded image if dimension is not within limit + final int decodedWidth = mDecoded.getWidth(); + final int decodedHeight = mDecoded.getHeight(); + if (decodedWidth > mWidthLimit || decodedHeight > mHeightLimit) { + final float minScaleFactor = Math.max( + mWidthLimit == 0 ? 1.0f : + (float) decodedWidth / (float) mWidthLimit, + mHeightLimit == 0 ? 1.0f : + (float) decodedHeight / (float) mHeightLimit); + if (mScaleFactor < minScaleFactor) { + mScaleFactor = minScaleFactor; + } + } + if (mScaleFactor > 1.0 || mOrientationParams.rotation != 0) { + mMatrix.reset(); + mMatrix.postRotate(mOrientationParams.rotation); + mMatrix.postScale(mOrientationParams.scaleX / mScaleFactor, + mOrientationParams.scaleY / mScaleFactor); + mScaled = Bitmap.createBitmap(mDecoded, 0, 0, decodedWidth, decodedHeight, + mMatrix, false /* filter */); + if (mScaled == null) { + if (logv) { + LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, + "getResizedImageData: got empty scaled bitmap"); + } + return null; + } + if (logv) { + LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, "getResizedImageData: scaled w,h=" + + mScaled.getWidth() + "," + mScaled.getHeight()); + } + } else { + mScaled = mDecoded; + } + } + // Now encode it at current quality + encoded = ImageUtils.bitmapToBytes(mScaled, mQuality); + if (encoded != null && logv) { + LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, + "getResizedImageData: Encoded down to " + encoded.length + "@" + + mScaled.getWidth() + "/" + mScaled.getHeight() + "~" + + mQuality); + } + } catch (final OutOfMemoryError e) { + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, + "getResizedImageData - image too big (OutOfMemoryError), will try " + + " with smaller scale factor"); + // fall through and keep trying with more compression + } + return encoded; + } + + /** + * When image recode fails this method updates compression parameters for the next attempt + * @param currentSize encoded image size (will be 0 if OOM) + */ + private void updateRecodeParameters(final int currentSize) { + final boolean logv = LogUtil.isLoggable(LogUtil.BUGLE_IMAGE_TAG, LogUtil.VERBOSE); + // Only return data within the limit + if (currentSize > 0 && + mQuality > MINIMUM_IMAGE_COMPRESSION_QUALITY) { + // First if everything succeeded but failed to hit target size + // Try quality proportioned to sqrt of size over size limit + mQuality = Math.max(MINIMUM_IMAGE_COMPRESSION_QUALITY, + Math.min((int) (mQuality * Math.sqrt((1.0 * mByteLimit) / currentSize)), + (int) (mQuality * QUALITY_SCALE_DOWN_RATIO))); + if (logv) { + LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, + "getResizedImageData: Retrying at quality " + mQuality); + } + } else if (currentSize > 0 && + mScaleFactor < 2.0 * MIN_SCALE_DOWN_RATIO * MIN_SCALE_DOWN_RATIO) { + // JPEG compression failed to hit target size - need smaller image + // First try scaling by a little (< factor of 2) just so long resulting scale down + // ratio is still significantly bigger than next subsampling step + // i.e. mScaleFactor/MIN_SCALE_DOWN_RATIO (new scaling factor) < + // 2.0 / MIN_SCALE_DOWN_RATIO (arbitrary limit) + mQuality = IMAGE_COMPRESSION_QUALITY; + mScaleFactor = mScaleFactor / MIN_SCALE_DOWN_RATIO; + if (logv) { + LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, + "getResizedImageData: Retrying at scale " + mScaleFactor); + } + // Release scaled bitmap to trigger rescaling + if (mScaled != null && mScaled != mDecoded) { + mScaled.recycle(); + } + mScaled = null; + } else if (currentSize <= 0 && !mHasReclaimedMemory) { + // Then before we subsample try cleaning up our cached memory + Factory.get().reclaimMemory(); + mHasReclaimedMemory = true; + if (logv) { + LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, + "getResizedImageData: Retrying after reclaiming memory "); + } + } else { + // Last resort - subsample image by another factor of 2 and try again + mSampleSize = mSampleSize * 2; + mQuality = IMAGE_COMPRESSION_QUALITY; + mScaleFactor = 1.0f; + if (logv) { + LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, + "getResizedImageData: Retrying at sampleSize " + mSampleSize); + } + // Release all bitmaps to trigger subsampling + if (mScaled != null && mScaled != mDecoded) { + mScaled.recycle(); + } + mScaled = null; + if (mDecoded != null) { + mDecoded.recycle(); + mDecoded = null; + } + } + } + } + + /** + * Scales and center-crops a bitmap to the size passed in and returns the new bitmap. + * + * @param source Bitmap to scale and center-crop + * @param newWidth destination width + * @param newHeight destination height + * @return Bitmap scaled and center-cropped bitmap + */ + public static Bitmap scaleCenterCrop(final Bitmap source, final int newWidth, + final int newHeight) { + final int sourceWidth = source.getWidth(); + final int sourceHeight = source.getHeight(); + + // Compute the scaling factors to fit the new height and width, respectively. + // To cover the final image, the final scaling will be the bigger + // of these two. + final float xScale = (float) newWidth / sourceWidth; + final float yScale = (float) newHeight / sourceHeight; + final float scale = Math.max(xScale, yScale); + + // Now get the size of the source bitmap when scaled + final float scaledWidth = scale * sourceWidth; + final float scaledHeight = scale * sourceHeight; + + // Let's find out the upper left coordinates if the scaled bitmap + // should be centered in the new size give by the parameters + final float left = (newWidth - scaledWidth) / 2; + final float top = (newHeight - scaledHeight) / 2; + + // The target rectangle for the new, scaled version of the source bitmap will now + // be + final RectF targetRect = new RectF(left, top, left + scaledWidth, top + scaledHeight); + + // Finally, we create a new bitmap of the specified size and draw our new, + // scaled bitmap onto it. + final Bitmap dest = Bitmap.createBitmap(newWidth, newHeight, source.getConfig()); + final Canvas canvas = new Canvas(dest); + canvas.drawBitmap(source, null, targetRect, null); + + return dest; + } + + /** + * The drawable can be a Nine-Patch. If we directly use the same drawable instance for each + * drawable of different sizes, then the drawable sizes would interfere with each other. The + * solution here is to create a new drawable instance for every time with the SAME + * ConstantState (i.e. sharing the same common state such as the bitmap, so that we don't have + * to recreate the bitmap resource), and apply the different properties on top (nine-patch + * size and color tint). + * + * TODO: we are creating new drawable instances here, but there are optimizations that + * can be made. For example, message bubbles shouldn't need the mutate() call and the + * play/pause buttons shouldn't need to create new drawable from the constant state. + */ + public static Drawable getTintedDrawable(final Context context, final Drawable drawable, + final int color) { + // For some reason occassionally drawables on JB has a null constant state + final Drawable.ConstantState constantStateDrawable = drawable.getConstantState(); + final Drawable retDrawable = (constantStateDrawable != null) + ? constantStateDrawable.newDrawable(context.getResources()).mutate() + : drawable; + retDrawable.setColorFilter(color, PorterDuff.Mode.SRC_ATOP); + return retDrawable; + } + + /** + * Decodes image resource header and returns the image size. + */ + public static Rect decodeImageBounds(final Context context, final Uri imageUri) { + final ContentResolver cr = context.getContentResolver(); + try { + final InputStream inputStream = cr.openInputStream(imageUri); + if (inputStream != null) { + try { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeStream(inputStream, null, options); + return new Rect(0, 0, options.outWidth, options.outHeight); + } finally { + try { + inputStream.close(); + } catch (IOException e) { + // Do nothing. + } + } + } + } catch (FileNotFoundException e) { + LogUtil.e(TAG, "Couldn't open input stream for uri = " + imageUri); + } + return new Rect(0, 0, ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE); + } +} diff --git a/src/com/android/messaging/util/ImeUtil.java b/src/com/android/messaging/util/ImeUtil.java new file mode 100644 index 0000000..ab0df13 --- /dev/null +++ b/src/com/android/messaging/util/ImeUtil.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.view.View; +import android.view.inputmethod.InputMethodManager; + +import com.google.common.annotations.VisibleForTesting; + +public class ImeUtil { + public interface ImeStateObserver { + void onImeStateChanged(boolean imeOpen); + } + + public interface ImeStateHost { + void onDisplayHeightChanged(int heightMeasureSpec); + void registerImeStateObserver(ImeUtil.ImeStateObserver observer); + void unregisterImeStateObserver(ImeUtil.ImeStateObserver observer); + boolean isImeOpen(); + } + + private static volatile ImeUtil sInstance; + + // Used to clear the static cached instance of ImeUtil during testing. This is necessary + // because a previous test may have installed a mocked instance (or vice versa). + public static void clearInstance() { + sInstance = null; + } + public static ImeUtil get() { + if (sInstance == null) { + synchronized (ImeUtil.class) { + if (sInstance == null) { + sInstance = new ImeUtil(); + } + } + } + return sInstance; + } + + @VisibleForTesting + public static void set(final ImeUtil imeUtil) { + sInstance = imeUtil; + } + + public void hideImeKeyboard(@NonNull final Context context, @NonNull final View v) { + Assert.notNull(context); + Assert.notNull(v); + + final InputMethodManager inputMethodManager = + (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + if (inputMethodManager != null) { + inputMethodManager.hideSoftInputFromWindow(v.getWindowToken(), 0 /* flags */); + } + } + + public void showImeKeyboard(@NonNull final Context context, @NonNull final View v) { + Assert.notNull(context); + Assert.notNull(v); + + final InputMethodManager inputMethodManager = + (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + if (inputMethodManager != null) { + v.requestFocus(); + inputMethodManager.showSoftInput(v, 0 /* flags */); + } + } + + public static void hideSoftInput(@NonNull final Context context, @NonNull final View v) { + final InputMethodManager inputMethodManager = + (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + inputMethodManager.hideSoftInputFromWindow(v.getWindowToken(), 0); + } +} diff --git a/src/com/android/messaging/util/LogSaver.java b/src/com/android/messaging/util/LogSaver.java new file mode 100644 index 0000000..7d1f2fd --- /dev/null +++ b/src/com/android/messaging/util/LogSaver.java @@ -0,0 +1,293 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.os.Process; +import android.util.Log; + +import com.android.messaging.Factory; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.IOException; +import java.io.PrintWriter; +import java.text.SimpleDateFormat; +import java.util.logging.FileHandler; +import java.util.logging.Formatter; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Save the app's own log to dump along with adb bugreport + */ +public abstract class LogSaver { + /** + * Writes the accumulated log entries, from oldest to newest, to the specified PrintWriter. + * Log lines are emitted in much the same form as logcat -v threadtime -- specifically, + * lines will include a timestamp, pid, tid, level, and tag. + * + * @param writer The PrintWriter to output + */ + public abstract void dump(PrintWriter writer); + + /** + * Log a line + * + * @param level The log level to use + * @param tag The log tag + * @param msg The message of the log line + */ + public abstract void log(int level, String tag, String msg); + + /** + * Check if the LogSaver still matches the current Gservices settings + * + * @return true if matches, false otherwise + */ + public abstract boolean isCurrent(); + + private LogSaver() { + } + + public static LogSaver newInstance() { + final boolean persistent = BugleGservices.get().getBoolean( + BugleGservicesKeys.PERSISTENT_LOGSAVER, + BugleGservicesKeys.PERSISTENT_LOGSAVER_DEFAULT); + if (persistent) { + final int setSize = BugleGservices.get().getInt( + BugleGservicesKeys.PERSISTENT_LOGSAVER_ROTATION_SET_SIZE, + BugleGservicesKeys.PERSISTENT_LOGSAVER_ROTATION_SET_SIZE_DEFAULT); + final int fileLimitBytes = BugleGservices.get().getInt( + BugleGservicesKeys.PERSISTENT_LOGSAVER_FILE_LIMIT_BYTES, + BugleGservicesKeys.PERSISTENT_LOGSAVER_FILE_LIMIT_BYTES_DEFAULT); + return new DiskLogSaver(setSize, fileLimitBytes); + } else { + final int size = BugleGservices.get().getInt( + BugleGservicesKeys.IN_MEMORY_LOGSAVER_RECORD_COUNT, + BugleGservicesKeys.IN_MEMORY_LOGSAVER_RECORD_COUNT_DEFAULT); + return new MemoryLogSaver(size); + } + } + + /** + * A circular in-memory log to be used to log potentially verbose logs. The logs will be + * persisted in memory in the application and can be dumped by various dump() methods. + * For example, adb shell dumpsys activity provider com.android.messaging. + * The dump will also show up in bugreports. + */ + private static final class MemoryLogSaver extends LogSaver { + /** + * Record to store a single log entry. Stores timestamp, tid, level, tag, and message. + * It can be reused when the circular log rolls over. This avoids creating new objects. + */ + private static class LogRecord { + int mTid; + String mLevelString; + long mTimeMillis; // from System.currentTimeMillis + String mTag; + String mMessage; + + LogRecord() { + } + + void set(int tid, int level, long time, String tag, String message) { + this.mTid = tid; + this.mTimeMillis = time; + this.mTag = tag; + this.mMessage = message; + this.mLevelString = getLevelString(level); + } + } + + private final int mSize; + private final CircularArray mLogList; + private final Object mLock; + + private final SimpleDateFormat mSdf = new SimpleDateFormat("MM-dd HH:mm:ss.SSS"); + + public MemoryLogSaver(final int size) { + mSize = size; + mLogList = new CircularArray(size); + mLock = new Object(); + } + + @Override + public void dump(PrintWriter writer) { + int pid = Process.myPid(); + synchronized (mLock) { + for (int i = 0; i < mLogList.count(); i++) { + LogRecord rec = mLogList.get(i); + writer.println(String.format("%s %5d %5d %s %s: %s", + mSdf.format(rec.mTimeMillis), + pid, rec.mTid, rec.mLevelString, rec.mTag, rec.mMessage)); + } + } + } + + @Override + public void log(int level, String tag, String msg) { + synchronized (mLock) { + LogRecord rec = mLogList.getFree(); + if (rec == null) { + rec = new LogRecord(); + } + rec.set(Process.myTid(), level, System.currentTimeMillis(), tag, msg); + mLogList.add(rec); + } + } + + @Override + public boolean isCurrent() { + final boolean persistent = BugleGservices.get().getBoolean( + BugleGservicesKeys.PERSISTENT_LOGSAVER, + BugleGservicesKeys.PERSISTENT_LOGSAVER_DEFAULT); + if (persistent) { + return false; + } + final int size = BugleGservices.get().getInt( + BugleGservicesKeys.IN_MEMORY_LOGSAVER_RECORD_COUNT, + BugleGservicesKeys.IN_MEMORY_LOGSAVER_RECORD_COUNT_DEFAULT); + return size == mSize; + } + } + + /** + * A persistent, on-disk log saver. It uses the standard Java util logger along with + * a rotation log file set to store the logs in app's local file directory "app_logs". + */ + private static final class DiskLogSaver extends LogSaver { + private static final String DISK_LOG_DIR_NAME = "logs"; + + private final int mSetSize; + private final int mFileLimitBytes; + private Logger mDiskLogger; + + public DiskLogSaver(final int setSize, final int fileLimitBytes) { + Assert.isTrue(setSize > 0); + Assert.isTrue(fileLimitBytes > 0); + mSetSize = setSize; + mFileLimitBytes = fileLimitBytes; + initDiskLog(); + } + + private static void clearDefaultHandlers(Logger logger) { + Assert.notNull(logger); + for (Handler handler : logger.getHandlers()) { + logger.removeHandler(handler); + } + } + + private void initDiskLog() { + mDiskLogger = Logger.getLogger(LogUtil.BUGLE_TAG); + // We don't want the default console handler + clearDefaultHandlers(mDiskLogger); + // Don't want duplicate print in system log + mDiskLogger.setUseParentHandlers(false); + // FileHandler manages the log files in a fixed rotation set + final File logDir = Factory.get().getApplicationContext().getDir( + DISK_LOG_DIR_NAME, 0/*mode*/); + FileHandler handler = null; + try { + handler = new FileHandler( + logDir + "/%g.log", mFileLimitBytes, mSetSize, true/*append*/); + } catch (Exception e) { + Log.e(LogUtil.BUGLE_TAG, "LogSaver: fail to init disk logger", e); + return; + } + final Formatter formatter = new Formatter() { + @Override + public String format(java.util.logging.LogRecord r) { + return r.getMessage(); + } + }; + handler.setFormatter(formatter); + handler.setLevel(Level.ALL); + mDiskLogger.addHandler(handler); + } + + @Override + public void dump(PrintWriter writer) { + for (int i = mSetSize - 1; i >= 0; i--) { + final File logDir = Factory.get().getApplicationContext().getDir( + DISK_LOG_DIR_NAME, 0/*mode*/); + final String logFilePath = logDir + "/" + i + ".log"; + try { + final File logFile = new File(logFilePath); + if (!logFile.exists()) { + continue; + } + final BufferedReader reader = new BufferedReader(new FileReader(logFile)); + for (String line; (line = reader.readLine()) != null;) { + line = line.trim(); + writer.println(line); + } + } catch (FileNotFoundException e) { + Log.w(LogUtil.BUGLE_TAG, "LogSaver: can not find log file " + logFilePath); + } catch (IOException e) { + Log.w(LogUtil.BUGLE_TAG, "LogSaver: can not read log file", e); + } + } + } + + @Override + public void log(int level, String tag, String msg) { + final SimpleDateFormat sdf = new SimpleDateFormat("MM-dd HH:mm:ss.SSS"); + mDiskLogger.info(String.format("%s %5d %5d %s %s: %s\n", + sdf.format(System.currentTimeMillis()), + Process.myPid(), Process.myTid(), getLevelString(level), tag, msg)); + } + + @Override + public boolean isCurrent() { + final boolean persistent = BugleGservices.get().getBoolean( + BugleGservicesKeys.PERSISTENT_LOGSAVER, + BugleGservicesKeys.PERSISTENT_LOGSAVER_DEFAULT); + if (!persistent) { + return false; + } + final int setSize = BugleGservices.get().getInt( + BugleGservicesKeys.PERSISTENT_LOGSAVER_ROTATION_SET_SIZE, + BugleGservicesKeys.PERSISTENT_LOGSAVER_ROTATION_SET_SIZE_DEFAULT); + final int fileLimitBytes = BugleGservices.get().getInt( + BugleGservicesKeys.PERSISTENT_LOGSAVER_FILE_LIMIT_BYTES, + BugleGservicesKeys.PERSISTENT_LOGSAVER_FILE_LIMIT_BYTES_DEFAULT); + return setSize == mSetSize && fileLimitBytes == mFileLimitBytes; + } + } + + private static String getLevelString(final int level) { + switch (level) { + case android.util.Log.DEBUG: + return "D"; + case android.util.Log.WARN: + return "W"; + case android.util.Log.INFO: + return "I"; + case android.util.Log.VERBOSE: + return "V"; + case android.util.Log.ERROR: + return "E"; + case android.util.Log.ASSERT: + return "A"; + default: + return "?"; + } + } +} diff --git a/src/com/android/messaging/util/LogUtil.java b/src/com/android/messaging/util/LogUtil.java new file mode 100644 index 0000000..021f39b --- /dev/null +++ b/src/com/android/messaging/util/LogUtil.java @@ -0,0 +1,274 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +/** + * Log utility class. + */ +public class LogUtil { + public static final String BUGLE_TAG = "MessagingApp"; + public static final String PROFILE_TAG = "MessagingAppProf"; + public static final String BUGLE_DATABASE_TAG = "MessagingAppDb"; + public static final String BUGLE_DATABASE_PERF_TAG = "MessagingAppDbPerf"; + public static final String BUGLE_DATAMODEL_TAG = "MessagingAppDataModel"; + public static final String BUGLE_IMAGE_TAG = "MessagingAppImage"; + public static final String BUGLE_NOTIFICATIONS_TAG = "MessagingAppNotif"; + public static final String BUGLE_WIDGET_TAG = "MessagingAppWidget"; + + public static final int DEBUG = android.util.Log.DEBUG; + public static final int WARN = android.util.Log.WARN; + public static final int VERBOSE = android.util.Log.VERBOSE; + public static final int INFO = android.util.Log.INFO; + public static final int ERROR = android.util.Log.ERROR; + + // If this is non-null, DEBUG and higher logs will be tracked in-memory. It will not include + // VERBOSE logs. + private static LogSaver sDebugLogSaver; + private static volatile boolean sCaptureDebugLogs; + + /** + * Read Gservices to see if logging should be enabled. + */ + public static void refreshGservices(final BugleGservices gservices) { + sCaptureDebugLogs = gservices.getBoolean( + BugleGservicesKeys.ENABLE_LOG_SAVER, + BugleGservicesKeys.ENABLE_LOG_SAVER_DEFAULT); + if (sCaptureDebugLogs && (sDebugLogSaver == null || !sDebugLogSaver.isCurrent())) { + // We were not capturing logs before. We are now. + sDebugLogSaver = LogSaver.newInstance(); + } else if (!sCaptureDebugLogs && sDebugLogSaver != null) { + // We were capturing logs. We aren't anymore. + sDebugLogSaver = null; + } + } + + // This is called from FactoryImpl once the Gservices class is initialized. + public static void initializeGservices (final BugleGservices gservices) { + gservices.registerForChanges(new Runnable() { + @Override + public void run() { + refreshGservices(gservices); + } + }); + refreshGservices(gservices); + } + + /** + * Send a {@link #VERBOSE} log message. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + */ + public static void v(final String tag, final String msg) { + println(android.util.Log.VERBOSE, tag, msg); + } + + /** + * Send a {@link #VERBOSE} log message. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + * @param tr An exception to log + */ + public static void v(final String tag, final String msg, final Throwable tr) { + println(android.util.Log.VERBOSE, tag, msg + '\n' + + android.util.Log.getStackTraceString(tr)); + } + + /** + * Send a {@link #DEBUG} log message. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + */ + public static void d(final String tag, final String msg) { + println(android.util.Log.DEBUG, tag, msg); + } + + /** + * Send a {@link #DEBUG} log message and log the exception. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + * @param tr An exception to log + */ + public static void d(final String tag, final String msg, final Throwable tr) { + println(android.util.Log.DEBUG, tag, msg + '\n' + + android.util.Log.getStackTraceString(tr)); + } + + /** + * Send an {@link #INFO} log message. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + */ + public static void i(final String tag, final String msg) { + println(android.util.Log.INFO, tag, msg); + } + + /** + * Send a {@link #INFO} log message and log the exception. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + * @param tr An exception to log + */ + public static void i(final String tag, final String msg, final Throwable tr) { + println(android.util.Log.INFO, tag, msg + '\n' + + android.util.Log.getStackTraceString(tr)); + } + + /** + * Send a {@link #WARN} log message. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + */ + public static void w(final String tag, final String msg) { + println(android.util.Log.WARN, tag, msg); + } + + /** + * Send a {@link #WARN} log message and log the exception. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + * @param tr An exception to log + */ + public static void w(final String tag, final String msg, final Throwable tr) { + println(android.util.Log.WARN, tag, msg); + println(android.util.Log.WARN, tag, android.util.Log.getStackTraceString(tr)); + } + + /** + * Send an {@link #ERROR} log message. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + */ + public static void e(final String tag, final String msg) { + println(android.util.Log.ERROR, tag, msg); + } + + /** + * Send a {@link #ERROR} log message and log the exception. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + * @param tr An exception to log + */ + public static void e(final String tag, final String msg, final Throwable tr) { + println(android.util.Log.ERROR, tag, msg); + println(android.util.Log.ERROR, tag, android.util.Log.getStackTraceString(tr)); + } + + /** + * What a Terrible Failure: Report a condition that should never happen. + * The error will always be logged at level ASSERT with the call stack. + * Depending on system configuration, a report may be added to the + * {@link android.os.DropBoxManager} and/or the process may be terminated + * immediately with an error dialog. + * @param tag Used to identify the source of a log message. + * @param msg The message you would like logged. + */ + public static void wtf(final String tag, final String msg) { + // Make sure this goes into our log buffer + println(android.util.Log.ASSERT, tag, "wtf\n" + msg); + android.util.Log.wtf(tag, msg, new Exception()); + } + + /** + * What a Terrible Failure: Report a condition that should never happen. + * The error will always be logged at level ASSERT with the call stack. + * Depending on system configuration, a report may be added to the + * {@link android.os.DropBoxManager} and/or the process may be terminated + * immediately with an error dialog. + * @param tag Used to identify the source of a log message. + * @param msg The message you would like logged. + * @param tr An exception to log + */ + public static void wtf(final String tag, final String msg, final Throwable tr) { + // Make sure this goes into our log buffer + println(android.util.Log.ASSERT, tag, "wtf\n" + msg + '\n' + + android.util.Log.getStackTraceString(tr)); + android.util.Log.wtf(tag, msg, tr); + } + + /** + * Low-level logging call. + * @param level The priority/type of this log message + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + */ + private static void println(final int level, final String tag, final String msg) { + android.util.Log.println(level, tag, msg); + + LogSaver serviceLog = sDebugLogSaver; + if (serviceLog != null && level >= android.util.Log.DEBUG) { + serviceLog.log(level, tag, msg); + } + } + + /** + * Save logging into LogSaver only, for dumping to bug report + * + * @param level The priority/type of this log message + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + */ + public static void save(final int level, final String tag, final String msg) { + LogSaver serviceLog = sDebugLogSaver; + if (serviceLog != null) { + serviceLog.log(level, tag, msg); + } + } + + /** + * Checks to see whether or not a log for the specified tag is loggable at the specified level. + * See {@link android.util.Log#isLoggable(String, int)} for more discussion. + */ + public static boolean isLoggable(final String tag, final int level) { + return android.util.Log.isLoggable(tag, level); + } + + /** + * Returns text as is if {@value #BUGLE_TAG}'s log level is set to DEBUG or VERBOSE; + * returns "--" otherwise. Useful for log statements where we don't want to log + * various strings (e.g., usernames) with default logging to avoid leaking PII in logcat. + */ + public static String sanitizePII(final String text) { + if (text == null) { + return null; + } + + if (android.util.Log.isLoggable(BUGLE_TAG, android.util.Log.DEBUG)) { + return text; + } else { + return "Redacted-" + text.length(); + } + } + + public static void dump(java.io.PrintWriter out) { + final LogSaver logsaver = sDebugLogSaver; + if (logsaver != null) { + logsaver.dump(out); + } + } +} diff --git a/src/com/android/messaging/util/LoggingTimer.java b/src/com/android/messaging/util/LoggingTimer.java new file mode 100644 index 0000000..d0d41ac --- /dev/null +++ b/src/com/android/messaging/util/LoggingTimer.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.os.SystemClock; + +/** + * A utility timer that logs the execution time of operations + */ +public class LoggingTimer { + private static final int NO_WARN_LIMIT = -1; + + private final String mTag; + private final String mName; + private final long mWarnLimitMillis; + private long mStartMillis; + + public LoggingTimer(final String tag, final String name) { + this(tag, name, NO_WARN_LIMIT); + } + + public LoggingTimer(final String tag, final String name, final long warnLimitMillis) { + mTag = tag; + mName = name; + mWarnLimitMillis = warnLimitMillis; + } + + /** + * This method should be called at the start of the operation to be timed. + */ + public void start() { + mStartMillis = SystemClock.elapsedRealtime(); + + if (LogUtil.isLoggable(mTag, LogUtil.VERBOSE)) { + LogUtil.v(mTag, "Timer start for " + mName); + } + } + + /** + * This method should be called at the end of the operation to be timed. It logs the time since + * the last call to {@link #start} + */ + public void stopAndLog() { + final long elapsedMs = SystemClock.elapsedRealtime() - mStartMillis; + + final String logMessage = String.format("Used %dms for %s", elapsedMs, mName); + + LogUtil.save(LogUtil.DEBUG, mTag, logMessage); + + if (mWarnLimitMillis != NO_WARN_LIMIT && elapsedMs > mWarnLimitMillis) { + LogUtil.w(mTag, logMessage); + } else if (LogUtil.isLoggable(mTag, LogUtil.VERBOSE)) { + LogUtil.v(mTag, logMessage); + } + } +} diff --git a/src/com/android/messaging/util/LongSparseSet.java b/src/com/android/messaging/util/LongSparseSet.java new file mode 100644 index 0000000..8e2cfca --- /dev/null +++ b/src/com/android/messaging/util/LongSparseSet.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.support.v4.util.LongSparseArray; + +/** + * A space saving set for long values using v4 compat LongSparseArray + */ +public class LongSparseSet { + private static final Object THE_ONLY_VALID_VALUE = new Object(); + private final LongSparseArray mSet = new LongSparseArray(); + + public LongSparseSet() { + } + + /** + * @param key The element to check + * @return True if the element is in the set, false otherwise + */ + public boolean contains(long key) { + if (mSet.get(key, null/*default*/) == THE_ONLY_VALID_VALUE) { + return true; + } + return false; + } + + /** + * Add an element to the set + * + * @param key The element to add + */ + public void add(long key) { + mSet.put(key, THE_ONLY_VALID_VALUE); + } + + /** + * Remove an element from the set + * + * @param key The element to remove + */ + public void remove(long key) { + mSet.delete(key); + } +} diff --git a/src/com/android/messaging/util/MaterialPalette.java b/src/com/android/messaging/util/MaterialPalette.java new file mode 100644 index 0000000..8dbacbf --- /dev/null +++ b/src/com/android/messaging/util/MaterialPalette.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + + +public class MaterialPalette{ + public final int mPrimaryColor; + public final int mSecondaryColor; + + public MaterialPalette(final int primaryColor, final int secondaryColor) { + mPrimaryColor = primaryColor; + mSecondaryColor = secondaryColor; + } +} diff --git a/src/com/android/messaging/util/MediaMetadataRetrieverWrapper.java b/src/com/android/messaging/util/MediaMetadataRetrieverWrapper.java new file mode 100644 index 0000000..b1078d1 --- /dev/null +++ b/src/com/android/messaging/util/MediaMetadataRetrieverWrapper.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import android.content.ContentResolver; +import android.content.res.AssetFileDescriptor; +import android.graphics.Bitmap; +import android.media.MediaMetadataRetriever; +import android.net.Uri; +import android.text.TextUtils; + +import com.android.messaging.Factory; + +import java.io.IOException; + +/** + * Convenience wrapper for {@link MediaMetadataRetriever} to help with its eccentric error handling. + */ +public class MediaMetadataRetrieverWrapper { + private final MediaMetadataRetriever mRetriever = new MediaMetadataRetriever(); + + public MediaMetadataRetrieverWrapper() { + } + + public void setDataSource(Uri uri) throws IOException { + ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver(); + AssetFileDescriptor fd = resolver.openAssetFileDescriptor(uri, "r"); + if (fd == null) { + throw new IOException("openAssetFileDescriptor returned null for " + uri); + } + try { + mRetriever.setDataSource(fd.getFileDescriptor()); + } catch (RuntimeException e) { + release(); + throw new IOException(e); + } finally { + fd.close(); + } + } + + public int extractInteger(final int key, final int defaultValue) { + final String s = mRetriever.extractMetadata(key); + if (TextUtils.isEmpty(s)) { + return defaultValue; + } + return Integer.parseInt(s); + } + + public String extractMetadata(final int key) { + return mRetriever.extractMetadata(key); + } + + public Bitmap getFrameAtTime() { + return mRetriever.getFrameAtTime(); + } + + public Bitmap getFrameAtTime(final long timeUs) { + return mRetriever.getFrameAtTime(timeUs); + } + + public void release() { + try { + mRetriever.release(); + } catch (RuntimeException e) { + LogUtil.e(LogUtil.BUGLE_TAG, "MediaMetadataRetriever.release failed", e); + } + } +} diff --git a/src/com/android/messaging/util/MediaUtil.java b/src/com/android/messaging/util/MediaUtil.java new file mode 100644 index 0000000..f25354c --- /dev/null +++ b/src/com/android/messaging/util/MediaUtil.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import android.content.Context; + +import com.android.messaging.Factory; + +public abstract class MediaUtil { + public static interface OnCompletionListener { + public void onCompletion(); + } + + public static MediaUtil get() { + return Factory.get().getMediaUtil(); + } + + /** + * Play sound from local resources given a resource id. + */ + public abstract void playSound(final Context context, final int resId, + final OnCompletionListener completionListener); +} diff --git a/src/com/android/messaging/util/MediaUtilImpl.java b/src/com/android/messaging/util/MediaUtilImpl.java new file mode 100644 index 0000000..272a057 --- /dev/null +++ b/src/com/android/messaging/util/MediaUtilImpl.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import android.content.Context; +import android.content.res.AssetFileDescriptor; +import android.media.AudioManager; +import android.media.MediaPlayer; + +/** + * Default implementation of MediaUtil + */ +public class MediaUtilImpl extends MediaUtil { + + @Override + public void playSound(final Context context, final int resId, + final OnCompletionListener completionListener) { + // We want to play at the media volume and not the ringer volume, but we do want to + // avoid playing sound when the ringer/notifications are silenced. This is used for + // in app sounds that are not critical and should not impact running silent but also + // shouldn't play at full ring volume if you want to hear your ringer but don't want + // to be annoyed with in-app volume. + AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + try { + final MediaPlayer mediaPlayer = new MediaPlayer(); + mediaPlayer.setAudioStreamType(AudioManager.STREAM_NOTIFICATION); + final AssetFileDescriptor afd = context.getResources().openRawResourceFd(resId); + mediaPlayer.setDataSource( + afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength()); + afd.close(); + mediaPlayer.prepare(); + mediaPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(final MediaPlayer mp) { + if (completionListener != null) { + completionListener.onCompletion(); + } + mp.stop(); + mp.release(); + } + }); + mediaPlayer.seekTo(0); + mediaPlayer.start(); + return; + } catch (final Exception e) { + LogUtil.w("MediaUtilImpl", "Error playing sound id: " + resId, e); + } + if (completionListener != null) { + // Call the completion handler to not block functionality if audio play fails + completionListener.onCompletion(); + } + } +} \ No newline at end of file diff --git a/src/com/android/messaging/util/NotificationPlayer.java b/src/com/android/messaging/util/NotificationPlayer.java new file mode 100644 index 0000000..a4ed44e --- /dev/null +++ b/src/com/android/messaging/util/NotificationPlayer.java @@ -0,0 +1,363 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.content.Context; +import android.media.AudioManager; +import android.media.MediaPlayer; +import android.media.MediaPlayer.OnCompletionListener; +import android.net.Uri; +import android.os.Looper; +import android.os.PowerManager; +import android.os.SystemClock; + +import com.android.messaging.Factory; + +import java.util.LinkedList; + +/** + * This class is provides the same interface and functionality as android.media.AsyncPlayer + * with the following differences: + * - whenever audio is played, audio focus is requested, + * - whenever audio playback is stopped or the playback completed, audio focus is abandoned. + * + * This file has been copied from com.android.server.NotificationPlayer. The only modification is + * the addition of a volume parameter. Hopefully the framework will adapt AsyncPlayer to support + * all the functionality in this class, at which point this one can be deleted. + */ +public class NotificationPlayer implements OnCompletionListener { + private static final int PLAY = 1; + private static final int STOP = 2; + private static final boolean mDebug = false; + + private static final class Command { + int code; + Uri uri; + boolean looping; + int stream; + float volume; + long requestTime; + boolean releaseFocus; + + @Override + public String toString() { + return "{ code=" + code + " looping=" + looping + " stream=" + stream + + " uri=" + uri + " }"; + } + } + + private final LinkedList mCmdQueue = new LinkedList(); + + private Looper mLooper; + + /* + * Besides the use of audio focus, the only implementation difference between AsyncPlayer and + * NotificationPlayer resides in the creation of the MediaPlayer. For the completion callback, + * OnCompletionListener, to be called at the end of the playback, the MediaPlayer needs to + * be created with a looper running so its event handler is not null. + */ + private final class CreationAndCompletionThread extends Thread { + public Command mCmd; + public CreationAndCompletionThread(final Command cmd) { + super(); + mCmd = cmd; + } + + @Override + public void run() { + Looper.prepare(); + mLooper = Looper.myLooper(); + synchronized (this) { + final AudioManager audioManager = + (AudioManager) Factory.get().getApplicationContext() + .getSystemService(Context.AUDIO_SERVICE); + try { + final MediaPlayer player = new MediaPlayer(); + player.setAudioStreamType(mCmd.stream); + player.setDataSource(Factory.get().getApplicationContext(), mCmd.uri); + player.setLooping(mCmd.looping); + player.setVolume(mCmd.volume, mCmd.volume); + player.prepare(); + if ((mCmd.uri != null) && (mCmd.uri.getEncodedPath() != null) + && (mCmd.uri.getEncodedPath().length() > 0)) { + audioManager.requestAudioFocus(null, mCmd.stream, + mCmd.looping ? AudioManager.AUDIOFOCUS_GAIN_TRANSIENT + : AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK); + } + player.setOnCompletionListener(NotificationPlayer.this); + player.start(); + if (mPlayer != null) { + mPlayer.release(); + } + mPlayer = player; + } catch (final Exception e) { + LogUtil.w(mTag, "error loading sound for " + mCmd.uri, e); + } + mAudioManager = audioManager; + this.notify(); + } + Looper.loop(); + } + } + + private void startSound(final Command cmd) { + // Preparing can be slow, so if there is something else + // is playing, let it continue until we're done, so there + // is less of a glitch. + try { + if (mDebug) { + LogUtil.d(mTag, "Starting playback"); + } + //----------------------------------- + // This is were we deviate from the AsyncPlayer implementation and create the + // MediaPlayer in a new thread with which we're synchronized + synchronized (mCompletionHandlingLock) { + // if another sound was already playing, it doesn't matter we won't get notified + // of the completion, since only the completion notification of the last sound + // matters + if ((mLooper != null) + && (mLooper.getThread().getState() != Thread.State.TERMINATED)) { + mLooper.quit(); + } + mCompletionThread = new CreationAndCompletionThread(cmd); + synchronized (mCompletionThread) { + mCompletionThread.start(); + mCompletionThread.wait(); + } + } + //----------------------------------- + + final long delay = SystemClock.elapsedRealtime() - cmd.requestTime; + if (delay > 1000) { + LogUtil.w(mTag, "Notification sound delayed by " + delay + "msecs"); + } + } catch (final Exception e) { + LogUtil.w(mTag, "error loading sound for " + cmd.uri, e); + } + } + + private void stopSound(final Command cmd) { + if (mPlayer == null) { + return; + } + final long delay = SystemClock.elapsedRealtime() - cmd.requestTime; + if (delay > 1000) { + LogUtil.w(mTag, "Notification stop delayed by " + delay + "msecs"); + } + mPlayer.stop(); + mPlayer.release(); + mPlayer = null; + if (cmd.releaseFocus && mAudioManager != null) { + mAudioManager.abandonAudioFocus(null); + } + mAudioManager = null; + if ((mLooper != null) && (mLooper.getThread().getState() != Thread.State.TERMINATED)) { + mLooper.quit(); + } + } + + private final class CmdThread extends java.lang.Thread { + CmdThread() { + super("NotificationPlayer-" + mTag); + } + + @Override + public void run() { + while (true) { + Command cmd = null; + + synchronized (mCmdQueue) { + if (mDebug) { + LogUtil.d(mTag, "RemoveFirst"); + } + cmd = mCmdQueue.removeFirst(); + } + + switch (cmd.code) { + case PLAY: + if (mDebug) { + LogUtil.d(mTag, "PLAY"); + } + startSound(cmd); + break; + case STOP: + if (mDebug) { + LogUtil.d(mTag, "STOP"); + } + stopSound(cmd); + break; + } + + synchronized (mCmdQueue) { + if (mCmdQueue.size() == 0) { + // nothing left to do, quit + // doing this check after we're done prevents the case where they + // added it during the operation from spawning two threads and + // trying to do them in parallel. + mThread = null; + releaseWakeLock(); + return; + } + } + } + } + } + + @Override + public void onCompletion(final MediaPlayer mp) { + if (mAudioManager != null) { + mAudioManager.abandonAudioFocus(null); + } + // if there are no more sounds to play, end the Looper to listen for media completion + synchronized (mCmdQueue) { + if (mCmdQueue.size() == 0) { + synchronized (mCompletionHandlingLock) { + if (mLooper != null) { + mLooper.quit(); + } + mCompletionThread = null; + } + } + } + } + + private String mTag; + private CmdThread mThread; + private CreationAndCompletionThread mCompletionThread; + private final Object mCompletionHandlingLock = new Object(); + private MediaPlayer mPlayer; + private PowerManager.WakeLock mWakeLock; + private AudioManager mAudioManager; + + // The current state according to the caller. Reality lags behind + // because of the asynchronous nature of this class. + private int mState = STOP; + + /** + * Construct a NotificationPlayer object. + * + * @param tag a string to use for debugging + */ + public NotificationPlayer(final String tag) { + if (tag != null) { + mTag = tag; + } else { + mTag = "NotificationPlayer"; + } + } + + /** + * Start playing the sound. It will actually start playing at some + * point in the future. There are no guarantees about latency here. + * Calling this before another audio file is done playing will stop + * that one and start the new one. + * + * @param uri The URI to play. (see {@link MediaPlayer#setDataSource(Context, Uri)}) + * @param looping Whether the audio should loop forever. + * (see {@link MediaPlayer#setLooping(boolean)}) + * @param stream the AudioStream to use. + * (see {@link MediaPlayer#setAudioStreamType(int)}) + * @param volume The volume at which to play this sound, as a fraction of the system volume for + * the relevant stream type. A value of 1 is the maximum and means play at the system + * volume with no attenuation. + */ + public void play(final Uri uri, final boolean looping, final int stream, final float volume) { + final Command cmd = new Command(); + cmd.requestTime = SystemClock.elapsedRealtime(); + cmd.code = PLAY; + cmd.uri = uri; + cmd.looping = looping; + cmd.stream = stream; + cmd.volume = volume; + synchronized (mCmdQueue) { + enqueueLocked(cmd); + mState = PLAY; + } + } + + /** Same as calling stop(true) */ + public void stop() { + stop(true); + } + + /** + * Stop a previously played sound. It can't be played again or unpaused + * at this point. Calling this multiple times has no ill effects. + * @param releaseAudioFocus whether to release audio focus + */ + public void stop(final boolean releaseAudioFocus) { + synchronized (mCmdQueue) { + // This check allows stop to be called multiple times without starting + // a thread that ends up doing nothing. + if (mState != STOP) { + final Command cmd = new Command(); + cmd.requestTime = SystemClock.elapsedRealtime(); + cmd.code = STOP; + cmd.releaseFocus = releaseAudioFocus; + enqueueLocked(cmd); + mState = STOP; + } + } + } + + private void enqueueLocked(final Command cmd) { + mCmdQueue.add(cmd); + if (mThread == null) { + acquireWakeLock(); + mThread = new CmdThread(); + mThread.start(); + } + } + + /** + * We want to hold a wake lock while we do the prepare and play. The stop probably is + * optional, but it won't hurt to have it too. The problem is that if you start a sound + * while you're holding a wake lock (e.g. an alarm starting a notification), you want the + * sound to play, but if the CPU turns off before mThread gets to work, it won't. The + * simplest way to deal with this is to make it so there is a wake lock held while the + * thread is starting or running. You're going to need the WAKE_LOCK permission if you're + * going to call this. + * + * This must be called before the first time play is called. + * + * @hide + */ + public void setUsesWakeLock() { + if (mWakeLock != null || mThread != null) { + // if either of these has happened, we've already played something. + // and our releases will be out of sync. + throw new RuntimeException("assertion failed mWakeLock=" + mWakeLock + + " mThread=" + mThread); + } + final PowerManager pm = (PowerManager) Factory.get().getApplicationContext() + .getSystemService(Context.POWER_SERVICE); + mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, mTag); + } + + private void acquireWakeLock() { + if (mWakeLock != null) { + mWakeLock.acquire(); + } + } + + private void releaseWakeLock() { + if (mWakeLock != null) { + mWakeLock.release(); + } + } +} + diff --git a/src/com/android/messaging/util/OsUtil.java b/src/com/android/messaging/util/OsUtil.java new file mode 100644 index 0000000..e45a63c --- /dev/null +++ b/src/com/android/messaging/util/OsUtil.java @@ -0,0 +1,269 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.UserHandle; +import android.os.UserManager; + +import com.android.messaging.Factory; + +import java.util.ArrayList; +import java.util.Hashtable; +import java.util.Set; + +/** + * Android OS version utilities + */ +public class OsUtil { + private static boolean sIsAtLeastICS_MR1; + private static boolean sIsAtLeastJB; + private static boolean sIsAtLeastJB_MR1; + private static boolean sIsAtLeastJB_MR2; + private static boolean sIsAtLeastKLP; + private static boolean sIsAtLeastL; + private static boolean sIsAtLeastL_MR1; + private static boolean sIsAtLeastM; + + private static Boolean sIsSecondaryUser = null; + + static { + final int v = getApiVersion(); + sIsAtLeastICS_MR1 = v >= android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1; + sIsAtLeastJB = v >= android.os.Build.VERSION_CODES.JELLY_BEAN; + sIsAtLeastJB_MR1 = v >= android.os.Build.VERSION_CODES.JELLY_BEAN_MR1; + sIsAtLeastJB_MR2 = v >= android.os.Build.VERSION_CODES.JELLY_BEAN_MR2; + sIsAtLeastKLP = v >= android.os.Build.VERSION_CODES.KITKAT; + sIsAtLeastL = v >= android.os.Build.VERSION_CODES.LOLLIPOP; + sIsAtLeastL_MR1 = v >= android.os.Build.VERSION_CODES.LOLLIPOP_MR1; + sIsAtLeastM = v >= android.os.Build.VERSION_CODES.M; + } + + /** + * @return True if the version of Android that we're running on is at least Ice Cream Sandwich + * MR1 (API level 15). + */ + public static boolean isAtLeastICS_MR1() { + return sIsAtLeastICS_MR1; + } + + /** + * @return True if the version of Android that we're running on is at least Jelly Bean + * (API level 16). + */ + public static boolean isAtLeastJB() { + return sIsAtLeastJB; + } + + /** + * @return True if the version of Android that we're running on is at least Jelly Bean MR1 + * (API level 17). + */ + public static boolean isAtLeastJB_MR1() { + return sIsAtLeastJB_MR1; + } + + /** + * @return True if the version of Android that we're running on is at least Jelly Bean MR2 + * (API level 18). + */ + public static boolean isAtLeastJB_MR2() { + return sIsAtLeastJB_MR2; + } + + /** + * @return True if the version of Android that we're running on is at least KLP + * (API level 19). + */ + public static boolean isAtLeastKLP() { + return sIsAtLeastKLP; + } + + /** + * @return True if the version of Android that we're running on is at least L + * (API level 21). + */ + public static boolean isAtLeastL() { + return sIsAtLeastL; + } + + /** + * @return True if the version of Android that we're running on is at least L MR1 + * (API level 22). + */ + public static boolean isAtLeastL_MR1() { + return sIsAtLeastL_MR1; + } + + /** + * @return True if the version of Android that we're running on is at least M + * (API level 23). + */ + public static boolean isAtLeastM() { + return sIsAtLeastM; + } + + /** + * @return The Android API version of the OS that we're currently running on. + */ + public static int getApiVersion() { + return android.os.Build.VERSION.SDK_INT; + } + + public static boolean isSecondaryUser() { + if (sIsSecondaryUser == null) { + final Context context = Factory.get().getApplicationContext(); + boolean isSecondaryUser = false; + + // Only check for newer devices (but not the nexus 10) + if (OsUtil.sIsAtLeastJB_MR1 && !"Nexus 10".equals(Build.MODEL)) { + final UserHandle uh = android.os.Process.myUserHandle(); + final UserManager userManager = + (UserManager) context.getSystemService(Context.USER_SERVICE); + if (userManager != null) { + final long userSerialNumber = userManager.getSerialNumberForUser(uh); + isSecondaryUser = (0 != userSerialNumber); + } + } + sIsSecondaryUser = isSecondaryUser; + } + return sIsSecondaryUser; + } + + /** + * Creates a joined string from a Set using the given delimiter. + * @param values + * @param delimiter + * @return + */ + public static String joinFromSetWithDelimiter( + final Set values, final String delimiter) { + if (values != null) { + final StringBuilder joinedStringBuilder = new StringBuilder(); + boolean firstValue = true; + for (final String value : values) { + if (firstValue) { + firstValue = false; + } else { + joinedStringBuilder.append(delimiter); + } + joinedStringBuilder.append(value); + } + return joinedStringBuilder.toString(); + } + return null; + } + + private static Hashtable sPermissions = new Hashtable(); + + /** + * Check if the app has the specified permission. If it does not, the app needs to use + * {@link android.app.Activity#requestPermission}. Note that if it + * returns true, it cannot return false in the same process as the OS kills the process when + * any permission is revoked. + * @param permission A permission from {@link android.Manifest.permission} + */ + public static boolean hasPermission(final String permission) { + if (OsUtil.isAtLeastM()) { + // It is safe to cache the PERMISSION_GRANTED result as the process gets killed if the + // user revokes the permission setting. However, PERMISSION_DENIED should not be + // cached as the process does not get killed if the user enables the permission setting. + if (!sPermissions.containsKey(permission) + || sPermissions.get(permission) == PackageManager.PERMISSION_DENIED) { + final Context context = Factory.get().getApplicationContext(); + final int permissionState = context.checkSelfPermission(permission); + sPermissions.put(permission, permissionState); + } + return sPermissions.get(permission) == PackageManager.PERMISSION_GRANTED; + } else { + return true; + } + } + + /** Does the app have all the specified permissions */ + public static boolean hasPermissions(final String[] permissions) { + for (final String permission : permissions) { + if (!hasPermission(permission)) { + return false; + } + } + return true; + } + + public static boolean hasPhonePermission() { + return hasPermission(Manifest.permission.READ_PHONE_STATE); + } + + public static boolean hasSmsPermission() { + return hasPermission(Manifest.permission.READ_SMS); + } + + public static boolean hasLocationPermission() { + return OsUtil.hasPermission(Manifest.permission.ACCESS_FINE_LOCATION); + } + + + public static boolean hasStoragePermission() { + // Note that READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE are granted or denied + // together. + return OsUtil.hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE); + } + + public static boolean hasRecordAudioPermission() { + return OsUtil.hasPermission(Manifest.permission.RECORD_AUDIO); + } + + /** + * Returns array with the set of permissions that have not been granted from the given set. + * The array will be empty if the app has all of the specified permissions. Note that calling + * {@link Activity#requestPermissions} for an already granted permission can prompt the user + * again, and its up to the app to only request permissions that are missing. + */ + public static String[] getMissingPermissions(final String[] permissions) { + final ArrayList missingList = new ArrayList(); + for (final String permission : permissions) { + if (!hasPermission(permission)) { + missingList.add(permission); + } + } + + final String[] missingArray = new String[missingList.size()]; + missingList.toArray(missingArray); + return missingArray; + } + + private static String[] sRequiredPermissions = new String[] { + // Required to read existing SMS threads + Manifest.permission.READ_SMS, + // Required for knowing the phone number, number of SIMs, etc. + Manifest.permission.READ_PHONE_STATE, + // This is not strictly required, but simplifies the contact picker scenarios + Manifest.permission.READ_CONTACTS, + }; + + /** Does the app have the minimum set of permissions required to operate. */ + public static boolean hasRequiredPermissions() { + return hasPermissions(sRequiredPermissions); + } + + public static String[] getMissingRequiredPermissions() { + return getMissingPermissions(sRequiredPermissions); + } +} diff --git a/src/com/android/messaging/util/PendingIntentConstants.java b/src/com/android/messaging/util/PendingIntentConstants.java new file mode 100644 index 0000000..1a594c5 --- /dev/null +++ b/src/com/android/messaging/util/PendingIntentConstants.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + + +public class PendingIntentConstants { + // Notifications + public static final int SMS_NOTIFICATION_ID = 0; + public static final int SMS_SECONDARY_USER_NOTIFICATION_ID = 1; + public static final int MSG_SEND_ERROR = 2; + public static final int SMS_STORAGE_LOW_NOTIFICATION_ID = 3; + + // Request codes + public static final int UPDATE_NOTIFICATIONS_ALARM_ACTION_ID = 100; + + public static final int MIN_ASSIGNED_REQUEST_CODE = 1001; + + // Logging + private static final String TAG = LogUtil.BUGLE_TAG; + private static final boolean VERBOSE = false; + + // Internal Constants + private static final String NOTIFICATION_REQUEST_CODE_PREFS = "notificationRequestCodes.v1"; + private static final String REQUEST_CODE_DELIMITER = "|"; + private static final String MAX_REQUEST_CODE_KEY = "maxRequestCode"; +} diff --git a/src/com/android/messaging/util/PhoneUtils.java b/src/com/android/messaging/util/PhoneUtils.java new file mode 100644 index 0000000..726f083 --- /dev/null +++ b/src/com/android/messaging/util/PhoneUtils.java @@ -0,0 +1,1011 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.database.Cursor; +import android.net.ConnectivityManager; +import android.provider.Settings; +import android.provider.Telephony; +import android.support.v4.util.ArrayMap; +import android.telephony.PhoneNumberUtils; +import android.telephony.SmsManager; +import android.telephony.SubscriptionInfo; +import android.telephony.SubscriptionManager; +import android.telephony.TelephonyManager; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsSmsUtils; +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; + +/** + * This class abstracts away platform dependency of calling telephony related + * platform APIs, mostly involving TelephonyManager, SubscriptionManager and + * a bit of SmsManager. + * + * The class instance can only be obtained via the get(int subId) method parameterized + * by a SIM subscription ID. On pre-L_MR1, the subId is not used and it has to be + * the default subId (-1). + * + * A convenient getDefault() method is provided for default subId (-1) on any platform + */ +public abstract class PhoneUtils { + private static final String TAG = LogUtil.BUGLE_TAG; + + private static final int MINIMUM_PHONE_NUMBER_LENGTH_TO_FORMAT = 6; + + private static final List EMPTY_SUBSCRIPTION_LIST = new ArrayList<>(); + + // The canonical phone number cache + // Each country gets its own cache. The following maps from ISO country code to + // the country's cache. Each cache maps from original phone number to canonicalized phone + private static final ArrayMap> sCanonicalPhoneNumberCache = + new ArrayMap<>(); + + protected final Context mContext; + protected final TelephonyManager mTelephonyManager; + protected final int mSubId; + + public PhoneUtils(int subId) { + mSubId = subId; + mContext = Factory.get().getApplicationContext(); + mTelephonyManager = + (TelephonyManager) mContext.getSystemService(Context.TELEPHONY_SERVICE); + } + + /** + * Get the SIM's country code + * + * @return the country code on the SIM + */ + public abstract String getSimCountry(); + + /** + * Get number of SIM slots + * + * @return the SIM slot count + */ + public abstract int getSimSlotCount(); + + /** + * Get SIM's carrier name + * + * @return the carrier name of the SIM + */ + public abstract String getCarrierName(); + + /** + * Check if there is SIM inserted on the device + * + * @return true if there is SIM inserted, false otherwise + */ + public abstract boolean hasSim(); + + /** + * Check if the SIM is roaming + * + * @return true if the SIM is in romaing state, false otherwise + */ + public abstract boolean isRoaming(); + + /** + * Get the MCC and MNC in integer of the SIM's provider + * + * @return an array of two ints, [0] is the MCC code and [1] is the MNC code + */ + public abstract int[] getMccMnc(); + + /** + * Get the mcc/mnc string + * + * @return the text of mccmnc string + */ + public abstract String getSimOperatorNumeric(); + + /** + * Get the SIM's self raw number, i.e. not canonicalized + * + * @param allowOverride Whether to use the app's setting to override the self number + * @return the original self number + * @throws IllegalStateException if no active subscription on L-MR1+ + */ + public abstract String getSelfRawNumber(final boolean allowOverride); + + /** + * Returns the "effective" subId, or the subId used in the context of actual messages, + * conversations and subscription-specific settings, for the given "nominal" sub id. + * + * For pre-L-MR1 platform, this should always be + * {@value com.android.messaging.datamodel.data.ParticipantData#DEFAULT_SELF_SUB_ID}; + * + * On the other hand, for L-MR1 and above, DEFAULT_SELF_SUB_ID will be mapped to the system + * default subscription id for SMS. + * + * @param subId The input subId + * @return the real subId if we can convert + */ + public abstract int getEffectiveSubId(int subId); + + /** + * Returns the number of active subscriptions in the device. + */ + public abstract int getActiveSubscriptionCount(); + + /** + * Get {@link SmsManager} instance + * + * @return the relevant SmsManager instance based on OS version and subId + */ + public abstract SmsManager getSmsManager(); + + /** + * Get the default SMS subscription id + * + * @return the default sub ID + */ + public abstract int getDefaultSmsSubscriptionId(); + + /** + * Returns if there's currently a system default SIM selected for sending SMS. + */ + public abstract boolean getHasPreferredSmsSim(); + + /** + * For L_MR1, system may return a negative subId. Convert this into our own + * subId, so that we consistently use -1 for invalid or default. + * + * see b/18629526 and b/18670346 + * + * @param intent The push intent from system + * @param extraName The name of the sub id extra + * @return the subId that is valid and meaningful for the app + */ + public abstract int getEffectiveIncomingSubIdFromSystem(Intent intent, String extraName); + + /** + * Get the subscription_id column value from a telephony provider cursor + * + * @param cursor The database query cursor + * @param subIdIndex The index of the subId column in the cursor + * @return the subscription_id column value from the cursor + */ + public abstract int getSubIdFromTelephony(Cursor cursor, int subIdIndex); + + /** + * Check if data roaming is enabled + * + * @return true if data roaming is enabled, false otherwise + */ + public abstract boolean isDataRoamingEnabled(); + + /** + * Check if mobile data is enabled + * + * @return true if mobile data is enabled, false otherwise + */ + public abstract boolean isMobileDataEnabled(); + + /** + * Get the set of self phone numbers, all normalized + * + * @return the set of normalized self phone numbers + */ + public abstract HashSet getNormalizedSelfNumbers(); + + /** + * This interface packages methods should only compile on L_MR1. + * This is needed to make unit tests happy when mockito tries to + * mock these methods. Calling on these methods on L_MR1 requires + * an extra invocation of toMr1(). + */ + public interface LMr1 { + /** + * Get this SIM's information. Only applies to L_MR1 above + * + * @return the subscription info of the SIM + */ + public abstract SubscriptionInfo getActiveSubscriptionInfo(); + + /** + * Get the list of active SIMs in system. Only applies to L_MR1 above + * + * @return the list of subscription info for all inserted SIMs + */ + public abstract List getActiveSubscriptionInfoList(); + + /** + * Register subscription change listener. Only applies to L_MR1 above + * + * @param listener The listener to register + */ + public abstract void registerOnSubscriptionsChangedListener( + SubscriptionManager.OnSubscriptionsChangedListener listener); + } + + /** + * The PhoneUtils class for pre L_MR1 + */ + public static class PhoneUtilsPreLMR1 extends PhoneUtils { + private final ConnectivityManager mConnectivityManager; + + public PhoneUtilsPreLMR1() { + super(ParticipantData.DEFAULT_SELF_SUB_ID); + mConnectivityManager = + (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE); + } + + @Override + public String getSimCountry() { + final String country = mTelephonyManager.getSimCountryIso(); + if (TextUtils.isEmpty(country)) { + return null; + } + return country.toUpperCase(); + } + + @Override + public int getSimSlotCount() { + // Don't support MSIM pre-L_MR1 + return 1; + } + + @Override + public String getCarrierName() { + return mTelephonyManager.getNetworkOperatorName(); + } + + @Override + public boolean hasSim() { + return mTelephonyManager.getSimState() != TelephonyManager.SIM_STATE_ABSENT; + } + + @Override + public boolean isRoaming() { + return mTelephonyManager.isNetworkRoaming(); + } + + @Override + public int[] getMccMnc() { + final String mccmnc = mTelephonyManager.getSimOperator(); + int mcc = 0; + int mnc = 0; + try { + mcc = Integer.parseInt(mccmnc.substring(0, 3)); + mnc = Integer.parseInt(mccmnc.substring(3)); + } catch (Exception e) { + LogUtil.w(TAG, "PhoneUtils.getMccMnc: invalid string " + mccmnc, e); + } + return new int[]{mcc, mnc}; + } + + @Override + public String getSimOperatorNumeric() { + return mTelephonyManager.getSimOperator(); + } + + @Override + public String getSelfRawNumber(final boolean allowOverride) { + if (allowOverride) { + final String userDefinedNumber = getNumberFromPrefs(mContext, + ParticipantData.DEFAULT_SELF_SUB_ID); + if (!TextUtils.isEmpty(userDefinedNumber)) { + return userDefinedNumber; + } + } + return mTelephonyManager.getLine1Number(); + } + + @Override + public int getEffectiveSubId(int subId) { + Assert.equals(ParticipantData.DEFAULT_SELF_SUB_ID, subId); + return ParticipantData.DEFAULT_SELF_SUB_ID; + } + + @Override + public SmsManager getSmsManager() { + return SmsManager.getDefault(); + } + + @Override + public int getDefaultSmsSubscriptionId() { + Assert.fail("PhoneUtils.getDefaultSmsSubscriptionId(): not supported before L MR1"); + return ParticipantData.DEFAULT_SELF_SUB_ID; + } + + @Override + public boolean getHasPreferredSmsSim() { + // SIM selection is not supported pre-L_MR1. + return true; + } + + @Override + public int getActiveSubscriptionCount() { + return hasSim() ? 1 : 0; + } + + @Override + public int getEffectiveIncomingSubIdFromSystem(Intent intent, String extraName) { + // Pre-L_MR1 always returns the default id + return ParticipantData.DEFAULT_SELF_SUB_ID; + } + + @Override + public int getSubIdFromTelephony(Cursor cursor, int subIdIndex) { + // No subscription_id column before L_MR1 + return ParticipantData.DEFAULT_SELF_SUB_ID; + } + + @Override + @SuppressWarnings("deprecation") + public boolean isDataRoamingEnabled() { + boolean dataRoamingEnabled = false; + final ContentResolver cr = mContext.getContentResolver(); + if (OsUtil.isAtLeastJB_MR1()) { + dataRoamingEnabled = + (Settings.Global.getInt(cr, Settings.Global.DATA_ROAMING, 0) != 0); + } else { + dataRoamingEnabled = + (Settings.System.getInt(cr, Settings.System.DATA_ROAMING, 0) != 0); + } + return dataRoamingEnabled; + } + + @Override + public boolean isMobileDataEnabled() { + boolean mobileDataEnabled = false; + try { + final Class cmClass = mConnectivityManager.getClass(); + final Method method = cmClass.getDeclaredMethod("getMobileDataEnabled"); + method.setAccessible(true); // Make the method callable + // get the setting for "mobile data" + mobileDataEnabled = (Boolean) method.invoke(mConnectivityManager); + } catch (final Exception e) { + LogUtil.e(TAG, "PhoneUtil.isMobileDataEnabled: system api not found", e); + } + return mobileDataEnabled; + } + + @Override + public HashSet getNormalizedSelfNumbers() { + final HashSet numbers = new HashSet<>(); + numbers.add(getCanonicalForSelf(true/*allowOverride*/)); + return numbers; + } + } + + /** + * The PhoneUtils class for L_MR1 + */ + public static class PhoneUtilsLMR1 extends PhoneUtils implements LMr1 { + private final SubscriptionManager mSubscriptionManager; + + public PhoneUtilsLMR1(final int subId) { + super(subId); + mSubscriptionManager = SubscriptionManager.from(Factory.get().getApplicationContext()); + } + + @Override + public String getSimCountry() { + final SubscriptionInfo subInfo = getActiveSubscriptionInfo(); + if (subInfo != null) { + final String country = subInfo.getCountryIso(); + if (TextUtils.isEmpty(country)) { + return null; + } + return country.toUpperCase(); + } + return null; + } + + @Override + public int getSimSlotCount() { + return mSubscriptionManager.getActiveSubscriptionInfoCountMax(); + } + + @Override + public String getCarrierName() { + final SubscriptionInfo subInfo = getActiveSubscriptionInfo(); + if (subInfo != null) { + final CharSequence displayName = subInfo.getDisplayName(); + if (!TextUtils.isEmpty(displayName)) { + return displayName.toString(); + } + final CharSequence carrierName = subInfo.getCarrierName(); + if (carrierName != null) { + return carrierName.toString(); + } + } + return null; + } + + @Override + public boolean hasSim() { + return mSubscriptionManager.getActiveSubscriptionInfoCount() > 0; + } + + @Override + public boolean isRoaming() { + return mSubscriptionManager.isNetworkRoaming(mSubId); + } + + @Override + public int[] getMccMnc() { + int mcc = 0; + int mnc = 0; + final SubscriptionInfo subInfo = getActiveSubscriptionInfo(); + if (subInfo != null) { + mcc = subInfo.getMcc(); + mnc = subInfo.getMnc(); + } + return new int[]{mcc, mnc}; + } + + @Override + public String getSimOperatorNumeric() { + // For L_MR1 we return the canonicalized (xxxxxx) string + return getMccMncString(getMccMnc()); + } + + @Override + public String getSelfRawNumber(final boolean allowOverride) { + if (allowOverride) { + final String userDefinedNumber = getNumberFromPrefs(mContext, mSubId); + if (!TextUtils.isEmpty(userDefinedNumber)) { + return userDefinedNumber; + } + } + + final SubscriptionInfo subInfo = getActiveSubscriptionInfo(); + if (subInfo != null) { + String phoneNumber = subInfo.getNumber(); + if (TextUtils.isEmpty(phoneNumber) && LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + LogUtil.d(TAG, "SubscriptionInfo phone number for self is empty!"); + } + return phoneNumber; + } + LogUtil.w(TAG, "PhoneUtils.getSelfRawNumber: subInfo is null for " + mSubId); + throw new IllegalStateException("No active subscription"); + } + + @Override + public SubscriptionInfo getActiveSubscriptionInfo() { + try { + final SubscriptionInfo subInfo = + mSubscriptionManager.getActiveSubscriptionInfo(mSubId); + if (subInfo == null) { + if (LogUtil.isLoggable(TAG, LogUtil.DEBUG)) { + // This is possible if the sub id is no longer available. + LogUtil.d(TAG, "PhoneUtils.getActiveSubscriptionInfo(): empty sub info for " + + mSubId); + } + } + return subInfo; + } catch (Exception e) { + LogUtil.e(TAG, "PhoneUtils.getActiveSubscriptionInfo: system exception for " + + mSubId, e); + } + return null; + } + + @Override + public List getActiveSubscriptionInfoList() { + final List subscriptionInfos = + mSubscriptionManager.getActiveSubscriptionInfoList(); + if (subscriptionInfos != null) { + return subscriptionInfos; + } + return EMPTY_SUBSCRIPTION_LIST; + } + + @Override + public int getEffectiveSubId(int subId) { + if (subId == ParticipantData.DEFAULT_SELF_SUB_ID) { + return getDefaultSmsSubscriptionId(); + } + return subId; + } + + @Override + public void registerOnSubscriptionsChangedListener( + SubscriptionManager.OnSubscriptionsChangedListener listener) { + mSubscriptionManager.addOnSubscriptionsChangedListener(listener); + } + + @Override + public SmsManager getSmsManager() { + return SmsManager.getSmsManagerForSubscriptionId(mSubId); + } + + @Override + public int getDefaultSmsSubscriptionId() { + final int systemDefaultSubId = SmsManager.getDefaultSmsSubscriptionId(); + if (systemDefaultSubId < 0) { + // Always use -1 for any negative subId from system + return ParticipantData.DEFAULT_SELF_SUB_ID; + } + return systemDefaultSubId; + } + + @Override + public boolean getHasPreferredSmsSim() { + return getDefaultSmsSubscriptionId() != ParticipantData.DEFAULT_SELF_SUB_ID; + } + + @Override + public int getActiveSubscriptionCount() { + return mSubscriptionManager.getActiveSubscriptionInfoCount(); + } + + @Override + public int getEffectiveIncomingSubIdFromSystem(Intent intent, String extraName) { + return getEffectiveIncomingSubIdFromSystem(intent.getIntExtra(extraName, + ParticipantData.DEFAULT_SELF_SUB_ID)); + } + + private int getEffectiveIncomingSubIdFromSystem(int subId) { + if (subId < 0) { + if (mSubscriptionManager.getActiveSubscriptionInfoCount() > 1) { + // For multi-SIM device, we can not decide which SIM to use if system + // does not know either. So just make it the invalid sub id. + return ParticipantData.DEFAULT_SELF_SUB_ID; + } + // For single-SIM device, it must come from the only SIM we have + return getDefaultSmsSubscriptionId(); + } + return subId; + } + + @Override + public int getSubIdFromTelephony(Cursor cursor, int subIdIndex) { + return getEffectiveIncomingSubIdFromSystem(cursor.getInt(subIdIndex)); + } + + @Override + public boolean isDataRoamingEnabled() { + final SubscriptionInfo subInfo = getActiveSubscriptionInfo(); + if (subInfo == null) { + // There is nothing we can do if system give us empty sub info + LogUtil.e(TAG, "PhoneUtils.isDataRoamingEnabled: system return empty sub info for " + + mSubId); + return false; + } + return subInfo.getDataRoaming() != SubscriptionManager.DATA_ROAMING_DISABLE; + } + + @Override + public boolean isMobileDataEnabled() { + boolean mobileDataEnabled = false; + try { + final Class cmClass = mTelephonyManager.getClass(); + final Method method = cmClass.getDeclaredMethod("getDataEnabled", Integer.TYPE); + method.setAccessible(true); // Make the method callable + // get the setting for "mobile data" + mobileDataEnabled = (Boolean) method.invoke( + mTelephonyManager, Integer.valueOf(mSubId)); + } catch (final Exception e) { + LogUtil.e(TAG, "PhoneUtil.isMobileDataEnabled: system api not found", e); + } + return mobileDataEnabled; + + } + + @Override + public HashSet getNormalizedSelfNumbers() { + final HashSet numbers = new HashSet<>(); + for (SubscriptionInfo info : getActiveSubscriptionInfoList()) { + numbers.add(PhoneUtils.get(info.getSubscriptionId()).getCanonicalForSelf( + true/*allowOverride*/)); + } + return numbers; + } + } + + /** + * A convenient get() method that uses the default SIM. Use this when SIM is + * not relevant, e.g. isDefaultSmsApp + * + * @return an instance of PhoneUtils for default SIM + */ + public static PhoneUtils getDefault() { + return Factory.get().getPhoneUtils(ParticipantData.DEFAULT_SELF_SUB_ID); + } + + /** + * Get an instance of PhoneUtils associated with a specific SIM, which is also platform + * specific. + * + * @param subId The SIM's subscription ID + * @return the instance + */ + public static PhoneUtils get(int subId) { + return Factory.get().getPhoneUtils(subId); + } + + public LMr1 toLMr1() { + if (OsUtil.isAtLeastL_MR1()) { + return (LMr1) this; + } else { + Assert.fail("PhoneUtils.toLMr1(): invalid OS version"); + return null; + } + } + + /** + * Check if this device supports SMS + * + * @return true if SMS is supported, false otherwise + */ + public boolean isSmsCapable() { + return mTelephonyManager.isSmsCapable(); + } + + /** + * Check if this device supports voice calling + * + * @return true if voice calling is supported, false otherwise + */ + public boolean isVoiceCapable() { + return mTelephonyManager.isVoiceCapable(); + } + + /** + * Get the ISO country code from system locale setting + * + * @return the ISO country code from system locale + */ + private static String getLocaleCountry() { + final String country = Locale.getDefault().getCountry(); + if (TextUtils.isEmpty(country)) { + return null; + } + return country.toUpperCase(); + } + + /** + * Get ISO country code from the SIM, if not available, fall back to locale + * + * @return SIM or locale ISO country code + */ + public String getSimOrDefaultLocaleCountry() { + String country = getSimCountry(); + if (country == null) { + country = getLocaleCountry(); + } + return country; + } + + // Get or set the cache of canonicalized phone numbers for a specific country + private static ArrayMap getOrAddCountryMapInCacheLocked(String country) { + if (country == null) { + country = ""; + } + ArrayMap countryMap = sCanonicalPhoneNumberCache.get(country); + if (countryMap == null) { + countryMap = new ArrayMap<>(); + sCanonicalPhoneNumberCache.put(country, countryMap); + } + return countryMap; + } + + // Get canonicalized phone number from cache + private static String getCanonicalFromCache(final String phoneText, String country) { + synchronized (sCanonicalPhoneNumberCache) { + final ArrayMap countryMap = getOrAddCountryMapInCacheLocked(country); + return countryMap.get(phoneText); + } + } + + // Put canonicalized phone number into cache + private static void putCanonicalToCache(final String phoneText, String country, + final String canonical) { + synchronized (sCanonicalPhoneNumberCache) { + final ArrayMap countryMap = getOrAddCountryMapInCacheLocked(country); + countryMap.put(phoneText, canonical); + } + } + + /** + * Utility method to parse user input number into standard E164 number. + * + * @param phoneText Phone number text as input by user. + * @param country ISO country code based on which to parse the number. + * @return E164 phone number. Returns null in case parsing failed. + */ + private static String getValidE164Number(final String phoneText, final String country) { + final PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance(); + try { + final PhoneNumber phoneNumber = phoneNumberUtil.parse(phoneText, country); + if (phoneNumber != null && phoneNumberUtil.isValidNumber(phoneNumber)) { + return phoneNumberUtil.format(phoneNumber, PhoneNumberFormat.E164); + } + } catch (final NumberParseException e) { + LogUtil.e(TAG, "PhoneUtils.getValidE164Number(): Not able to parse phone number " + + LogUtil.sanitizePII(phoneText) + " for country " + country); + } + return null; + } + + /** + * Canonicalize phone number using system locale country + * + * @param phoneText The phone number to canonicalize + * @return the canonicalized number + */ + public String getCanonicalBySystemLocale(final String phoneText) { + return getCanonicalByCountry(phoneText, getLocaleCountry()); + } + + /** + * Canonicalize phone number using SIM's country, may fall back to system locale country + * if SIM country can not be obtained + * + * @param phoneText The phone number to canonicalize + * @return the canonicalized number + */ + public String getCanonicalBySimLocale(final String phoneText) { + return getCanonicalByCountry(phoneText, getSimOrDefaultLocaleCountry()); + } + + /** + * Canonicalize phone number using a country code. + * This uses an internal cache per country to speed up. + * + * @param phoneText The phone number to canonicalize + * @param country The ISO country code to use + * @return the canonicalized number, or the original number if can't be parsed + */ + private String getCanonicalByCountry(final String phoneText, final String country) { + Assert.notNull(phoneText); + + String canonicalNumber = getCanonicalFromCache(phoneText, country); + if (canonicalNumber != null) { + return canonicalNumber; + } + canonicalNumber = getValidE164Number(phoneText, country); + if (canonicalNumber == null) { + // If we can't normalize this number, we just use the display string number. + // This is possible for short codes and other non-localizable numbers. + canonicalNumber = phoneText; + } + putCanonicalToCache(phoneText, country, canonicalNumber); + return canonicalNumber; + } + + /** + * Canonicalize the self (per SIM) phone number + * + * @param allowOverride whether to use the override number in app settings + * @return the canonicalized self phone number + */ + public String getCanonicalForSelf(final boolean allowOverride) { + String selfNumber = null; + try { + selfNumber = getSelfRawNumber(allowOverride); + } catch (IllegalStateException e) { + // continue; + } + if (selfNumber == null) { + return ""; + } + return getCanonicalBySimLocale(selfNumber); + } + + /** + * Get the SIM's phone number in NATIONAL format with only digits, used in sending + * as LINE1NOCOUNTRYCODE macro in mms_config + * + * @return all digits national format number of the SIM + */ + public String getSimNumberNoCountryCode() { + String selfNumber = null; + try { + selfNumber = getSelfRawNumber(false/*allowOverride*/); + } catch (IllegalStateException e) { + // continue + } + if (selfNumber == null) { + selfNumber = ""; + } + final String country = getSimCountry(); + final PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance(); + try { + final PhoneNumber phoneNumber = phoneNumberUtil.parse(selfNumber, country); + if (phoneNumber != null && phoneNumberUtil.isValidNumber(phoneNumber)) { + return phoneNumberUtil + .format(phoneNumber, PhoneNumberFormat.NATIONAL) + .replaceAll("\\D", ""); + } + } catch (final NumberParseException e) { + LogUtil.e(TAG, "PhoneUtils.getSimNumberNoCountryCode(): Not able to parse phone number " + + LogUtil.sanitizePII(selfNumber) + " for country " + country); + } + return selfNumber; + + } + + /** + * Format a phone number for displaying, using system locale country. + * If the country code matches between the system locale and the input phone number, + * it will be formatted into NATIONAL format, otherwise, the INTERNATIONAL format + * + * @param phoneText The original phone text + * @return formatted number + */ + public String formatForDisplay(final String phoneText) { + // Only format a valid number which length >=6 + if (TextUtils.isEmpty(phoneText) || + phoneText.replaceAll("\\D", "").length() < MINIMUM_PHONE_NUMBER_LENGTH_TO_FORMAT) { + return phoneText; + } + final PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance(); + final String systemCountry = getLocaleCountry(); + final int systemCountryCode = phoneNumberUtil.getCountryCodeForRegion(systemCountry); + try { + final PhoneNumber parsedNumber = phoneNumberUtil.parse(phoneText, systemCountry); + final PhoneNumberFormat phoneNumberFormat = + (systemCountryCode > 0 && parsedNumber.getCountryCode() == systemCountryCode) ? + PhoneNumberFormat.NATIONAL : PhoneNumberFormat.INTERNATIONAL; + return phoneNumberUtil.format(parsedNumber, phoneNumberFormat); + } catch (NumberParseException e) { + LogUtil.e(TAG, "PhoneUtils.formatForDisplay: invalid phone number " + + LogUtil.sanitizePII(phoneText) + " with country " + systemCountry); + return phoneText; + } + } + + /** + * Is Messaging the default SMS app? + * - On KLP+ this checks the system setting. + * - On JB (and below) this always returns true, since the setting was added in KLP. + */ + public boolean isDefaultSmsApp() { + if (OsUtil.isAtLeastKLP()) { + final String configuredApplication = Telephony.Sms.getDefaultSmsPackage(mContext); + return mContext.getPackageName().equals(configuredApplication); + } + return true; + } + + /** + * Get default SMS app package name + * + * @return the package name of default SMS app + */ + public String getDefaultSmsApp() { + if (OsUtil.isAtLeastKLP()) { + return Telephony.Sms.getDefaultSmsPackage(mContext); + } + return null; + } + + /** + * Determines if SMS is currently enabled on this device. + * - Device must support SMS + * - On KLP+ we must be set as the default SMS app + */ + public boolean isSmsEnabled() { + return isSmsCapable() && isDefaultSmsApp(); + } + + /** + * Returns the name of the default SMS app, or the empty string if there is + * an error or there is no default app (e.g. JB and below). + */ + public String getDefaultSmsAppLabel() { + if (OsUtil.isAtLeastKLP()) { + final String packageName = Telephony.Sms.getDefaultSmsPackage(mContext); + final PackageManager pm = mContext.getPackageManager(); + try { + final ApplicationInfo appInfo = pm.getApplicationInfo(packageName, 0); + return pm.getApplicationLabel(appInfo).toString(); + } catch (NameNotFoundException e) { + // Fall through and return empty string + } + } + return ""; + } + + /** + * Gets the state of Airplane Mode. + * + * @return true if enabled. + */ + @SuppressWarnings("deprecation") + public boolean isAirplaneModeOn() { + if (OsUtil.isAtLeastJB_MR1()) { + return Settings.Global.getInt(mContext.getContentResolver(), + Settings.Global.AIRPLANE_MODE_ON, 0) != 0; + } else { + return Settings.System.getInt(mContext.getContentResolver(), + Settings.System.AIRPLANE_MODE_ON, 0) != 0; + } + } + + public static String getMccMncString(int[] mccmnc) { + if (mccmnc == null || mccmnc.length != 2) { + return "000000"; + } + return String.format("%03d%03d", mccmnc[0], mccmnc[1]); + } + + public static String canonicalizeMccMnc(final String mcc, final String mnc) { + try { + return String.format("%03d%03d", Integer.parseInt(mcc), Integer.parseInt(mnc)); + } catch (final NumberFormatException e) { + // Return invalid as is + LogUtil.w(TAG, "canonicalizeMccMnc: invalid mccmnc:" + mcc + " ," + mnc); + } + return mcc + mnc; + } + + /** + * Returns whether the given destination is valid for sending SMS/MMS message. + */ + public static boolean isValidSmsMmsDestination(final String destination) { + return PhoneNumberUtils.isWellFormedSmsAddress(destination) || + MmsSmsUtils.isEmailAddress(destination); + } + + public interface SubscriptionRunnable { + void runForSubscription(int subId); + } + + /** + * A convenience method for iterating through all active subscriptions + * + * @param runnable a {@link SubscriptionRunnable} for performing work on each subscription. + */ + public static void forEachActiveSubscription(final SubscriptionRunnable runnable) { + if (OsUtil.isAtLeastL_MR1()) { + final List subscriptionList = + getDefault().toLMr1().getActiveSubscriptionInfoList(); + for (final SubscriptionInfo subscriptionInfo : subscriptionList) { + runnable.runForSubscription(subscriptionInfo.getSubscriptionId()); + } + } else { + runnable.runForSubscription(ParticipantData.DEFAULT_SELF_SUB_ID); + } + } + + private static String getNumberFromPrefs(final Context context, final int subId) { + final BuglePrefs prefs = BuglePrefs.getSubscriptionPrefs(subId); + final String mmsPhoneNumberPrefKey = + context.getString(R.string.mms_phone_number_pref_key); + final String userDefinedNumber = prefs.getString(mmsPhoneNumberPrefKey, null); + if (!TextUtils.isEmpty(userDefinedNumber)) { + return userDefinedNumber; + } + return null; + } +} diff --git a/src/com/android/messaging/util/RingtoneUtil.java b/src/com/android/messaging/util/RingtoneUtil.java new file mode 100644 index 0000000..a7facfb --- /dev/null +++ b/src/com/android/messaging/util/RingtoneUtil.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import android.content.Context; +import android.net.Uri; +import android.provider.Settings; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.R; + +public class RingtoneUtil { + /** + * Return a ringtone Uri for the string representation passed in. Use the app + * and system defaults as fallbacks + * @param ringtoneString is the ringtone to resolve + * @return the Uri of the ringtone or the fallback ringtone + */ + public static Uri getNotificationRingtoneUri(String ringtoneString) { + if (ringtoneString == null) { + // No override specified, fall back to system-wide setting. + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + final Context context = Factory.get().getApplicationContext(); + final String prefKey = context.getString(R.string.notification_sound_pref_key); + ringtoneString = prefs.getString(prefKey, null); + } + + if (!TextUtils.isEmpty(ringtoneString)) { + // We have set a value, even if it is the default Uri at some point + return Uri.parse(ringtoneString); + } else if (ringtoneString == null) { + // We have no setting specified (== null), so we default to the system default + return Settings.System.DEFAULT_NOTIFICATION_URI; + } else { + // An empty string (== "") here is the result of selecting "None" as the ringtone + return null; + } + } +} diff --git a/src/com/android/messaging/util/SafeAsyncTask.java b/src/com/android/messaging/util/SafeAsyncTask.java new file mode 100644 index 0000000..1cce6e9 --- /dev/null +++ b/src/com/android/messaging/util/SafeAsyncTask.java @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.content.Intent; +import android.os.AsyncTask; +import android.os.Debug; +import android.os.SystemClock; + +import com.android.messaging.Factory; +import com.android.messaging.util.Assert.RunsOnAnyThread; + +/** + * Wrapper class which provides explicit API for: + *
    + *
  1. Threading policy choice - Users of this class should use the explicit API instead of + * {@link #execute} which uses different threading policy on different OS versions. + *
  2. Enforce creation on main thread as required by AsyncTask + *
  3. Enforce that the background task does not take longer than expected. + *
+ */ +public abstract class SafeAsyncTask + extends AsyncTask { + private static final long DEFAULT_MAX_EXECUTION_TIME_MILLIS = 10 * 1000; // 10 seconds + + /** This is strongly discouraged as it can block other AsyncTasks indefinitely. */ + public static final long UNBOUNDED_TIME = Long.MAX_VALUE; + + private static final String WAKELOCK_ID = "bugle_safe_async_task_wakelock"; + protected static final int WAKELOCK_OP = 1000; + private static WakeLockHelper sWakeLock = new WakeLockHelper(WAKELOCK_ID); + + private final long mMaxExecutionTimeMillis; + private final boolean mCancelExecutionOnTimeout; + private boolean mThreadPoolRequested; + + public SafeAsyncTask() { + this(DEFAULT_MAX_EXECUTION_TIME_MILLIS, false); + } + + public SafeAsyncTask(final long maxTimeMillis) { + this(maxTimeMillis, false); + } + + /** + * @param maxTimeMillis maximum expected time for the background operation. This is just + * a diagnostic tool to catch unexpectedly long operations. If an operation does take + * longer than expected, it is fine to increase this argument. If the value is larger + * than a minute, you should consider using a dedicated thread so as not to interfere + * with other AsyncTasks. + * + *

Use {@link #UNBOUNDED_TIME} if you do not know the maximum expected time. This + * is strongly discouraged as it can block other AsyncTasks indefinitely. + * + * @param cancelExecutionOnTimeout whether to attempt to cancel the task execution on timeout. + * If this is set, at execution timeout we will call cancel(), so doInBackgroundTimed() + * should periodically check if the task is to be cancelled and finish promptly if + * possible, and handle the cancel event in onCancelled(). Also, at the end of execution + * we will not crash the execution if it went over limit since we explicitly canceled it. + */ + public SafeAsyncTask(final long maxTimeMillis, final boolean cancelExecutionOnTimeout) { + Assert.isMainThread(); // AsyncTask has to be created on the main thread + mMaxExecutionTimeMillis = maxTimeMillis; + mCancelExecutionOnTimeout = cancelExecutionOnTimeout; + } + + public final SafeAsyncTask executeOnThreadPool( + final Params... params) { + Assert.isMainThread(); // AsyncTask requires this + mThreadPoolRequested = true; + executeOnExecutor(THREAD_POOL_EXECUTOR, params); + return this; + } + + protected abstract Result doInBackgroundTimed(final Params... params); + + @Override + protected final Result doInBackground(final Params... params) { + // This enforces that executeOnThreadPool was called, not execute. Ideally, we would + // make execute throw an exception, but since it is final, we cannot override it. + Assert.isTrue(mThreadPoolRequested); + + if (mCancelExecutionOnTimeout) { + ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() { + @Override + public void run() { + if (getStatus() == Status.RUNNING) { + // Cancel the task if it's still running. + LogUtil.w(LogUtil.BUGLE_TAG, String.format("%s timed out and is canceled", + this)); + cancel(true /* mayInterruptIfRunning */); + } + } + }, mMaxExecutionTimeMillis); + } + + final long startTime = SystemClock.elapsedRealtime(); + try { + return doInBackgroundTimed(params); + } finally { + final long executionTime = SystemClock.elapsedRealtime() - startTime; + if (executionTime > mMaxExecutionTimeMillis) { + LogUtil.w(LogUtil.BUGLE_TAG, String.format("%s took %dms", this, executionTime)); + // Don't crash if debugger is attached or if we are asked to cancel on timeout. + if (!Debug.isDebuggerConnected() && !mCancelExecutionOnTimeout) { + Assert.fail(this + " took too long"); + } + } + } + + } + + @Override + protected void onPostExecute(final Result result) { + // No need to use AsyncTask at all if there is no onPostExecute + Assert.fail("Use SafeAsyncTask.executeOnThreadPool"); + } + + /** + * This provides a way for people to run async tasks but without onPostExecute. + * This can be called on any thread. + * + * Run code in a thread using AsyncTask's thread pool. + * + * To enable wakelock during the execution, see {@link #executeOnThreadPool(Runnable, boolean)} + * + * @param runnable The Runnable to execute asynchronously + */ + @RunsOnAnyThread + public static void executeOnThreadPool(final Runnable runnable) { + executeOnThreadPool(runnable, false); + } + + /** + * This provides a way for people to run async tasks but without onPostExecute. + * This can be called on any thread. + * + * Run code in a thread using AsyncTask's thread pool. + * + * @param runnable The Runnable to execute asynchronously + * @param withWakeLock when set, a wake lock will be held for the duration of the runnable + * execution + */ + public static void executeOnThreadPool(final Runnable runnable, final boolean withWakeLock) { + if (withWakeLock) { + final Intent intent = new Intent(); + sWakeLock.acquire(Factory.get().getApplicationContext(), intent, WAKELOCK_OP); + THREAD_POOL_EXECUTOR.execute(new Runnable() { + @Override + public void run() { + try { + runnable.run(); + } finally { + sWakeLock.release(intent, WAKELOCK_OP); + } + } + }); + } else { + THREAD_POOL_EXECUTOR.execute(runnable); + } + } +} diff --git a/src/com/android/messaging/util/SwitchCompatUtils.java b/src/com/android/messaging/util/SwitchCompatUtils.java new file mode 100644 index 0000000..b5d1ed5 --- /dev/null +++ b/src/com/android/messaging/util/SwitchCompatUtils.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.support.v7.graphics.drawable.DrawableWrapper; +import android.support.v7.widget.SwitchCompat; +import android.util.TypedValue; + +/* Most methods in this file are copied from + * v7/appcompat/src/android/support/v7/internal/widget/TintManager.java. It would be better if + * we could have just extended the TintManager but this is a final class that we do not have + * access to. */ + +/** + * Util methods for the SwitchCompat widget + */ +public class SwitchCompatUtils { + /** + * Given a color and a SwitchCompat view, updates the SwitchCompat to appear with the appropiate + * color when enabled and checked + */ + public static void updateSwitchCompatColor(SwitchCompat switchCompat, final int color) { + final Context context = switchCompat.getContext(); + final TypedValue typedValue = new TypedValue(); + + switchCompat.setThumbDrawable(getColorTintedDrawable(switchCompat.getThumbDrawable(), + getSwitchThumbColorStateList(context, color, typedValue), + PorterDuff.Mode.MULTIPLY)); + + switchCompat.setTrackDrawable(getColorTintedDrawable(switchCompat.getTrackDrawable(), + getSwitchTrackColorStateList(context, color, typedValue), PorterDuff.Mode.SRC_IN)); + } + + private static Drawable getColorTintedDrawable(Drawable oldDrawable, + final ColorStateList colorStateList, final PorterDuff.Mode mode) { + final int[] thumbState = oldDrawable.isStateful() ? oldDrawable.getState() : null; + if (oldDrawable instanceof DrawableWrapper) { + oldDrawable = ((DrawableWrapper) oldDrawable).getWrappedDrawable(); + } + final Drawable newDrawable = new TintDrawableWrapper(oldDrawable, colorStateList, mode); + if (thumbState != null) { + newDrawable.setState(thumbState); + } + return newDrawable; + } + + private static ColorStateList getSwitchThumbColorStateList(final Context context, + final int color, final TypedValue typedValue) { + final int[][] states = new int[3][]; + final int[] colors = new int[3]; + int i = 0; + // Disabled state + states[i] = new int[] { -android.R.attr.state_enabled }; + colors[i] = getColor(Color.parseColor("#ffbdbdbd"), 1f); + i++; + states[i] = new int[] { android.R.attr.state_checked }; + colors[i] = color; + i++; + // Default enabled state + states[i] = new int[0]; + colors[i] = getThemeAttrColor(context, typedValue, + android.support.v7.appcompat.R.attr.colorSwitchThumbNormal); + i++; + return new ColorStateList(states, colors); + } + + private static ColorStateList getSwitchTrackColorStateList(final Context context, + final int color, final TypedValue typedValue) { + final int[][] states = new int[3][]; + final int[] colors = new int[3]; + int i = 0; + // Disabled state + states[i] = new int[] { -android.R.attr.state_enabled }; + colors[i] = getThemeAttrColor(context, typedValue, android.R.attr.colorForeground, 0.1f); + i++; + states[i] = new int[] { android.R.attr.state_checked }; + colors[i] = getColor(color, 0.3f); + i++; + // Default enabled state + states[i] = new int[0]; + colors[i] = getThemeAttrColor(context, typedValue, android.R.attr.colorForeground, 0.3f); + i++; + return new ColorStateList(states, colors); + } + + private static int getThemeAttrColor(final Context context, final TypedValue typedValue, + final int attr) { + if (context.getTheme().resolveAttribute(attr, typedValue, true)) { + if (typedValue.type >= TypedValue.TYPE_FIRST_INT + && typedValue.type <= TypedValue.TYPE_LAST_INT) { + return typedValue.data; + } else if (typedValue.type == TypedValue.TYPE_STRING) { + return context.getResources().getColor(typedValue.resourceId); + } + } + return 0; + } + + private static int getThemeAttrColor(final Context context, final TypedValue typedValue, + final int attr, final float alpha) { + final int color = getThemeAttrColor(context, typedValue, attr); + return getColor(color, alpha); + } + + private static int getColor(int color, float alpha) { + final int originalAlpha = Color.alpha(color); + // Return the color, multiplying the original alpha by the disabled value + return (color & 0x00ffffff) | (Math.round(originalAlpha * alpha) << 24); + } +} diff --git a/src/com/android/messaging/util/TextUtil.java b/src/com/android/messaging/util/TextUtil.java new file mode 100644 index 0000000..b240396 --- /dev/null +++ b/src/com/android/messaging/util/TextUtil.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import android.support.annotation.Nullable; + +public class TextUtil { + /** + * Returns true if the string is empty, null or only whitespace. + */ + public static boolean isAllWhitespace(@Nullable String string) { + if (string == null || string.isEmpty()) { + return true; + } + + for (int i = 0; i < string.length(); ++i) { + if (!Character.isWhitespace(string.charAt(i))) { + return false; + } + } + + return true; + } + + /** + * Taken from PhoneNumberUtils, where it is only available in API 21+ Replaces all unicode + * (e.g. Arabic, Persian) digits with their decimal digit equivalents. + * + * @param number the number to perform the replacement on. + * @return the replaced number. + */ + public static String replaceUnicodeDigits(String number) { + StringBuilder normalizedDigits = new StringBuilder(number.length()); + for (char c : number.toCharArray()) { + int digit = Character.digit(c, 10); + if (digit != -1) { + normalizedDigits.append(digit); + } else { + normalizedDigits.append(c); + } + } + return normalizedDigits.toString(); + } + + /** + * Appends text to the stringBuilder. + * If stringBuilder already has content, separator is prepended to create a separator between + * entries. + * @param stringBuilder The stringBuilder to add to + * @param text The text to append + * @param separator The separator to add if there is already text, typically "," or "\n" + */ + public static void appendWithSeparator(final StringBuilder stringBuilder, final String text, + final String separator) { + if (stringBuilder.length() > 0) { + stringBuilder.append(separator); + } + stringBuilder.append(text); + } +} diff --git a/src/com/android/messaging/util/ThreadUtil.java b/src/com/android/messaging/util/ThreadUtil.java new file mode 100644 index 0000000..3b935d8 --- /dev/null +++ b/src/com/android/messaging/util/ThreadUtil.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.os.Handler; +import android.os.Looper; + +public class ThreadUtil { + private static final Handler sHandler = new Handler(Looper.getMainLooper()); + + public static Handler getMainThreadHandler() { + return sHandler; + } +} diff --git a/src/com/android/messaging/util/TintDrawableWrapper.java b/src/com/android/messaging/util/TintDrawableWrapper.java new file mode 100644 index 0000000..e6ea4bd --- /dev/null +++ b/src/com/android/messaging/util/TintDrawableWrapper.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.support.v7.graphics.drawable.DrawableWrapper; + +/* + * This is directly copied from v7/appcompat/src/android/support/v7/internal/widget/TintManager.java + */ + +/** + * A {@link DrawableWrapper} which updates it's color filter using a {@link ColorStateList}. + */ +class TintDrawableWrapper extends DrawableWrapper { + private final ColorStateList mTintStateList; + private final PorterDuff.Mode mTintMode; + private int mCurrentColor; + public TintDrawableWrapper(Drawable drawable, ColorStateList tintStateList) { + this(drawable, tintStateList, PorterDuff.Mode.SRC_IN); + } + public TintDrawableWrapper(Drawable drawable, ColorStateList tintStateList, + PorterDuff.Mode tintMode) { + super(drawable); + mTintStateList = tintStateList; + mTintMode = tintMode; + } + @Override + public boolean isStateful() { + return (mTintStateList != null && mTintStateList.isStateful()) || super.isStateful(); + } + @Override + public boolean setState(int[] stateSet) { + boolean handled = super.setState(stateSet); + handled = updateTint(stateSet) || handled; + return handled; + } + private boolean updateTint(int[] state) { + if (mTintStateList != null) { + final int color = mTintStateList.getColorForState(state, mCurrentColor); + if (color != mCurrentColor) { + if (color != Color.TRANSPARENT) { + setColorFilter(color, mTintMode); + } else { + clearColorFilter(); + } + mCurrentColor = color; + return true; + } + } + return false; + } +} diff --git a/src/com/android/messaging/util/Trace.java b/src/com/android/messaging/util/Trace.java new file mode 100644 index 0000000..da1e87c --- /dev/null +++ b/src/com/android/messaging/util/Trace.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + + +import android.annotation.TargetApi; +import android.os.Build; + +/** + * Helper class for systrace (see http://developer.android.com/tools/help/systrace.html).

+ * To enable, set log.tag.Bugle_Trace (defined by {@link #TAG} to VERBOSE before + * the process starts.

+ * Note that this will run only on JBMR2 or later; on earlier platforms or if the log + * tag isn't set, calls to {@link #beginSection(String)} or {@link #endSection()} are no-ops.

+ * Internally, calls dispatch to either a class that actually does work or a class that doesn't. + * This avoids Dalvik complaining when it loads the class on earlier platforms that the + * opcodes aren't available, and, according to the Dalvik team, using vtable dispatching for + * something like this should be faster than if (OsUtil.isAtLeast...()) on each call. + */ +public final class Trace { + private static final String TAG = "Bugle_Trace"; + private abstract static class AbstractTrace { + abstract void beginSection(String sectionName); + abstract void endSection(); + } + + private static final AbstractTrace sTrace; + + // Static initializer to pick the correct trace class to handle tracing. + static { + // Use android.util.Log instead of LogUtil here to avoid pulling in Gservices + // too early in app startup. + if (OsUtil.isAtLeastJB_MR2() && + android.util.Log.isLoggable(TAG, android.util.Log.VERBOSE)) { + sTrace = new TraceJBMR2(); + } else { + sTrace = new TraceShim(); + } + } + + /** + * Writes a trace message to indicate that a given section of code has begun. This call must + * be followed by a corresponding call to {@link #endSection()} on the same thread. + * + *

At this time the vertical bar character '|', newline character '\n', and + * null character '\0' are used internally by the tracing mechanism. If sectionName contains + * these characters they will be replaced with a space character in the trace. + * + * @param sectionName The name of the code section to appear in the trace. This may be at + * most 127 Unicode code units long. + */ + public static void beginSection(String sectionName) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "beginSection() " + sectionName); + } + sTrace.beginSection(sectionName); + } + + /** + * Writes a trace message to indicate that a given section of code has ended. This call must + * be preceeded by a corresponding call to {@link #beginSection(String)}. Calling this method + * will mark the end of the most recently begun section of code, so care must be taken to + * ensure that beginSection / endSection pairs are properly nested and called from the same + * thread. + */ + public static void endSection() { + sTrace.endSection(); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "endSection()"); + } + } + + /** + * Internal class that we use if we really did enable tracing. + */ + @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) + private static final class TraceJBMR2 extends AbstractTrace { + @Override + void beginSection(String sectionName) { + android.os.Trace.beginSection(sectionName); + } + + @Override + void endSection() { + android.os.Trace.endSection(); + } + } + + /** + * Dummy class that we use if we aren't really tracing. + */ + private static final class TraceShim extends AbstractTrace { + @Override + void beginSection(String sectionName) { + } + + @Override + void endSection() { + } + } +} diff --git a/src/com/android/messaging/util/Typefaces.java b/src/com/android/messaging/util/Typefaces.java new file mode 100644 index 0000000..eb8562c --- /dev/null +++ b/src/com/android/messaging/util/Typefaces.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import android.graphics.Typeface; + +/** + * Provides access to typefaces used by code. Specially important for typefaces coming from assets, + * which appear (from platform code inspection) to not be cached. + * Note: Considered making this a singleton provided by factory/appcontext, but seemed too simple, + * not worth stubbing. + */ +public class Typefaces { + private static Typeface sRobotoBold; + private static Typeface sRobotoNormal; + + public static Typeface getRobotoBold() { + Assert.isMainThread(); + if (sRobotoBold == null) { + sRobotoBold = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD); + } + return sRobotoBold; + } + + public static Typeface getRobotoNormal() { + Assert.isMainThread(); + if (sRobotoNormal == null) { + sRobotoNormal = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL); + } + return sRobotoNormal; + } +} diff --git a/src/com/android/messaging/util/UiUtils.java b/src/com/android/messaging/util/UiUtils.java new file mode 100644 index 0000000..84fe353 --- /dev/null +++ b/src/com/android/messaging/util/UiUtils.java @@ -0,0 +1,438 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.app.Activity; +import android.content.Context; +import android.content.ContextWrapper; +import android.content.pm.ActivityInfo; +import android.content.res.Configuration; +import android.graphics.Color; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.app.ActionBar; +import android.support.v7.app.ActionBarActivity; +import android.text.Html; +import android.text.Spanned; +import android.text.TextPaint; +import android.text.TextUtils; +import android.text.style.URLSpan; +import android.view.Gravity; +import android.view.Surface; +import android.view.View; +import android.view.View.OnLayoutChangeListener; +import android.view.animation.Animation; +import android.view.animation.Animation.AnimationListener; +import android.view.animation.Interpolator; +import android.view.animation.ScaleAnimation; +import android.widget.RemoteViews; +import android.widget.Toast; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.ui.SnackBar; +import com.android.messaging.ui.SnackBar.Placement; +import com.android.messaging.ui.conversationlist.ConversationListActivity; +import com.android.messaging.ui.SnackBarInteraction; +import com.android.messaging.ui.SnackBarManager; +import com.android.messaging.ui.UIIntents; + +import java.lang.reflect.Field; +import java.util.List; + +public class UiUtils { + /** MediaPicker transition duration in ms */ + public static final int MEDIAPICKER_TRANSITION_DURATION = + getApplicationContext().getResources().getInteger( + R.integer.mediapicker_transition_duration); + /** Short transition duration in ms */ + public static final int ASYNCIMAGE_TRANSITION_DURATION = + getApplicationContext().getResources().getInteger( + R.integer.asyncimage_transition_duration); + /** Compose transition duration in ms */ + public static final int COMPOSE_TRANSITION_DURATION = + getApplicationContext().getResources().getInteger( + R.integer.compose_transition_duration); + /** Generic duration for revealing/hiding a view */ + public static final int REVEAL_ANIMATION_DURATION = + getApplicationContext().getResources().getInteger( + R.integer.reveal_view_animation_duration); + + public static final Interpolator DEFAULT_INTERPOLATOR = new CubicBezierInterpolator( + 0.4f, 0.0f, 0.2f, 1.0f); + + public static final Interpolator EASE_IN_INTERPOLATOR = new CubicBezierInterpolator( + 0.4f, 0.0f, 0.8f, 0.5f); + + public static final Interpolator EASE_OUT_INTERPOLATOR = new CubicBezierInterpolator( + 0.0f, 0.0f, 0.2f, 1f); + + /** Show a simple toast at the bottom */ + public static void showToastAtBottom(final int messageId) { + UiUtils.showToastAtBottom(getApplicationContext().getString(messageId)); + } + + /** Show a simple toast at the bottom */ + public static void showToastAtBottom(final String message) { + final Toast toast = Toast.makeText(getApplicationContext(), message, Toast.LENGTH_LONG); + toast.setGravity(Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL, 0, 0); + toast.show(); + } + + /** Show a simple toast at the default position */ + public static void showToast(final int messageId) { + final Toast toast = Toast.makeText(getApplicationContext(), + getApplicationContext().getString(messageId), Toast.LENGTH_LONG); + toast.setGravity(Gravity.CENTER_HORIZONTAL, 0, 0); + toast.show(); + } + + /** Show a simple toast at the default position */ + public static void showToast(final int pluralsMessageId, final int count) { + final Toast toast = Toast.makeText(getApplicationContext(), + getApplicationContext().getResources().getQuantityString(pluralsMessageId, count), + Toast.LENGTH_LONG); + toast.setGravity(Gravity.CENTER_HORIZONTAL, 0, 0); + toast.show(); + } + + public static void showSnackBar(final Context context, @NonNull final View parentView, + final String message, @Nullable final Runnable runnable, final int runnableLabel, + @Nullable final List interactions) { + Assert.notNull(context); + SnackBar.Action action = null; + switch (runnableLabel) { + case SnackBar.Action.SNACK_BAR_UNDO: + action = SnackBar.Action.createUndoAction(runnable); + break; + case SnackBar.Action.SNACK_BAR_RETRY: + action = SnackBar.Action.createRetryAction(runnable); + break; + default : + break; + } + + showSnackBarWithCustomAction(context, parentView, message, action, interactions, + null /* placement */); + } + + public static void showSnackBarWithCustomAction(final Context context, + @NonNull final View parentView, + @NonNull final String message, + @NonNull final SnackBar.Action action, + @Nullable final List interactions, + @Nullable final Placement placement) { + Assert.notNull(context); + Assert.isTrue(!TextUtils.isEmpty(message)); + Assert.notNull(action); + SnackBarManager.get() + .newBuilder(parentView) + .setText(message) + .setAction(action) + .withInteractions(interactions) + .withPlacement(placement) + .show(); + } + + /** + * Run the given runnable once after the next layout pass of the view. + */ + public static void doOnceAfterLayoutChange(final View view, final Runnable runnable) { + final OnLayoutChangeListener listener = new OnLayoutChangeListener() { + @Override + public void onLayoutChange(final View v, final int left, final int top, final int right, + final int bottom, final int oldLeft, final int oldTop, final int oldRight, + final int oldBottom) { + // Call the runnable outside the layout pass because very few actions are allowed in + // the layout pass + ThreadUtil.getMainThreadHandler().post(runnable); + view.removeOnLayoutChangeListener(this); + } + }; + view.addOnLayoutChangeListener(listener); + } + + public static boolean isLandscapeMode() { + return Factory.get().getApplicationContext().getResources().getConfiguration().orientation + == Configuration.ORIENTATION_LANDSCAPE; + } + + private static Context getApplicationContext() { + return Factory.get().getApplicationContext(); + } + + public static CharSequence commaEllipsize( + final String text, + final TextPaint paint, + final int width, + final String oneMore, + final String more) { + CharSequence ellipsized = TextUtils.commaEllipsize( + text, + paint, + width, + oneMore, + more); + if (TextUtils.isEmpty(ellipsized)) { + ellipsized = text; + } + return ellipsized; + } + + /** + * Reveals/Hides a view with a scale animation from view center. + * @param view the view to animate + * @param desiredVisibility desired visibility (e.g. View.GONE) for the animated view. + * @param onFinishRunnable an optional runnable called at the end of the animation + */ + public static void revealOrHideViewWithAnimation(final View view, final int desiredVisibility, + @Nullable final Runnable onFinishRunnable) { + final boolean needAnimation = view.getVisibility() != desiredVisibility; + if (needAnimation) { + final float fromScale = desiredVisibility == View.VISIBLE ? 0F : 1F; + final float toScale = desiredVisibility == View.VISIBLE ? 1F : 0F; + final ScaleAnimation showHideAnimation = + new ScaleAnimation(fromScale, toScale, fromScale, toScale, + ScaleAnimation.RELATIVE_TO_SELF, 0.5f, + ScaleAnimation.RELATIVE_TO_SELF, 0.5f); + showHideAnimation.setDuration(REVEAL_ANIMATION_DURATION); + showHideAnimation.setInterpolator(DEFAULT_INTERPOLATOR); + showHideAnimation.setAnimationListener(new AnimationListener() { + @Override + public void onAnimationStart(final Animation animation) { + } + + @Override + public void onAnimationRepeat(final Animation animation) { + } + + @Override + public void onAnimationEnd(final Animation animation) { + if (onFinishRunnable != null) { + // Rather than running this immediately, we post it to happen next so that + // the animation will be completed so that the view can be detached from + // it's window. Otherwise, we may leak memory. + ThreadUtil.getMainThreadHandler().post(onFinishRunnable); + } + } + }); + view.clearAnimation(); + view.startAnimation(showHideAnimation); + // We are playing a view Animation; unlike view property animations, we can commit the + // visibility immediately instead of waiting for animation end. + view.setVisibility(desiredVisibility); + } else if (onFinishRunnable != null) { + // Make sure onFinishRunnable is always executed. + ThreadUtil.getMainThreadHandler().post(onFinishRunnable); + } + } + + public static Rect getMeasuredBoundsOnScreen(final View view) { + final int[] location = new int[2]; + view.getLocationOnScreen(location); + return new Rect(location[0], location[1], + location[0] + view.getMeasuredWidth(), location[1] + view.getMeasuredHeight()); + } + + public static void setStatusBarColor(final Activity activity, final int color) { + if (OsUtil.isAtLeastL()) { + // To achieve the appearance of an 80% opacity blend against a black background, + // each color channel is reduced in value by 20%. + final int blendedRed = (int) Math.floor(0.8 * Color.red(color)); + final int blendedGreen = (int) Math.floor(0.8 * Color.green(color)); + final int blendedBlue = (int) Math.floor(0.8 * Color.blue(color)); + + activity.getWindow().setStatusBarColor( + Color.rgb(blendedRed, blendedGreen, blendedBlue)); + } + } + + public static void lockOrientation(final Activity activity) { + final int orientation = activity.getResources().getConfiguration().orientation; + final int rotation = activity.getWindowManager().getDefaultDisplay().getRotation(); + + // rotation tracks the rotation of the device from its natural orientation + // orientation tracks whether the screen is landscape or portrait. + // It is possible to have a rotation of 0 (device in its natural orientation) in portrait + // (phone), or in landscape (tablet), so we have to check both values to determine what to + // pass to setRequestedOrientation. + if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_90) { + if (orientation == Configuration.ORIENTATION_PORTRAIT) { + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); + } else if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); + } + } else if (rotation == Surface.ROTATION_180 || rotation == Surface.ROTATION_270) { + if (orientation == Configuration.ORIENTATION_PORTRAIT) { + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT); + } else if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE); + } + } + } + + public static void unlockOrientation(final Activity activity) { + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR); + } + + public static int getPaddingStart(final View view) { + return OsUtil.isAtLeastJB_MR1() ? view.getPaddingStart() : view.getPaddingLeft(); + } + + public static int getPaddingEnd(final View view) { + return OsUtil.isAtLeastJB_MR1() ? view.getPaddingEnd() : view.getPaddingRight(); + } + + public static boolean isRtlMode() { + return OsUtil.isAtLeastJB_MR2() && Factory.get().getApplicationContext().getResources() + .getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL; + } + + /** + * Check if the activity needs to be redirected to permission check + * @return true if {@link Activity#finish()} was called because redirection was performed + */ + public static boolean redirectToPermissionCheckIfNeeded(final Activity activity) { + if (!OsUtil.hasRequiredPermissions()) { + UIIntents.get().launchPermissionCheckActivity(activity); + } else { + // No redirect performed + return false; + } + + // Redirect performed + activity.finish(); + return true; + } + + /** + * Called to check if all conditions are nominal and a "go" for some action, such as deleting + * a message, that requires this app to be the default app. This is also a precondition + * required for sending a draft. + * @return true if all conditions are nominal and we're ready to send a message + */ + public static boolean isReadyForAction() { + final PhoneUtils phoneUtils = PhoneUtils.getDefault(); + + // Have all the conditions been met: + // Supports SMS? + // Has a preferred sim? + // Is the default sms app? + return phoneUtils.isSmsCapable() && + phoneUtils.getHasPreferredSmsSim() && + phoneUtils.isDefaultSmsApp(); + } + + /* + * Removes all html markup from the text and replaces links with the the text and a text version + * of the href. + * @param htmlText HTML markup text + * @return Sanitized string with link hrefs inlined + */ + public static String stripHtml(final String htmlText) { + final StringBuilder result = new StringBuilder(); + final Spanned markup = Html.fromHtml(htmlText); + final String strippedText = markup.toString(); + + final URLSpan[] links = markup.getSpans(0, markup.length() - 1, URLSpan.class); + int currentIndex = 0; + for (final URLSpan link : links) { + final int spanStart = markup.getSpanStart(link); + final int spanEnd = markup.getSpanEnd(link); + if (spanStart > currentIndex) { + result.append(strippedText, currentIndex, spanStart); + } + final String displayText = strippedText.substring(spanStart, spanEnd); + final String linkText = link.getURL(); + result.append(getApplicationContext().getString(R.string.link_display_format, + displayText, linkText)); + currentIndex = spanEnd; + } + if (strippedText.length() > currentIndex) { + result.append(strippedText, currentIndex, strippedText.length()); + } + return result.toString(); + } + + public static void setActionBarShadowVisibility(final ActionBarActivity activity, final boolean visible) { + final ActionBar actionBar = activity.getSupportActionBar(); + actionBar.setElevation(visible ? + activity.getResources().getDimensionPixelSize(R.dimen.action_bar_elevation) : + 0); + final View actionBarView = activity.getWindow().getDecorView().findViewById( + android.support.v7.appcompat.R.id.decor_content_parent); + if (actionBarView != null) { + // AppCompatActionBar has one drawable Field, which is the shadow for the action bar + // set the alpha on that drawable manually + final Field[] fields = actionBarView.getClass().getDeclaredFields(); + try { + for (final Field field : fields) { + if (field.getType().equals(Drawable.class)) { + field.setAccessible(true); + final Drawable shadowDrawable = (Drawable) field.get(actionBarView); + if (shadowDrawable != null) { + shadowDrawable.setAlpha(visible ? 255 : 0); + actionBarView.invalidate(); + return; + } + } + } + } catch (final IllegalAccessException ex) { + // Not expected, we should avoid this via field.setAccessible(true) above + LogUtil.e(LogUtil.BUGLE_TAG, "Error setting shadow visibility", ex); + } + } + } + + /** + * Get the activity that's hosting the view, typically casting view.getContext() as an Activity + * is sufficient, but sometimes the context is a context wrapper, in which case we need to case + * the base context + */ + public static Activity getActivity(final View view) { + if (view == null) { + return null; + } + return getActivity(view.getContext()); + } + + /** + * Get the activity for the supplied context, typically casting context as an Activity + * is sufficient, but sometimes the context is a context wrapper, in which case we need to case + * the base context + */ + public static Activity getActivity(final Context context) { + if (context == null) { + return null; + } + if (context instanceof Activity) { + return (Activity) context; + } + if (context instanceof ContextWrapper) { + return getActivity(((ContextWrapper) context).getBaseContext()); + } + + // We've hit a non-activity context such as an app-context + return null; + } + + public static RemoteViews getWidgetMissingPermissionView(final Context context) { + return new RemoteViews(context.getPackageName(), R.layout.widget_missing_permission); + } +} diff --git a/src/com/android/messaging/util/UriUtil.java b/src/com/android/messaging/util/UriUtil.java new file mode 100644 index 0000000..4bbc80d --- /dev/null +++ b/src/com/android/messaging/util/UriUtil.java @@ -0,0 +1,393 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.res.AssetFileDescriptor; +import android.media.MediaMetadataRetriever; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.MediaStore; +import android.support.annotation.NonNull; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.google.common.io.ByteStreams; +import com.google.common.io.Files; +import com.google.common.io.Resources; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; +import java.net.URLConnection; +import java.util.Arrays; +import java.util.HashSet; + +public class UriUtil { + private static final String SCHEME_SMS = "sms"; + private static final String SCHEME_SMSTO = "smsto"; + private static final String SCHEME_MMS = "mms"; + private static final String SCHEME_MMSTO = "smsto"; + public static final HashSet SMS_MMS_SCHEMES = new HashSet( + Arrays.asList(SCHEME_SMS, SCHEME_MMS, SCHEME_SMSTO, SCHEME_MMSTO)); + + public static final String SCHEME_BUGLE = "bugle"; + public static final HashSet SUPPORTED_SCHEME = new HashSet( + Arrays.asList(ContentResolver.SCHEME_ANDROID_RESOURCE, + ContentResolver.SCHEME_CONTENT, + ContentResolver.SCHEME_FILE, + SCHEME_BUGLE)); + + public static final String SCHEME_TEL = "tel:"; + + /** + * Get a Uri representation of the file path of a resource file. + */ + public static Uri getUriForResourceFile(final String path) { + return TextUtils.isEmpty(path) ? null : Uri.fromFile(new File(path)); + } + + /** + * Extract the path from a file:// Uri, or null if the uri is of other scheme. + */ + public static String getFilePathFromUri(final Uri uri) { + if (!isFileUri(uri)) { + return null; + } + return uri.getPath(); + } + + /** + * Returns whether the given Uri is local or remote. + */ + public static boolean isLocalResourceUri(final Uri uri) { + final String scheme = uri.getScheme(); + return TextUtils.equals(scheme, ContentResolver.SCHEME_ANDROID_RESOURCE) || + TextUtils.equals(scheme, ContentResolver.SCHEME_CONTENT) || + TextUtils.equals(scheme, ContentResolver.SCHEME_FILE); + } + + /** + * Returns whether the given Uri is part of Bugle's app package + */ + public static boolean isBugleAppResource(final Uri uri) { + final String scheme = uri.getScheme(); + return TextUtils.equals(scheme, ContentResolver.SCHEME_ANDROID_RESOURCE); + } + + public static boolean isFileUri(final Uri uri) { + return uri != null && TextUtils.equals(uri.getScheme(), ContentResolver.SCHEME_FILE); + } + + /** + * Constructs an android.resource:// uri for the given resource id. + */ + public static Uri getUriForResourceId(final Context context, final int resId) { + return new Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(context.getPackageName()) + .appendPath(String.valueOf(resId)) + .build(); + } + + /** + * Returns whether the given Uri string is local. + */ + public static boolean isLocalUri(@NonNull final Uri uri) { + Assert.notNull(uri); + return SUPPORTED_SCHEME.contains(uri.getScheme()); + } + + private static final String MEDIA_STORE_URI_KLP = "com.android.providers.media.documents"; + + /** + * Check if a URI is from the MediaStore + */ + public static boolean isMediaStoreUri(final Uri uri) { + final String uriAuthority = uri.getAuthority(); + return TextUtils.equals(ContentResolver.SCHEME_CONTENT, uri.getScheme()) + && (TextUtils.equals(MediaStore.AUTHORITY, uriAuthority) || + // KK changed the media store authority name + TextUtils.equals(MEDIA_STORE_URI_KLP, uriAuthority)); + } + + /** + * Gets the size in bytes for the content uri. Currently we only support content in the + * scratch space. + */ + @DoesNotRunOnMainThread + public static long getContentSize(final Uri uri) { + Assert.isNotMainThread(); + if (isLocalResourceUri(uri)) { + ParcelFileDescriptor pfd = null; + try { + pfd = Factory.get().getApplicationContext() + .getContentResolver().openFileDescriptor(uri, "r"); + return Math.max(pfd.getStatSize(), 0); + } catch (final FileNotFoundException e) { + LogUtil.e(LogUtil.BUGLE_TAG, "Error getting content size", e); + } finally { + if (pfd != null) { + try { + pfd.close(); + } catch (final IOException e) { + // Do nothing. + } + } + } + } else { + Assert.fail("Unsupported uri type!"); + } + return 0; + } + + /** @return duration in milliseconds or 0 if not able to determine */ + public static int getMediaDurationMs(final Uri uri) { + final MediaMetadataRetrieverWrapper retriever = new MediaMetadataRetrieverWrapper(); + try { + retriever.setDataSource(uri); + return retriever.extractInteger(MediaMetadataRetriever.METADATA_KEY_DURATION, 0); + } catch (final IOException e) { + LogUtil.e(LogUtil.BUGLE_TAG, "Unable extract duration from media file: " + uri, e); + return 0; + } finally { + retriever.release(); + } + } + + /** + * Persist a piece of content from the given input stream, byte by byte to the scratch + * directory. + * @return the output Uri if the operation succeeded, or null if failed. + */ + @DoesNotRunOnMainThread + public static Uri persistContentToScratchSpace(final InputStream inputStream) { + final Context context = Factory.get().getApplicationContext(); + final Uri scratchSpaceUri = MediaScratchFileProvider.buildMediaScratchSpaceUri(null); + return copyContent(context, inputStream, scratchSpaceUri); + } + + /** + * Persist a piece of content from the given sourceUri, byte by byte to the scratch + * directory. + * @return the output Uri if the operation succeeded, or null if failed. + */ + @DoesNotRunOnMainThread + public static Uri persistContentToScratchSpace(final Uri sourceUri) { + InputStream inputStream = null; + final Context context = Factory.get().getApplicationContext(); + try { + if (UriUtil.isLocalResourceUri(sourceUri)) { + inputStream = context.getContentResolver().openInputStream(sourceUri); + } else { + // The content is remote. Download it. + final URL url = new URL(sourceUri.toString()); + final URLConnection ucon = url.openConnection(); + inputStream = new BufferedInputStream(ucon.getInputStream()); + } + return persistContentToScratchSpace(inputStream); + } catch (final Exception ex) { + LogUtil.e(LogUtil.BUGLE_TAG, "Error while retrieving media ", ex); + return null; + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (final IOException e) { + LogUtil.e(LogUtil.BUGLE_TAG, "error trying to close the inputStream", e); + } + } + } + } + + /** + * Persist a piece of content from the given input stream, byte by byte to the specified + * directory. + * @return the output Uri if the operation succeeded, or null if failed. + */ + @DoesNotRunOnMainThread + public static Uri persistContent( + final InputStream inputStream, final File outputDir, final String contentType) { + if (!outputDir.exists() && !outputDir.mkdirs()) { + LogUtil.e(LogUtil.BUGLE_TAG, "Error creating " + outputDir.getAbsolutePath()); + return null; + } + + final Context context = Factory.get().getApplicationContext(); + try { + final Uri targetUri = Uri.fromFile(FileUtil.getNewFile(outputDir, contentType)); + return copyContent(context, inputStream, targetUri); + } catch (final IOException e) { + LogUtil.e(LogUtil.BUGLE_TAG, "Error creating file in " + outputDir.getAbsolutePath()); + return null; + } + } + + /** + * Persist a piece of content from the given sourceUri, byte by byte to the + * specified output directory. + * @return the output Uri if the operation succeeded, or null if failed. + */ + @DoesNotRunOnMainThread + public static Uri persistContent( + final Uri sourceUri, final File outputDir, final String contentType) { + InputStream inputStream = null; + final Context context = Factory.get().getApplicationContext(); + try { + if (UriUtil.isLocalResourceUri(sourceUri)) { + inputStream = context.getContentResolver().openInputStream(sourceUri); + } else { + // The content is remote. Download it. + final URL url = new URL(sourceUri.toString()); + final URLConnection ucon = url.openConnection(); + inputStream = new BufferedInputStream(ucon.getInputStream()); + } + return persistContent(inputStream, outputDir, contentType); + } catch (final Exception ex) { + LogUtil.e(LogUtil.BUGLE_TAG, "Error while retrieving media ", ex); + return null; + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (final IOException e) { + LogUtil.e(LogUtil.BUGLE_TAG, "error trying to close the inputStream", e); + } + } + } + } + + /** @return uri of target file, or null on error */ + @DoesNotRunOnMainThread + private static Uri copyContent( + final Context context, final InputStream inputStream, final Uri targetUri) { + Assert.isNotMainThread(); + OutputStream outputStream = null; + try { + outputStream = context.getContentResolver().openOutputStream(targetUri); + ByteStreams.copy(inputStream, outputStream); + } catch (final Exception ex) { + LogUtil.e(LogUtil.BUGLE_TAG, "Error while copying content ", ex); + return null; + } finally { + if (outputStream != null) { + try { + outputStream.flush(); + } catch (final IOException e) { + LogUtil.e(LogUtil.BUGLE_TAG, "error trying to flush the outputStream", e); + return null; + } finally { + try { + outputStream.close(); + } catch (final IOException e) { + // Do nothing. + } + } + } + } + return targetUri; + } + + public static boolean isSmsMmsUri(final Uri uri) { + return uri != null && SMS_MMS_SCHEMES.contains(uri.getScheme()); + } + + /** + * Extract recipient destinations from Uri of form + * SCHEME:destionation[,destination]?otherstuff + * where SCHEME is one of the supported sms/mms schemes. + * + * @param uri sms/mms uri + * @return recipient destinations or null + */ + public static String[] parseRecipientsFromSmsMmsUri(final Uri uri) { + if (!isSmsMmsUri(uri)) { + return null; + } + final String[] parts = uri.getSchemeSpecificPart().split("\\?"); + if (TextUtils.isEmpty(parts[0])) { + return null; + } + // replaceUnicodeDigits will replace digits typed in other languages (i.e. Egyptian) with + // the usual ascii equivalents. + return TextUtil.replaceUnicodeDigits(parts[0]).replace(';', ',').split(","); + } + + /** + * Return the length of the file to which contentUri refers + * + * @param contentUri URI for the file of which we want the length + * @return Length of the file or AssetFileDescriptor.UNKNOWN_LENGTH + */ + public static long getUriContentLength(final Uri contentUri) { + final Context context = Factory.get().getApplicationContext(); + AssetFileDescriptor afd = null; + try { + afd = context.getContentResolver().openAssetFileDescriptor(contentUri, "r"); + return afd.getLength(); + } catch (final FileNotFoundException e) { + LogUtil.w(LogUtil.BUGLE_TAG, "Failed to query length of " + contentUri); + } finally { + if (afd != null) { + try { + afd.close(); + } catch (final IOException e) { + LogUtil.w(LogUtil.BUGLE_TAG, "Failed to close afd for " + contentUri); + } + } + } + return AssetFileDescriptor.UNKNOWN_LENGTH; + } + + /** + * Download data from the given url to the given local file. + * @return true if the download was successful. + * + * TODO: Add retry/exponential backoff logic. + */ + @DoesNotRunOnMainThread + public static boolean downloadDataFromUrl(final String urlString, final File localFile) { + Assert.isNotMainThread(); + LogUtil.i(LogUtil.BUGLE_TAG, "Downloading from " + urlString + " to " + localFile); + try { + Files.createParentDirs(localFile); + final URL inUrl = new URL(urlString); + ByteStreams.copy(Resources.newInputStreamSupplier(inUrl), + Files.newOutputStreamSupplier(localFile)); + return true; + } catch (final IOException ex) { + LogUtil.e(LogUtil.BUGLE_TAG, "Error downloading from " + urlString, ex); + return false; + } + } + + /** @return string representation of URI or null if URI was null */ + public static String stringFromUri(final Uri uri) { + return uri == null ? null : uri.toString(); + } + + /** @return URI created from string or null if string was null or empty */ + public static Uri uriFromString(final String uriString) { + return TextUtils.isEmpty(uriString) ? null : Uri.parse(uriString); + } +} diff --git a/src/com/android/messaging/util/VersionUtil.java b/src/com/android/messaging/util/VersionUtil.java new file mode 100644 index 0000000..b87aa55 --- /dev/null +++ b/src/com/android/messaging/util/VersionUtil.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager.NameNotFoundException; + +import java.util.Locale; + +public final class VersionUtil { + private static final Object sLock = new Object(); + private static VersionUtil sInstance; + private final String mSimpleVersionName; + private final int mVersionCode; + + public static VersionUtil getInstance(final Context context) { + synchronized (sLock) { + if (sInstance == null) { + sInstance = new VersionUtil(context); + } + } + return sInstance; + } + + private VersionUtil(final Context context) { + int versionCode; + try { + PackageInfo pi = context.getPackageManager().getPackageInfo( + context.getPackageName(), 0); + versionCode = pi.versionCode; + } catch (final NameNotFoundException exception) { + Assert.fail("couldn't get package info " + exception); + versionCode = -1; + } + mVersionCode = versionCode; + final int majorBuildNumber = versionCode / 1000; + // Use US locale to format version number so that other language characters don't + // show up in version string. + mSimpleVersionName = String.format(Locale.US, "%d.%d.%03d", + majorBuildNumber / 10000, + (majorBuildNumber / 1000) % 10, + majorBuildNumber % 1000); + } + + public int getVersionCode() { + return mVersionCode; + } + + public String getSimpleName() { + return mSimpleVersionName; + } +} diff --git a/src/com/android/messaging/util/WakeLockHelper.java b/src/com/android/messaging/util/WakeLockHelper.java new file mode 100644 index 0000000..c9a9152 --- /dev/null +++ b/src/com/android/messaging/util/WakeLockHelper.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.content.Context; +import android.content.Intent; +import android.os.Debug; +import android.os.PowerManager; +import android.os.Process; + +import com.google.common.annotations.VisibleForTesting; + +/** + * Helper class used to manage wakelock state + */ +public class WakeLockHelper { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + private static final boolean VERBOSE = false; + + @VisibleForTesting + public static final String EXTRA_CALLING_PID = "pid"; + + private final Object mLock = new Object(); + private final String mWakeLockId; + private final int mMyPid; + + private PowerManager.WakeLock mWakeLock; + + public WakeLockHelper(final String wakeLockId) { + mWakeLockId = wakeLockId; + mMyPid = Process.myPid(); + } + + /** + * Acquire the wakelock + */ + public void acquire(final Context context, final Intent intent, final int opcode) { + synchronized (mLock) { + if (mWakeLock == null) { + if (VERBOSE) { + LogUtil.v(TAG, "initializing wakelock"); + } + final PowerManager pm = (PowerManager) + context.getSystemService(Context.POWER_SERVICE); + mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, mWakeLockId); + } + } + if (VERBOSE) { + LogUtil.v(TAG, "acquiring " + mWakeLockId + " for opcode " + opcode); + } + mWakeLock.acquire(); + intent.putExtra(EXTRA_CALLING_PID, mMyPid); + } + + /** + * Check if wakelock held by this process + */ + public boolean isHeld(final Intent intent) { + final boolean respectWakeLock = (mMyPid == intent.getIntExtra(EXTRA_CALLING_PID, -1)); + return (respectWakeLock && mWakeLock.isHeld()); + } + + /** + * Ensure that wakelock is held by this process + */ + public boolean ensure(final Intent intent, final int opcode) { + final boolean respectWakeLock = (mMyPid == intent.getIntExtra(EXTRA_CALLING_PID, -1)); + if (VERBOSE) { + LogUtil.v(TAG, "WakeLockHelper.ensure Intent " + intent + " " + + intent.getAction() + " opcode: " + opcode + + " respectWakeLock " + respectWakeLock); + } + + if (respectWakeLock) { + final boolean isHeld = (respectWakeLock && isHeld(intent)); + if (!isHeld) { + LogUtil.e(TAG, "WakeLockHelper.ensure called " + intent + " " + intent.getAction() + + " opcode: " + opcode + " sWakeLock: " + mWakeLock + " isHeld: " + + ((mWakeLock == null) ? "(null)" : mWakeLock.isHeld())); + if (!Debug.isDebuggerConnected()) { + Assert.fail("WakeLock dropped prior to service starting"); + } + } + return true; + } + return false; + } + + /** + * Release wakelock (if it is held by this process) + */ + public void release(final Intent intent, final int opcode) { + final boolean respectWakeLock = (mMyPid == intent.getIntExtra(EXTRA_CALLING_PID, -1)); + if (respectWakeLock) { + try { + mWakeLock.release(); + } catch (final RuntimeException ex) { + LogUtil.e(TAG, "KeepAliveService.onHandleIntent exit crash " + intent + " " + + intent.getAction() + " opcode: " + opcode + " sWakeLock: " + mWakeLock + + " isHeld: " + ((mWakeLock == null) ? "(null)" : mWakeLock.isHeld())); + if (!Debug.isDebuggerConnected()) { + Assert.fail("WakeLock no longer held at end of handler"); + } + } + } + } +} diff --git a/src/com/android/messaging/util/YouTubeUtil.java b/src/com/android/messaging/util/YouTubeUtil.java new file mode 100644 index 0000000..203a666 --- /dev/null +++ b/src/com/android/messaging/util/YouTubeUtil.java @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import android.net.Uri; +import android.text.TextUtils; + +public class YouTubeUtil { + private static final String YOUTUBE_HOST_1 = "www.youtube.com"; + private static final String YOUTUBE_HOST_2 = "youtube.com"; + private static final String YOUTUBE_HOST_3 = "m.youtube.com"; + private static final String YOUTUBE_HOST_4 = "youtube.googleapis.com"; + private static final String YOUTUBE_HOST_5 = "youtu.be"; + + private static final String YOUTUBE_PATH_1 = "/watch"; + private static final String YOUTUBE_PATH_2 = "/embed/"; + private static final String YOUTUBE_PATH_3 = "/v/"; + private static final String YOUTUBE_PATH_4 = "/apiplayer"; + + public static final String YOUTUBE_STATIC_THUMBNAIL_PREFIX = "https://img.youtube.com/vi/"; + public static final String YOUTUBE_STATIC_THUMBNAIL_END = "/hqdefault.jpg"; + + public static String getYoutubePreviewImageLink(String urlString) { + // Types of youtube urls: + // 1.) http://www.youtube.com/watch?v=VIDEOID + // 2.) http://www.youtube.com/embed/VIDEOID + // 3.) http://www.youtube.com/v/VIDEOID + // 3a.) https://youtube.googleapis.com/v/VIDEOID + // 4.) http://www.youtube.com/apiplayer?video_id=VIDEO_ID + // 5.) http://youtu.be/VIDEOID + if (!urlString.startsWith("http")) { + // Apparently the url is not an RFC 2396 compliant uri without the port + urlString = "http://" + urlString; + } + final Uri uri = Uri.parse(urlString); + final String host = uri.getHost(); + if (YOUTUBE_HOST_1.equalsIgnoreCase(host) + || YOUTUBE_HOST_2.equalsIgnoreCase(host) + || YOUTUBE_HOST_3.equalsIgnoreCase(host) + || YOUTUBE_HOST_4.equalsIgnoreCase(host) + || YOUTUBE_HOST_5.equalsIgnoreCase(host)) { + final String videoId = getYouTubeVideoId(uri); + if (!TextUtils.isEmpty(videoId)) { + return YOUTUBE_STATIC_THUMBNAIL_PREFIX + videoId + YOUTUBE_STATIC_THUMBNAIL_END; + } + return null; + } + return null; + } + + private static String getYouTubeVideoId(Uri uri) { + final String urlPath = uri.getPath(); + + if (TextUtils.isEmpty(urlPath)) { + // There is no path so no need to continue. + return null; + } + // Case 1 + if (urlPath.startsWith(YOUTUBE_PATH_1)) { + return uri.getQueryParameter("v"); + } + // Case 2 + if (urlPath.startsWith(YOUTUBE_PATH_2)) { + return getVideoIdFromPath(YOUTUBE_PATH_2, urlPath); + } + // Case 3 + if (urlPath.startsWith(YOUTUBE_PATH_3)) { + return getVideoIdFromPath(YOUTUBE_PATH_3, urlPath); + } + // Case 4 + if (urlPath.startsWith(YOUTUBE_PATH_4)) { + return uri.getQueryParameter("video_id"); + } + // Case 5 + if (YOUTUBE_HOST_5.equalsIgnoreCase(uri.getHost())) { + return getVideoIdFromPath("/", urlPath); + } + return null; + } + + private static String getVideoIdFromPath(String prefixSubstring, String urlPath) { + return urlPath.substring(prefixSubstring.length()); + } + +} diff --git a/src/com/android/messaging/util/exif/ByteBufferInputStream.java b/src/com/android/messaging/util/exif/ByteBufferInputStream.java new file mode 100644 index 0000000..9db92ef --- /dev/null +++ b/src/com/android/messaging/util/exif/ByteBufferInputStream.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util.exif; + +import java.io.InputStream; +import java.nio.ByteBuffer; + +class ByteBufferInputStream extends InputStream { + + private final ByteBuffer mBuf; + + public ByteBufferInputStream(ByteBuffer buf) { + mBuf = buf; + } + + @Override + public int read() { + if (!mBuf.hasRemaining()) { + return -1; + } + return mBuf.get() & 0xFF; + } + + @Override + public int read(byte[] bytes, int off, int len) { + if (!mBuf.hasRemaining()) { + return -1; + } + + len = Math.min(len, mBuf.remaining()); + mBuf.get(bytes, off, len); + return len; + } +} diff --git a/src/com/android/messaging/util/exif/CountedDataInputStream.java b/src/com/android/messaging/util/exif/CountedDataInputStream.java new file mode 100644 index 0000000..ce766d9 --- /dev/null +++ b/src/com/android/messaging/util/exif/CountedDataInputStream.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util.exif; + +import java.io.EOFException; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.Charset; + +class CountedDataInputStream extends FilterInputStream { + + private int mCount = 0; + + // allocate a byte buffer for a long value; + private final byte mByteArray[] = new byte[8]; + private final ByteBuffer mByteBuffer = ByteBuffer.wrap(mByteArray); + + protected CountedDataInputStream(InputStream in) { + super(in); + } + + public int getReadByteCount() { + return mCount; + } + + @Override + public int read(byte[] b) throws IOException { + int r = in.read(b); + mCount += (r >= 0) ? r : 0; + return r; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int r = in.read(b, off, len); + mCount += (r >= 0) ? r : 0; + return r; + } + + @Override + public int read() throws IOException { + int r = in.read(); + mCount += (r >= 0) ? 1 : 0; + return r; + } + + @Override + public long skip(long length) throws IOException { + long skip = in.skip(length); + mCount += skip; + return skip; + } + + public void skipOrThrow(long length) throws IOException { + if (skip(length) != length) { + throw new EOFException(); + } + } + + public void skipTo(long target) throws IOException { + long cur = mCount; + long diff = target - cur; + assert(diff >= 0); + skipOrThrow(diff); + } + + public void readOrThrow(byte[] b, int off, int len) throws IOException { + int r = read(b, off, len); + if (r != len) { + throw new EOFException(); + } + } + + public void readOrThrow(byte[] b) throws IOException { + readOrThrow(b, 0, b.length); + } + + public void setByteOrder(ByteOrder order) { + mByteBuffer.order(order); + } + + public ByteOrder getByteOrder() { + return mByteBuffer.order(); + } + + public short readShort() throws IOException { + readOrThrow(mByteArray, 0 , 2); + mByteBuffer.rewind(); + return mByteBuffer.getShort(); + } + + public int readUnsignedShort() throws IOException { + return readShort() & 0xffff; + } + + public int readInt() throws IOException { + readOrThrow(mByteArray, 0 , 4); + mByteBuffer.rewind(); + return mByteBuffer.getInt(); + } + + public long readUnsignedInt() throws IOException { + return readInt() & 0xffffffffL; + } + + public long readLong() throws IOException { + readOrThrow(mByteArray, 0 , 8); + mByteBuffer.rewind(); + return mByteBuffer.getLong(); + } + + public String readString(int n) throws IOException { + byte buf[] = new byte[n]; + readOrThrow(buf); + return new String(buf, "UTF8"); + } + + public String readString(int n, Charset charset) throws IOException { + byte buf[] = new byte[n]; + readOrThrow(buf); + return new String(buf, charset); + } +} diff --git a/src/com/android/messaging/util/exif/ExifData.java b/src/com/android/messaging/util/exif/ExifData.java new file mode 100644 index 0000000..77ba4e9 --- /dev/null +++ b/src/com/android/messaging/util/exif/ExifData.java @@ -0,0 +1,349 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util.exif; + +import android.util.Log; +import com.android.messaging.util.LogUtil; + +import java.io.UnsupportedEncodingException; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * This class stores the EXIF header in IFDs according to the JPEG + * specification. It is the result produced by {@link ExifReader}. + * + * @see ExifReader + * @see IfdData + */ +class ExifData { + private static final String TAG = LogUtil.BUGLE_TAG; + private static final byte[] USER_COMMENT_ASCII = { + 0x41, 0x53, 0x43, 0x49, 0x49, 0x00, 0x00, 0x00 + }; + private static final byte[] USER_COMMENT_JIS = { + 0x4A, 0x49, 0x53, 0x00, 0x00, 0x00, 0x00, 0x00 + }; + private static final byte[] USER_COMMENT_UNICODE = { + 0x55, 0x4E, 0x49, 0x43, 0x4F, 0x44, 0x45, 0x00 + }; + + private final IfdData[] mIfdDatas = new IfdData[IfdId.TYPE_IFD_COUNT]; + private byte[] mThumbnail; + private final ArrayList mStripBytes = new ArrayList(); + private final ByteOrder mByteOrder; + + ExifData(ByteOrder order) { + mByteOrder = order; + } + + /** + * Gets the compressed thumbnail. Returns null if there is no compressed + * thumbnail. + * + * @see #hasCompressedThumbnail() + */ + protected byte[] getCompressedThumbnail() { + return mThumbnail; + } + + /** + * Sets the compressed thumbnail. + */ + protected void setCompressedThumbnail(byte[] thumbnail) { + mThumbnail = thumbnail; + } + + /** + * Returns true it this header contains a compressed thumbnail. + */ + protected boolean hasCompressedThumbnail() { + return mThumbnail != null; + } + + /** + * Adds an uncompressed strip. + */ + protected void setStripBytes(int index, byte[] strip) { + if (index < mStripBytes.size()) { + mStripBytes.set(index, strip); + } else { + for (int i = mStripBytes.size(); i < index; i++) { + mStripBytes.add(null); + } + mStripBytes.add(strip); + } + } + + /** + * Gets the strip count. + */ + protected int getStripCount() { + return mStripBytes.size(); + } + + /** + * Gets the strip at the specified index. + * + * @exceptions #IndexOutOfBoundException + */ + protected byte[] getStrip(int index) { + return mStripBytes.get(index); + } + + /** + * Returns true if this header contains uncompressed strip. + */ + protected boolean hasUncompressedStrip() { + return mStripBytes.size() != 0; + } + + /** + * Gets the byte order. + */ + protected ByteOrder getByteOrder() { + return mByteOrder; + } + + /** + * Returns the {@link IfdData} object corresponding to a given IFD if it + * exists or null. + */ + protected IfdData getIfdData(int ifdId) { + if (ExifTag.isValidIfd(ifdId)) { + return mIfdDatas[ifdId]; + } + return null; + } + + /** + * Adds IFD data. If IFD data of the same type already exists, it will be + * replaced by the new data. + */ + protected void addIfdData(IfdData data) { + mIfdDatas[data.getId()] = data; + } + + /** + * Returns the {@link IfdData} object corresponding to a given IFD or + * generates one if none exist. + */ + protected IfdData getOrCreateIfdData(int ifdId) { + IfdData ifdData = mIfdDatas[ifdId]; + if (ifdData == null) { + ifdData = new IfdData(ifdId); + mIfdDatas[ifdId] = ifdData; + } + return ifdData; + } + + /** + * Returns the tag with a given TID in the given IFD if the tag exists. + * Otherwise returns null. + */ + protected ExifTag getTag(short tag, int ifd) { + IfdData ifdData = mIfdDatas[ifd]; + return (ifdData == null) ? null : ifdData.getTag(tag); + } + + /** + * Adds the given ExifTag to its default IFD and returns an existing ExifTag + * with the same TID or null if none exist. + */ + protected ExifTag addTag(ExifTag tag) { + if (tag != null) { + int ifd = tag.getIfd(); + return addTag(tag, ifd); + } + return null; + } + + /** + * Adds the given ExifTag to the given IFD and returns an existing ExifTag + * with the same TID or null if none exist. + */ + protected ExifTag addTag(ExifTag tag, int ifdId) { + if (tag != null && ExifTag.isValidIfd(ifdId)) { + IfdData ifdData = getOrCreateIfdData(ifdId); + return ifdData.setTag(tag); + } + return null; + } + + protected void clearThumbnailAndStrips() { + mThumbnail = null; + mStripBytes.clear(); + } + + /** + * Removes the thumbnail and its related tags. IFD1 will be removed. + */ + protected void removeThumbnailData() { + clearThumbnailAndStrips(); + mIfdDatas[IfdId.TYPE_IFD_1] = null; + } + + /** + * Removes the tag with a given TID and IFD. + */ + protected void removeTag(short tagId, int ifdId) { + IfdData ifdData = mIfdDatas[ifdId]; + if (ifdData == null) { + return; + } + ifdData.removeTag(tagId); + } + + /** + * Decodes the user comment tag into string as specified in the EXIF + * standard. Returns null if decoding failed. + */ + protected String getUserComment() { + IfdData ifdData = mIfdDatas[IfdId.TYPE_IFD_0]; + if (ifdData == null) { + return null; + } + ExifTag tag = ifdData.getTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_USER_COMMENT)); + if (tag == null) { + return null; + } + if (tag.getComponentCount() < 8) { + return null; + } + + byte[] buf = new byte[tag.getComponentCount()]; + tag.getBytes(buf); + + byte[] code = new byte[8]; + System.arraycopy(buf, 0, code, 0, 8); + + try { + if (Arrays.equals(code, USER_COMMENT_ASCII)) { + return new String(buf, 8, buf.length - 8, "US-ASCII"); + } else if (Arrays.equals(code, USER_COMMENT_JIS)) { + return new String(buf, 8, buf.length - 8, "EUC-JP"); + } else if (Arrays.equals(code, USER_COMMENT_UNICODE)) { + return new String(buf, 8, buf.length - 8, "UTF-16"); + } else { + return null; + } + } catch (UnsupportedEncodingException e) { + Log.w(TAG, "Failed to decode the user comment"); + return null; + } + } + + /** + * Returns a list of all {@link ExifTag}s in the ExifData or null if there + * are none. + */ + protected List getAllTags() { + ArrayList ret = new ArrayList(); + for (IfdData d : mIfdDatas) { + if (d != null) { + ExifTag[] tags = d.getAllTags(); + if (tags != null) { + for (ExifTag t : tags) { + ret.add(t); + } + } + } + } + if (ret.size() == 0) { + return null; + } + return ret; + } + + /** + * Returns a list of all {@link ExifTag}s in a given IFD or null if there + * are none. + */ + protected List getAllTagsForIfd(int ifd) { + IfdData d = mIfdDatas[ifd]; + if (d == null) { + return null; + } + ExifTag[] tags = d.getAllTags(); + if (tags == null) { + return null; + } + ArrayList ret = new ArrayList(tags.length); + for (ExifTag t : tags) { + ret.add(t); + } + if (ret.size() == 0) { + return null; + } + return ret; + } + + /** + * Returns a list of all {@link ExifTag}s with a given TID or null if there + * are none. + */ + protected List getAllTagsForTagId(short tag) { + ArrayList ret = new ArrayList(); + for (IfdData d : mIfdDatas) { + if (d != null) { + ExifTag t = d.getTag(tag); + if (t != null) { + ret.add(t); + } + } + } + if (ret.size() == 0) { + return null; + } + return ret; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (obj instanceof ExifData) { + ExifData data = (ExifData) obj; + if (data.mByteOrder != mByteOrder || + data.mStripBytes.size() != mStripBytes.size() || + !Arrays.equals(data.mThumbnail, mThumbnail)) { + return false; + } + for (int i = 0; i < mStripBytes.size(); i++) { + if (!Arrays.equals(data.mStripBytes.get(i), mStripBytes.get(i))) { + return false; + } + } + for (int i = 0; i < IfdId.TYPE_IFD_COUNT; i++) { + IfdData ifd1 = data.getIfdData(i); + IfdData ifd2 = getIfdData(i); + if (ifd1 != ifd2 && ifd1 != null && !ifd1.equals(ifd2)) { + return false; + } + } + return true; + } + return false; + } + +} diff --git a/src/com/android/messaging/util/exif/ExifInterface.java b/src/com/android/messaging/util/exif/ExifInterface.java new file mode 100644 index 0000000..b556748 --- /dev/null +++ b/src/com/android/messaging/util/exif/ExifInterface.java @@ -0,0 +1,2448 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util.exif; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.util.SparseIntArray; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.channels.FileChannel.MapMode; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.TimeZone; + +/** + * This class provides methods and constants for reading and writing jpeg file + * metadata. It contains a collection of ExifTags, and a collection of + * definitions for creating valid ExifTags. The collection of ExifTags can be + * updated by: reading new ones from a file, deleting or adding existing ones, + * or building new ExifTags from a tag definition. These ExifTags can be written + * to a valid jpeg image as exif metadata. + *

+ * Each ExifTag has a tag ID (TID) and is stored in a specific image file + * directory (IFD) as specified by the exif standard. A tag definition can be + * looked up with a constant that is a combination of TID and IFD. This + * definition has information about the type, number of components, and valid + * IFDs for a tag. + * + * @see ExifTag + */ +public class ExifInterface { + public static final int TAG_NULL = -1; + public static final int IFD_NULL = -1; + public static final int DEFINITION_NULL = 0; + + /** + * Tag constants for Jeita EXIF 2.2 + */ + + // IFD 0 + public static final int TAG_IMAGE_WIDTH = + defineTag(IfdId.TYPE_IFD_0, (short) 0x0100); + public static final int TAG_IMAGE_LENGTH = + defineTag(IfdId.TYPE_IFD_0, (short) 0x0101); // Image height + public static final int TAG_BITS_PER_SAMPLE = + defineTag(IfdId.TYPE_IFD_0, (short) 0x0102); + public static final int TAG_COMPRESSION = + defineTag(IfdId.TYPE_IFD_0, (short) 0x0103); + public static final int TAG_PHOTOMETRIC_INTERPRETATION = + defineTag(IfdId.TYPE_IFD_0, (short) 0x0106); + public static final int TAG_IMAGE_DESCRIPTION = + defineTag(IfdId.TYPE_IFD_0, (short) 0x010E); + public static final int TAG_MAKE = + defineTag(IfdId.TYPE_IFD_0, (short) 0x010F); + public static final int TAG_MODEL = + defineTag(IfdId.TYPE_IFD_0, (short) 0x0110); + public static final int TAG_STRIP_OFFSETS = + defineTag(IfdId.TYPE_IFD_0, (short) 0x0111); + public static final int TAG_ORIENTATION = + defineTag(IfdId.TYPE_IFD_0, (short) 0x0112); + public static final int TAG_SAMPLES_PER_PIXEL = + defineTag(IfdId.TYPE_IFD_0, (short) 0x0115); + public static final int TAG_ROWS_PER_STRIP = + defineTag(IfdId.TYPE_IFD_0, (short) 0x0116); + public static final int TAG_STRIP_BYTE_COUNTS = + defineTag(IfdId.TYPE_IFD_0, (short) 0x0117); + public static final int TAG_X_RESOLUTION = + defineTag(IfdId.TYPE_IFD_0, (short) 0x011A); + public static final int TAG_Y_RESOLUTION = + defineTag(IfdId.TYPE_IFD_0, (short) 0x011B); + public static final int TAG_PLANAR_CONFIGURATION = + defineTag(IfdId.TYPE_IFD_0, (short) 0x011C); + public static final int TAG_RESOLUTION_UNIT = + defineTag(IfdId.TYPE_IFD_0, (short) 0x0128); + public static final int TAG_TRANSFER_FUNCTION = + defineTag(IfdId.TYPE_IFD_0, (short) 0x012D); + public static final int TAG_SOFTWARE = + defineTag(IfdId.TYPE_IFD_0, (short) 0x0131); + public static final int TAG_DATE_TIME = + defineTag(IfdId.TYPE_IFD_0, (short) 0x0132); + public static final int TAG_ARTIST = + defineTag(IfdId.TYPE_IFD_0, (short) 0x013B); + public static final int TAG_WHITE_POINT = + defineTag(IfdId.TYPE_IFD_0, (short) 0x013E); + public static final int TAG_PRIMARY_CHROMATICITIES = + defineTag(IfdId.TYPE_IFD_0, (short) 0x013F); + public static final int TAG_Y_CB_CR_COEFFICIENTS = + defineTag(IfdId.TYPE_IFD_0, (short) 0x0211); + public static final int TAG_Y_CB_CR_SUB_SAMPLING = + defineTag(IfdId.TYPE_IFD_0, (short) 0x0212); + public static final int TAG_Y_CB_CR_POSITIONING = + defineTag(IfdId.TYPE_IFD_0, (short) 0x0213); + public static final int TAG_REFERENCE_BLACK_WHITE = + defineTag(IfdId.TYPE_IFD_0, (short) 0x0214); + public static final int TAG_COPYRIGHT = + defineTag(IfdId.TYPE_IFD_0, (short) 0x8298); + public static final int TAG_EXIF_IFD = + defineTag(IfdId.TYPE_IFD_0, (short) 0x8769); + public static final int TAG_GPS_IFD = + defineTag(IfdId.TYPE_IFD_0, (short) 0x8825); + // IFD 1 + public static final int TAG_JPEG_INTERCHANGE_FORMAT = + defineTag(IfdId.TYPE_IFD_1, (short) 0x0201); + public static final int TAG_JPEG_INTERCHANGE_FORMAT_LENGTH = + defineTag(IfdId.TYPE_IFD_1, (short) 0x0202); + // IFD Exif Tags + public static final int TAG_EXPOSURE_TIME = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x829A); + public static final int TAG_F_NUMBER = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x829D); + public static final int TAG_EXPOSURE_PROGRAM = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x8822); + public static final int TAG_SPECTRAL_SENSITIVITY = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x8824); + public static final int TAG_ISO_SPEED_RATINGS = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x8827); + public static final int TAG_OECF = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x8828); + public static final int TAG_EXIF_VERSION = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9000); + public static final int TAG_DATE_TIME_ORIGINAL = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9003); + public static final int TAG_DATE_TIME_DIGITIZED = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9004); + public static final int TAG_COMPONENTS_CONFIGURATION = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9101); + public static final int TAG_COMPRESSED_BITS_PER_PIXEL = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9102); + public static final int TAG_SHUTTER_SPEED_VALUE = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9201); + public static final int TAG_APERTURE_VALUE = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9202); + public static final int TAG_BRIGHTNESS_VALUE = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9203); + public static final int TAG_EXPOSURE_BIAS_VALUE = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9204); + public static final int TAG_MAX_APERTURE_VALUE = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9205); + public static final int TAG_SUBJECT_DISTANCE = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9206); + public static final int TAG_METERING_MODE = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9207); + public static final int TAG_LIGHT_SOURCE = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9208); + public static final int TAG_FLASH = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9209); + public static final int TAG_FOCAL_LENGTH = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x920A); + public static final int TAG_SUBJECT_AREA = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9214); + public static final int TAG_MAKER_NOTE = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x927C); + public static final int TAG_USER_COMMENT = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9286); + public static final int TAG_SUB_SEC_TIME = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9290); + public static final int TAG_SUB_SEC_TIME_ORIGINAL = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9291); + public static final int TAG_SUB_SEC_TIME_DIGITIZED = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0x9292); + public static final int TAG_FLASHPIX_VERSION = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA000); + public static final int TAG_COLOR_SPACE = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA001); + public static final int TAG_PIXEL_X_DIMENSION = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA002); + public static final int TAG_PIXEL_Y_DIMENSION = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA003); + public static final int TAG_RELATED_SOUND_FILE = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA004); + public static final int TAG_INTEROPERABILITY_IFD = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA005); + public static final int TAG_FLASH_ENERGY = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA20B); + public static final int TAG_SPATIAL_FREQUENCY_RESPONSE = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA20C); + public static final int TAG_FOCAL_PLANE_X_RESOLUTION = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA20E); + public static final int TAG_FOCAL_PLANE_Y_RESOLUTION = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA20F); + public static final int TAG_FOCAL_PLANE_RESOLUTION_UNIT = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA210); + public static final int TAG_SUBJECT_LOCATION = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA214); + public static final int TAG_EXPOSURE_INDEX = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA215); + public static final int TAG_SENSING_METHOD = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA217); + public static final int TAG_FILE_SOURCE = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA300); + public static final int TAG_SCENE_TYPE = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA301); + public static final int TAG_CFA_PATTERN = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA302); + public static final int TAG_CUSTOM_RENDERED = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA401); + public static final int TAG_EXPOSURE_MODE = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA402); + public static final int TAG_WHITE_BALANCE = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA403); + public static final int TAG_DIGITAL_ZOOM_RATIO = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA404); + public static final int TAG_FOCAL_LENGTH_IN_35_MM_FILE = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA405); + public static final int TAG_SCENE_CAPTURE_TYPE = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA406); + public static final int TAG_GAIN_CONTROL = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA407); + public static final int TAG_CONTRAST = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA408); + public static final int TAG_SATURATION = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA409); + public static final int TAG_SHARPNESS = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA40A); + public static final int TAG_DEVICE_SETTING_DESCRIPTION = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA40B); + public static final int TAG_SUBJECT_DISTANCE_RANGE = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA40C); + public static final int TAG_IMAGE_UNIQUE_ID = + defineTag(IfdId.TYPE_IFD_EXIF, (short) 0xA420); + // IFD GPS tags + public static final int TAG_GPS_VERSION_ID = + defineTag(IfdId.TYPE_IFD_GPS, (short) 0); + public static final int TAG_GPS_LATITUDE_REF = + defineTag(IfdId.TYPE_IFD_GPS, (short) 1); + public static final int TAG_GPS_LATITUDE = + defineTag(IfdId.TYPE_IFD_GPS, (short) 2); + public static final int TAG_GPS_LONGITUDE_REF = + defineTag(IfdId.TYPE_IFD_GPS, (short) 3); + public static final int TAG_GPS_LONGITUDE = + defineTag(IfdId.TYPE_IFD_GPS, (short) 4); + public static final int TAG_GPS_ALTITUDE_REF = + defineTag(IfdId.TYPE_IFD_GPS, (short) 5); + public static final int TAG_GPS_ALTITUDE = + defineTag(IfdId.TYPE_IFD_GPS, (short) 6); + public static final int TAG_GPS_TIME_STAMP = + defineTag(IfdId.TYPE_IFD_GPS, (short) 7); + public static final int TAG_GPS_SATTELLITES = + defineTag(IfdId.TYPE_IFD_GPS, (short) 8); + public static final int TAG_GPS_STATUS = + defineTag(IfdId.TYPE_IFD_GPS, (short) 9); + public static final int TAG_GPS_MEASURE_MODE = + defineTag(IfdId.TYPE_IFD_GPS, (short) 10); + public static final int TAG_GPS_DOP = + defineTag(IfdId.TYPE_IFD_GPS, (short) 11); + public static final int TAG_GPS_SPEED_REF = + defineTag(IfdId.TYPE_IFD_GPS, (short) 12); + public static final int TAG_GPS_SPEED = + defineTag(IfdId.TYPE_IFD_GPS, (short) 13); + public static final int TAG_GPS_TRACK_REF = + defineTag(IfdId.TYPE_IFD_GPS, (short) 14); + public static final int TAG_GPS_TRACK = + defineTag(IfdId.TYPE_IFD_GPS, (short) 15); + public static final int TAG_GPS_IMG_DIRECTION_REF = + defineTag(IfdId.TYPE_IFD_GPS, (short) 16); + public static final int TAG_GPS_IMG_DIRECTION = + defineTag(IfdId.TYPE_IFD_GPS, (short) 17); + public static final int TAG_GPS_MAP_DATUM = + defineTag(IfdId.TYPE_IFD_GPS, (short) 18); + public static final int TAG_GPS_DEST_LATITUDE_REF = + defineTag(IfdId.TYPE_IFD_GPS, (short) 19); + public static final int TAG_GPS_DEST_LATITUDE = + defineTag(IfdId.TYPE_IFD_GPS, (short) 20); + public static final int TAG_GPS_DEST_LONGITUDE_REF = + defineTag(IfdId.TYPE_IFD_GPS, (short) 21); + public static final int TAG_GPS_DEST_LONGITUDE = + defineTag(IfdId.TYPE_IFD_GPS, (short) 22); + public static final int TAG_GPS_DEST_BEARING_REF = + defineTag(IfdId.TYPE_IFD_GPS, (short) 23); + public static final int TAG_GPS_DEST_BEARING = + defineTag(IfdId.TYPE_IFD_GPS, (short) 24); + public static final int TAG_GPS_DEST_DISTANCE_REF = + defineTag(IfdId.TYPE_IFD_GPS, (short) 25); + public static final int TAG_GPS_DEST_DISTANCE = + defineTag(IfdId.TYPE_IFD_GPS, (short) 26); + public static final int TAG_GPS_PROCESSING_METHOD = + defineTag(IfdId.TYPE_IFD_GPS, (short) 27); + public static final int TAG_GPS_AREA_INFORMATION = + defineTag(IfdId.TYPE_IFD_GPS, (short) 28); + public static final int TAG_GPS_DATE_STAMP = + defineTag(IfdId.TYPE_IFD_GPS, (short) 29); + public static final int TAG_GPS_DIFFERENTIAL = + defineTag(IfdId.TYPE_IFD_GPS, (short) 30); + // IFD Interoperability tags + public static final int TAG_INTEROPERABILITY_INDEX = + defineTag(IfdId.TYPE_IFD_INTEROPERABILITY, (short) 1); + + /** + * Tags that contain offset markers. These are included in the banned + * defines. + */ + private static HashSet sOffsetTags = new HashSet(); + static { + sOffsetTags.add(getTrueTagKey(TAG_GPS_IFD)); + sOffsetTags.add(getTrueTagKey(TAG_EXIF_IFD)); + sOffsetTags.add(getTrueTagKey(TAG_JPEG_INTERCHANGE_FORMAT)); + sOffsetTags.add(getTrueTagKey(TAG_INTEROPERABILITY_IFD)); + sOffsetTags.add(getTrueTagKey(TAG_STRIP_OFFSETS)); + } + + /** + * Tags with definitions that cannot be overridden (banned defines). + */ + protected static HashSet sBannedDefines = new HashSet(sOffsetTags); + static { + sBannedDefines.add(getTrueTagKey(TAG_NULL)); + sBannedDefines.add(getTrueTagKey(TAG_JPEG_INTERCHANGE_FORMAT_LENGTH)); + sBannedDefines.add(getTrueTagKey(TAG_STRIP_BYTE_COUNTS)); + } + + /** + * Returns the constant representing a tag with a given TID and default IFD. + */ + public static int defineTag(int ifdId, short tagId) { + return (tagId & 0x0000ffff) | (ifdId << 16); + } + + /** + * Returns the TID for a tag constant. + */ + public static short getTrueTagKey(int tag) { + // Truncate + return (short) tag; + } + + /** + * Returns the default IFD for a tag constant. + */ + public static int getTrueIfd(int tag) { + return tag >>> 16; + } + + /** + * Constants for {@link TAG_ORIENTATION}. They can be interpreted as + * follows: + *

    + *
  • TOP_LEFT is the normal orientation.
  • + *
  • TOP_RIGHT is a left-right mirror.
  • + *
  • BOTTOM_LEFT is a 180 degree rotation.
  • + *
  • BOTTOM_RIGHT is a top-bottom mirror.
  • + *
  • LEFT_TOP is mirrored about the top-left<->bottom-right axis.
  • + *
  • RIGHT_TOP is a 90 degree clockwise rotation.
  • + *
  • LEFT_BOTTOM is mirrored about the top-right<->bottom-left axis.
  • + *
  • RIGHT_BOTTOM is a 270 degree clockwise rotation.
  • + *
+ */ + public static interface Orientation { + public static final short TOP_LEFT = 1; + public static final short TOP_RIGHT = 2; + public static final short BOTTOM_LEFT = 3; + public static final short BOTTOM_RIGHT = 4; + public static final short LEFT_TOP = 5; + public static final short RIGHT_TOP = 6; + public static final short LEFT_BOTTOM = 7; + public static final short RIGHT_BOTTOM = 8; + } + + /** + * Constants for {@link TAG_Y_CB_CR_POSITIONING} + */ + public static interface YCbCrPositioning { + public static final short CENTERED = 1; + public static final short CO_SITED = 2; + } + + /** + * Constants for {@link TAG_COMPRESSION} + */ + public static interface Compression { + public static final short UNCOMPRESSION = 1; + public static final short JPEG = 6; + } + + /** + * Constants for {@link TAG_RESOLUTION_UNIT} + */ + public static interface ResolutionUnit { + public static final short INCHES = 2; + public static final short CENTIMETERS = 3; + } + + /** + * Constants for {@link TAG_PHOTOMETRIC_INTERPRETATION} + */ + public static interface PhotometricInterpretation { + public static final short RGB = 2; + public static final short YCBCR = 6; + } + + /** + * Constants for {@link TAG_PLANAR_CONFIGURATION} + */ + public static interface PlanarConfiguration { + public static final short CHUNKY = 1; + public static final short PLANAR = 2; + } + + /** + * Constants for {@link TAG_EXPOSURE_PROGRAM} + */ + public static interface ExposureProgram { + public static final short NOT_DEFINED = 0; + public static final short MANUAL = 1; + public static final short NORMAL_PROGRAM = 2; + public static final short APERTURE_PRIORITY = 3; + public static final short SHUTTER_PRIORITY = 4; + public static final short CREATIVE_PROGRAM = 5; + public static final short ACTION_PROGRAM = 6; + public static final short PROTRAIT_MODE = 7; + public static final short LANDSCAPE_MODE = 8; + } + + /** + * Constants for {@link TAG_METERING_MODE} + */ + public static interface MeteringMode { + public static final short UNKNOWN = 0; + public static final short AVERAGE = 1; + public static final short CENTER_WEIGHTED_AVERAGE = 2; + public static final short SPOT = 3; + public static final short MULTISPOT = 4; + public static final short PATTERN = 5; + public static final short PARTAIL = 6; + public static final short OTHER = 255; + } + + /** + * Constants for {@link TAG_FLASH} As the definition in Jeita EXIF 2.2 + * standard, we can treat this constant as bitwise flag. + *

+ * e.g. + *

+ * short flash = FIRED | RETURN_STROBE_RETURN_LIGHT_DETECTED | + * MODE_AUTO_MODE + */ + public static interface Flash { + // LSB + public static final short DID_NOT_FIRED = 0; + public static final short FIRED = 1; + // 1st~2nd bits + public static final short RETURN_NO_STROBE_RETURN_DETECTION_FUNCTION = 0 << 1; + public static final short RETURN_STROBE_RETURN_LIGHT_NOT_DETECTED = 2 << 1; + public static final short RETURN_STROBE_RETURN_LIGHT_DETECTED = 3 << 1; + // 3rd~4th bits + public static final short MODE_UNKNOWN = 0 << 3; + public static final short MODE_COMPULSORY_FLASH_FIRING = 1 << 3; + public static final short MODE_COMPULSORY_FLASH_SUPPRESSION = 2 << 3; + public static final short MODE_AUTO_MODE = 3 << 3; + // 5th bit + public static final short FUNCTION_PRESENT = 0 << 5; + public static final short FUNCTION_NO_FUNCTION = 1 << 5; + // 6th bit + public static final short RED_EYE_REDUCTION_NO_OR_UNKNOWN = 0 << 6; + public static final short RED_EYE_REDUCTION_SUPPORT = 1 << 6; + } + + /** + * Constants for {@link TAG_COLOR_SPACE} + */ + public static interface ColorSpace { + public static final short SRGB = 1; + public static final short UNCALIBRATED = (short) 0xFFFF; + } + + /** + * Constants for {@link TAG_EXPOSURE_MODE} + */ + public static interface ExposureMode { + public static final short AUTO_EXPOSURE = 0; + public static final short MANUAL_EXPOSURE = 1; + public static final short AUTO_BRACKET = 2; + } + + /** + * Constants for {@link TAG_WHITE_BALANCE} + */ + public static interface WhiteBalance { + public static final short AUTO = 0; + public static final short MANUAL = 1; + } + + /** + * Constants for {@link TAG_SCENE_CAPTURE_TYPE} + */ + public static interface SceneCapture { + public static final short STANDARD = 0; + public static final short LANDSCAPE = 1; + public static final short PROTRAIT = 2; + public static final short NIGHT_SCENE = 3; + } + + /** + * Constants for {@link TAG_COMPONENTS_CONFIGURATION} + */ + public static interface ComponentsConfiguration { + public static final short NOT_EXIST = 0; + public static final short Y = 1; + public static final short CB = 2; + public static final short CR = 3; + public static final short R = 4; + public static final short G = 5; + public static final short B = 6; + } + + /** + * Constants for {@link TAG_LIGHT_SOURCE} + */ + public static interface LightSource { + public static final short UNKNOWN = 0; + public static final short DAYLIGHT = 1; + public static final short FLUORESCENT = 2; + public static final short TUNGSTEN = 3; + public static final short FLASH = 4; + public static final short FINE_WEATHER = 9; + public static final short CLOUDY_WEATHER = 10; + public static final short SHADE = 11; + public static final short DAYLIGHT_FLUORESCENT = 12; + public static final short DAY_WHITE_FLUORESCENT = 13; + public static final short COOL_WHITE_FLUORESCENT = 14; + public static final short WHITE_FLUORESCENT = 15; + public static final short STANDARD_LIGHT_A = 17; + public static final short STANDARD_LIGHT_B = 18; + public static final short STANDARD_LIGHT_C = 19; + public static final short D55 = 20; + public static final short D65 = 21; + public static final short D75 = 22; + public static final short D50 = 23; + public static final short ISO_STUDIO_TUNGSTEN = 24; + public static final short OTHER = 255; + } + + /** + * Constants for {@link TAG_SENSING_METHOD} + */ + public static interface SensingMethod { + public static final short NOT_DEFINED = 1; + public static final short ONE_CHIP_COLOR = 2; + public static final short TWO_CHIP_COLOR = 3; + public static final short THREE_CHIP_COLOR = 4; + public static final short COLOR_SEQUENTIAL_AREA = 5; + public static final short TRILINEAR = 7; + public static final short COLOR_SEQUENTIAL_LINEAR = 8; + } + + /** + * Constants for {@link TAG_FILE_SOURCE} + */ + public static interface FileSource { + public static final short DSC = 3; + } + + /** + * Constants for {@link TAG_SCENE_TYPE} + */ + public static interface SceneType { + public static final short DIRECT_PHOTOGRAPHED = 1; + } + + /** + * Constants for {@link TAG_GAIN_CONTROL} + */ + public static interface GainControl { + public static final short NONE = 0; + public static final short LOW_UP = 1; + public static final short HIGH_UP = 2; + public static final short LOW_DOWN = 3; + public static final short HIGH_DOWN = 4; + } + + /** + * Constants for {@link TAG_CONTRAST} + */ + public static interface Contrast { + public static final short NORMAL = 0; + public static final short SOFT = 1; + public static final short HARD = 2; + } + + /** + * Constants for {@link TAG_SATURATION} + */ + public static interface Saturation { + public static final short NORMAL = 0; + public static final short LOW = 1; + public static final short HIGH = 2; + } + + /** + * Constants for {@link TAG_SHARPNESS} + */ + public static interface Sharpness { + public static final short NORMAL = 0; + public static final short SOFT = 1; + public static final short HARD = 2; + } + + /** + * Constants for {@link TAG_SUBJECT_DISTANCE} + */ + public static interface SubjectDistance { + public static final short UNKNOWN = 0; + public static final short MACRO = 1; + public static final short CLOSE_VIEW = 2; + public static final short DISTANT_VIEW = 3; + } + + /** + * Constants for {@link TAG_GPS_LATITUDE_REF}, + * {@link TAG_GPS_DEST_LATITUDE_REF} + */ + public static interface GpsLatitudeRef { + public static final String NORTH = "N"; + public static final String SOUTH = "S"; + } + + /** + * Constants for {@link TAG_GPS_LONGITUDE_REF}, + * {@link TAG_GPS_DEST_LONGITUDE_REF} + */ + public static interface GpsLongitudeRef { + public static final String EAST = "E"; + public static final String WEST = "W"; + } + + /** + * Constants for {@link TAG_GPS_ALTITUDE_REF} + */ + public static interface GpsAltitudeRef { + public static final short SEA_LEVEL = 0; + public static final short SEA_LEVEL_NEGATIVE = 1; + } + + /** + * Constants for {@link TAG_GPS_STATUS} + */ + public static interface GpsStatus { + public static final String IN_PROGRESS = "A"; + public static final String INTEROPERABILITY = "V"; + } + + /** + * Constants for {@link TAG_GPS_MEASURE_MODE} + */ + public static interface GpsMeasureMode { + public static final String MODE_2_DIMENSIONAL = "2"; + public static final String MODE_3_DIMENSIONAL = "3"; + } + + /** + * Constants for {@link TAG_GPS_SPEED_REF}, + * {@link TAG_GPS_DEST_DISTANCE_REF} + */ + public static interface GpsSpeedRef { + public static final String KILOMETERS = "K"; + public static final String MILES = "M"; + public static final String KNOTS = "N"; + } + + /** + * Constants for {@link TAG_GPS_TRACK_REF}, + * {@link TAG_GPS_IMG_DIRECTION_REF}, {@link TAG_GPS_DEST_BEARING_REF} + */ + public static interface GpsTrackRef { + public static final String TRUE_DIRECTION = "T"; + public static final String MAGNETIC_DIRECTION = "M"; + } + + /** + * Constants for {@link TAG_GPS_DIFFERENTIAL} + */ + public static interface GpsDifferential { + public static final short WITHOUT_DIFFERENTIAL_CORRECTION = 0; + public static final short DIFFERENTIAL_CORRECTION_APPLIED = 1; + } + + private static final String NULL_ARGUMENT_STRING = "Argument is null"; + private ExifData mData = new ExifData(DEFAULT_BYTE_ORDER); + public static final ByteOrder DEFAULT_BYTE_ORDER = ByteOrder.BIG_ENDIAN; + + public ExifInterface() { + mGPSDateStampFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + } + + /** + * Reads the exif tags from a byte array, clearing this ExifInterface + * object's existing exif tags. + * + * @param jpeg a byte array containing a jpeg compressed image. + * @throws java.io.IOException + */ + public void readExif(byte[] jpeg) throws IOException { + readExif(new ByteArrayInputStream(jpeg)); + } + + /** + * Reads the exif tags from an InputStream, clearing this ExifInterface + * object's existing exif tags. + * + * @param inStream an InputStream containing a jpeg compressed image. + * @throws java.io.IOException + */ + public void readExif(InputStream inStream) throws IOException { + if (inStream == null) { + throw new IllegalArgumentException(NULL_ARGUMENT_STRING); + } + ExifData d = null; + try { + d = new ExifReader(this).read(inStream); + } catch (ExifInvalidFormatException e) { + throw new IOException("Invalid exif format : " + e); + } + mData = d; + } + + /** + * Reads the exif tags from a file, clearing this ExifInterface object's + * existing exif tags. + * + * @param inFileName a string representing the filepath to jpeg file. + * @throws java.io.FileNotFoundException + * @throws java.io.IOException + */ + public void readExif(String inFileName) throws FileNotFoundException, IOException { + if (inFileName == null) { + throw new IllegalArgumentException(NULL_ARGUMENT_STRING); + } + InputStream is = null; + try { + is = new BufferedInputStream(new FileInputStream(inFileName)); + readExif(is); + } catch (IOException e) { + closeSilently(is); + throw e; + } + is.close(); + } + + /** + * Sets the exif tags, clearing this ExifInterface object's existing exif + * tags. + * + * @param tags a collection of exif tags to set. + */ + public void setExif(Collection tags) { + clearExif(); + setTags(tags); + } + + /** + * Clears this ExifInterface object's existing exif tags. + */ + public void clearExif() { + mData = new ExifData(DEFAULT_BYTE_ORDER); + } + + /** + * Writes the tags from this ExifInterface object into a jpeg image, + * removing prior exif tags. + * + * @param jpeg a byte array containing a jpeg compressed image. + * @param exifOutStream an OutputStream to which the jpeg image with added + * exif tags will be written. + * @throws java.io.IOException + */ + public void writeExif(byte[] jpeg, OutputStream exifOutStream) throws IOException { + if (jpeg == null || exifOutStream == null) { + throw new IllegalArgumentException(NULL_ARGUMENT_STRING); + } + OutputStream s = getExifWriterStream(exifOutStream); + s.write(jpeg, 0, jpeg.length); + s.flush(); + } + + /** + * Writes the tags from this ExifInterface object into a jpeg compressed + * bitmap, removing prior exif tags. + * + * @param bmap a bitmap to compress and write exif into. + * @param exifOutStream the OutputStream to which the jpeg image with added + * exif tags will be written. + * @throws java.io.IOException + */ + public void writeExif(Bitmap bmap, OutputStream exifOutStream) throws IOException { + if (bmap == null || exifOutStream == null) { + throw new IllegalArgumentException(NULL_ARGUMENT_STRING); + } + OutputStream s = getExifWriterStream(exifOutStream); + bmap.compress(Bitmap.CompressFormat.JPEG, 90, s); + s.flush(); + } + + /** + * Writes the tags from this ExifInterface object into a jpeg stream, + * removing prior exif tags. + * + * @param jpegStream an InputStream containing a jpeg compressed image. + * @param exifOutStream an OutputStream to which the jpeg image with added + * exif tags will be written. + * @throws java.io.IOException + */ + public void writeExif(InputStream jpegStream, OutputStream exifOutStream) throws IOException { + if (jpegStream == null || exifOutStream == null) { + throw new IllegalArgumentException(NULL_ARGUMENT_STRING); + } + OutputStream s = getExifWriterStream(exifOutStream); + doExifStreamIO(jpegStream, s); + s.flush(); + } + + /** + * Writes the tags from this ExifInterface object into a jpeg image, + * removing prior exif tags. + * + * @param jpeg a byte array containing a jpeg compressed image. + * @param exifOutFileName a String containing the filepath to which the jpeg + * image with added exif tags will be written. + * @throws java.io.FileNotFoundException + * @throws java.io.IOException + */ + public void writeExif(byte[] jpeg, String exifOutFileName) throws FileNotFoundException, + IOException { + if (jpeg == null || exifOutFileName == null) { + throw new IllegalArgumentException(NULL_ARGUMENT_STRING); + } + OutputStream s = null; + try { + s = getExifWriterStream(exifOutFileName); + s.write(jpeg, 0, jpeg.length); + s.flush(); + } catch (IOException e) { + closeSilently(s); + throw e; + } + s.close(); + } + + /** + * Writes the tags from this ExifInterface object into a jpeg compressed + * bitmap, removing prior exif tags. + * + * @param bmap a bitmap to compress and write exif into. + * @param exifOutFileName a String containing the filepath to which the jpeg + * image with added exif tags will be written. + * @throws java.io.FileNotFoundException + * @throws java.io.IOException + */ + public void writeExif(Bitmap bmap, String exifOutFileName) throws FileNotFoundException, + IOException { + if (bmap == null || exifOutFileName == null) { + throw new IllegalArgumentException(NULL_ARGUMENT_STRING); + } + OutputStream s = null; + try { + s = getExifWriterStream(exifOutFileName); + bmap.compress(Bitmap.CompressFormat.JPEG, 90, s); + s.flush(); + } catch (IOException e) { + closeSilently(s); + throw e; + } + s.close(); + } + + /** + * Writes the tags from this ExifInterface object into a jpeg stream, + * removing prior exif tags. + * + * @param jpegStream an InputStream containing a jpeg compressed image. + * @param exifOutFileName a String containing the filepath to which the jpeg + * image with added exif tags will be written. + * @throws java.io.FileNotFoundException + * @throws java.io.IOException + */ + public void writeExif(InputStream jpegStream, String exifOutFileName) + throws FileNotFoundException, IOException { + if (jpegStream == null || exifOutFileName == null) { + throw new IllegalArgumentException(NULL_ARGUMENT_STRING); + } + OutputStream s = null; + try { + s = getExifWriterStream(exifOutFileName); + doExifStreamIO(jpegStream, s); + s.flush(); + } catch (IOException e) { + closeSilently(s); + throw e; + } + s.close(); + } + + /** + * Writes the tags from this ExifInterface object into a jpeg file, removing + * prior exif tags. + * + * @param jpegFileName a String containing the filepath for a jpeg file. + * @param exifOutFileName a String containing the filepath to which the jpeg + * image with added exif tags will be written. + * @throws java.io.FileNotFoundException + * @throws java.io.IOException + */ + public void writeExif(String jpegFileName, String exifOutFileName) + throws FileNotFoundException, IOException { + if (jpegFileName == null || exifOutFileName == null) { + throw new IllegalArgumentException(NULL_ARGUMENT_STRING); + } + InputStream is = null; + try { + is = new FileInputStream(jpegFileName); + writeExif(is, exifOutFileName); + } catch (IOException e) { + closeSilently(is); + throw e; + } + is.close(); + } + + /** + * Wraps an OutputStream object with an ExifOutputStream. Exif tags in this + * ExifInterface object will be added to a jpeg image written to this + * stream, removing prior exif tags. Other methods of this ExifInterface + * object should not be called until the returned OutputStream has been + * closed. + * + * @param outStream an OutputStream to wrap. + * @return an OutputStream that wraps the outStream parameter, and adds exif + * metadata. A jpeg image should be written to this stream. + */ + public OutputStream getExifWriterStream(OutputStream outStream) { + if (outStream == null) { + throw new IllegalArgumentException(NULL_ARGUMENT_STRING); + } + ExifOutputStream eos = new ExifOutputStream(outStream, this); + eos.setExifData(mData); + return eos; + } + + /** + * Returns an OutputStream object that writes to a file. Exif tags in this + * ExifInterface object will be added to a jpeg image written to this + * stream, removing prior exif tags. Other methods of this ExifInterface + * object should not be called until the returned OutputStream has been + * closed. + * + * @param exifOutFileName an String containing a filepath for a jpeg file. + * @return an OutputStream that writes to the exifOutFileName file, and adds + * exif metadata. A jpeg image should be written to this stream. + * @throws java.io.FileNotFoundException + */ + public OutputStream getExifWriterStream(String exifOutFileName) throws FileNotFoundException { + if (exifOutFileName == null) { + throw new IllegalArgumentException(NULL_ARGUMENT_STRING); + } + OutputStream out = null; + try { + out = new FileOutputStream(exifOutFileName); + } catch (FileNotFoundException e) { + closeSilently(out); + throw e; + } + return getExifWriterStream(out); + } + + /** + * Attempts to do an in-place rewrite the exif metadata in a file for the + * given tags. If tags do not exist or do not have the same size as the + * existing exif tags, this method will fail. + * + * @param filename a String containing a filepath for a jpeg file with exif + * tags to rewrite. + * @param tags tags that will be written into the jpeg file over existing + * tags if possible. + * @return true if success, false if could not overwrite. If false, no + * changes are made to the file. + * @throws java.io.FileNotFoundException + * @throws java.io.IOException + */ + public boolean rewriteExif(String filename, Collection tags) + throws FileNotFoundException, IOException { + RandomAccessFile file = null; + InputStream is = null; + boolean ret; + try { + File temp = new File(filename); + is = new BufferedInputStream(new FileInputStream(temp)); + + // Parse beginning of APP1 in exif to find size of exif header. + ExifParser parser = null; + try { + parser = ExifParser.parse(is, this); + } catch (ExifInvalidFormatException e) { + throw new IOException("Invalid exif format : ", e); + } + long exifSize = parser.getOffsetToExifEndFromSOF(); + + // Free up resources + is.close(); + is = null; + + // Open file for memory mapping. + file = new RandomAccessFile(temp, "rw"); + long fileLength = file.length(); + if (fileLength < exifSize) { + throw new IOException("Filesize changed during operation"); + } + + // Map only exif header into memory. + ByteBuffer buf = file.getChannel().map(MapMode.READ_WRITE, 0, exifSize); + + // Attempt to overwrite tag values without changing lengths (avoids + // file copy). + ret = rewriteExif(buf, tags); + } catch (IOException e) { + closeSilently(file); + throw e; + } finally { + closeSilently(is); + } + file.close(); + return ret; + } + + /** + * Attempts to do an in-place rewrite the exif metadata in a ByteBuffer for + * the given tags. If tags do not exist or do not have the same size as the + * existing exif tags, this method will fail. + * + * @param buf a ByteBuffer containing a jpeg file with existing exif tags to + * rewrite. + * @param tags tags that will be written into the jpeg ByteBuffer over + * existing tags if possible. + * @return true if success, false if could not overwrite. If false, no + * changes are made to the ByteBuffer. + * @throws java.io.IOException + */ + public boolean rewriteExif(ByteBuffer buf, Collection tags) throws IOException { + ExifModifier mod = null; + try { + mod = new ExifModifier(buf, this); + for (ExifTag t : tags) { + mod.modifyTag(t); + } + return mod.commit(); + } catch (ExifInvalidFormatException e) { + throw new IOException("Invalid exif format : " + e); + } + } + + /** + * Attempts to do an in-place rewrite of the exif metadata. If this fails, + * fall back to overwriting file. This preserves tags that are not being + * rewritten. + * + * @param filename a String containing a filepath for a jpeg file. + * @param tags tags that will be written into the jpeg file over existing + * tags if possible. + * @throws java.io.FileNotFoundException + * @throws java.io.IOException + * @see #rewriteExif + */ + public void forceRewriteExif(String filename, Collection tags) + throws FileNotFoundException, + IOException { + // Attempt in-place write + if (!rewriteExif(filename, tags)) { + // Fall back to doing a copy + ExifData tempData = mData; + mData = new ExifData(DEFAULT_BYTE_ORDER); + FileInputStream is = null; + ByteArrayOutputStream bytes = null; + try { + is = new FileInputStream(filename); + bytes = new ByteArrayOutputStream(); + doExifStreamIO(is, bytes); + byte[] imageBytes = bytes.toByteArray(); + readExif(imageBytes); + setTags(tags); + writeExif(imageBytes, filename); + } catch (IOException e) { + closeSilently(is); + throw e; + } finally { + is.close(); + // Prevent clobbering of mData + mData = tempData; + } + } + } + + /** + * Attempts to do an in-place rewrite of the exif metadata using the tags in + * this ExifInterface object. If this fails, fall back to overwriting file. + * This preserves tags that are not being rewritten. + * + * @param filename a String containing a filepath for a jpeg file. + * @throws java.io.FileNotFoundException + * @throws java.io.IOException + * @see #rewriteExif + */ + public void forceRewriteExif(String filename) throws FileNotFoundException, IOException { + forceRewriteExif(filename, getAllTags()); + } + + /** + * Get the exif tags in this ExifInterface object or null if none exist. + * + * @return a List of {@link ExifTag}s. + */ + public List getAllTags() { + return mData.getAllTags(); + } + + /** + * Returns a list of ExifTags that share a TID (which can be obtained by + * calling {@link #getTrueTagKey} on a defined tag constant) or null if none + * exist. + * + * @param tagId a TID as defined in the exif standard (or with + * {@link #defineTag}). + * @return a List of {@link ExifTag}s. + */ + public List getTagsForTagId(short tagId) { + return mData.getAllTagsForTagId(tagId); + } + + /** + * Returns a list of ExifTags that share an IFD (which can be obtained by + * calling {@link #getTrueIFD} on a defined tag constant) or null if none + * exist. + * + * @param ifdId an IFD as defined in the exif standard (or with + * {@link #defineTag}). + * @return a List of {@link ExifTag}s. + */ + public List getTagsForIfdId(int ifdId) { + return mData.getAllTagsForIfd(ifdId); + } + + /** + * Gets an ExifTag for an IFD other than the tag's default. + * + * @see #getTag + */ + public ExifTag getTag(int tagId, int ifdId) { + if (!ExifTag.isValidIfd(ifdId)) { + return null; + } + return mData.getTag(getTrueTagKey(tagId), ifdId); + } + + /** + * Returns the ExifTag in that tag's default IFD for a defined tag constant + * or null if none exists. + * + * @param tagId a defined tag constant, e.g. {@link #TAG_IMAGE_WIDTH}. + * @return an {@link ExifTag} or null if none exists. + */ + public ExifTag getTag(int tagId) { + int ifdId = getDefinedTagDefaultIfd(tagId); + return getTag(tagId, ifdId); + } + + /** + * Gets a tag value for an IFD other than the tag's default. + * + * @see #getTagValue + */ + public Object getTagValue(int tagId, int ifdId) { + ExifTag t = getTag(tagId, ifdId); + return (t == null) ? null : t.getValue(); + } + + /** + * Returns the value of the ExifTag in that tag's default IFD for a defined + * tag constant or null if none exists or the value could not be cast into + * the return type. + * + * @param tagId a defined tag constant, e.g. {@link #TAG_IMAGE_WIDTH}. + * @return the value of the ExifTag or null if none exists. + */ + public Object getTagValue(int tagId) { + int ifdId = getDefinedTagDefaultIfd(tagId); + return getTagValue(tagId, ifdId); + } + + /* + * Getter methods that are similar to getTagValue. Null is returned if the + * tag value cannot be cast into the return type. + */ + + /** + * @see #getTagValue + */ + public String getTagStringValue(int tagId, int ifdId) { + ExifTag t = getTag(tagId, ifdId); + if (t == null) { + return null; + } + return t.getValueAsString(); + } + + /** + * @see #getTagValue + */ + public String getTagStringValue(int tagId) { + int ifdId = getDefinedTagDefaultIfd(tagId); + return getTagStringValue(tagId, ifdId); + } + + /** + * @see #getTagValue + */ + public Long getTagLongValue(int tagId, int ifdId) { + long[] l = getTagLongValues(tagId, ifdId); + if (l == null || l.length <= 0) { + return null; + } + return new Long(l[0]); + } + + /** + * @see #getTagValue + */ + public Long getTagLongValue(int tagId) { + int ifdId = getDefinedTagDefaultIfd(tagId); + return getTagLongValue(tagId, ifdId); + } + + /** + * @see #getTagValue + */ + public Integer getTagIntValue(int tagId, int ifdId) { + int[] l = getTagIntValues(tagId, ifdId); + if (l == null || l.length <= 0) { + return null; + } + return new Integer(l[0]); + } + + /** + * @see #getTagValue + */ + public Integer getTagIntValue(int tagId) { + int ifdId = getDefinedTagDefaultIfd(tagId); + return getTagIntValue(tagId, ifdId); + } + + /** + * @see #getTagValue + */ + public Byte getTagByteValue(int tagId, int ifdId) { + byte[] l = getTagByteValues(tagId, ifdId); + if (l == null || l.length <= 0) { + return null; + } + return new Byte(l[0]); + } + + /** + * @see #getTagValue + */ + public Byte getTagByteValue(int tagId) { + int ifdId = getDefinedTagDefaultIfd(tagId); + return getTagByteValue(tagId, ifdId); + } + + /** + * @see #getTagValue + */ + public Rational getTagRationalValue(int tagId, int ifdId) { + Rational[] l = getTagRationalValues(tagId, ifdId); + if (l == null || l.length == 0) { + return null; + } + return new Rational(l[0]); + } + + /** + * @see #getTagValue + */ + public Rational getTagRationalValue(int tagId) { + int ifdId = getDefinedTagDefaultIfd(tagId); + return getTagRationalValue(tagId, ifdId); + } + + /** + * @see #getTagValue + */ + public long[] getTagLongValues(int tagId, int ifdId) { + ExifTag t = getTag(tagId, ifdId); + if (t == null) { + return null; + } + return t.getValueAsLongs(); + } + + /** + * @see #getTagValue + */ + public long[] getTagLongValues(int tagId) { + int ifdId = getDefinedTagDefaultIfd(tagId); + return getTagLongValues(tagId, ifdId); + } + + /** + * @see #getTagValue + */ + public int[] getTagIntValues(int tagId, int ifdId) { + ExifTag t = getTag(tagId, ifdId); + if (t == null) { + return null; + } + return t.getValueAsInts(); + } + + /** + * @see #getTagValue + */ + public int[] getTagIntValues(int tagId) { + int ifdId = getDefinedTagDefaultIfd(tagId); + return getTagIntValues(tagId, ifdId); + } + + /** + * @see #getTagValue + */ + public byte[] getTagByteValues(int tagId, int ifdId) { + ExifTag t = getTag(tagId, ifdId); + if (t == null) { + return null; + } + return t.getValueAsBytes(); + } + + /** + * @see #getTagValue + */ + public byte[] getTagByteValues(int tagId) { + int ifdId = getDefinedTagDefaultIfd(tagId); + return getTagByteValues(tagId, ifdId); + } + + /** + * @see #getTagValue + */ + public Rational[] getTagRationalValues(int tagId, int ifdId) { + ExifTag t = getTag(tagId, ifdId); + if (t == null) { + return null; + } + return t.getValueAsRationals(); + } + + /** + * @see #getTagValue + */ + public Rational[] getTagRationalValues(int tagId) { + int ifdId = getDefinedTagDefaultIfd(tagId); + return getTagRationalValues(tagId, ifdId); + } + + /** + * Checks whether a tag has a defined number of elements. + * + * @param tagId a defined tag constant, e.g. {@link #TAG_IMAGE_WIDTH}. + * @return true if the tag has a defined number of elements. + */ + public boolean isTagCountDefined(int tagId) { + int info = getTagInfo().get(tagId); + // No value in info can be zero, as all tags have a non-zero type + if (info == 0) { + return false; + } + return getComponentCountFromInfo(info) != ExifTag.SIZE_UNDEFINED; + } + + /** + * Gets the defined number of elements for a tag. + * + * @param tagId a defined tag constant, e.g. {@link #TAG_IMAGE_WIDTH}. + * @return the number of elements or {@link ExifTag#SIZE_UNDEFINED} if the + * tag or the number of elements is not defined. + */ + public int getDefinedTagCount(int tagId) { + int info = getTagInfo().get(tagId); + if (info == 0) { + return ExifTag.SIZE_UNDEFINED; + } + return getComponentCountFromInfo(info); + } + + /** + * Gets the number of elements for an ExifTag in a given IFD. + * + * @param tagId a defined tag constant, e.g. {@link #TAG_IMAGE_WIDTH}. + * @param ifdId the IFD containing the ExifTag to check. + * @return the number of elements in the ExifTag, if the tag's size is + * undefined this will return the actual number of elements that is + * in the ExifTag's value. + */ + public int getActualTagCount(int tagId, int ifdId) { + ExifTag t = getTag(tagId, ifdId); + if (t == null) { + return 0; + } + return t.getComponentCount(); + } + + /** + * Gets the default IFD for a tag. + * + * @param tagId a defined tag constant, e.g. {@link #TAG_IMAGE_WIDTH}. + * @return the default IFD for a tag definition or {@link #IFD_NULL} if no + * definition exists. + */ + public int getDefinedTagDefaultIfd(int tagId) { + int info = getTagInfo().get(tagId); + if (info == DEFINITION_NULL) { + return IFD_NULL; + } + return getTrueIfd(tagId); + } + + /** + * Gets the defined type for a tag. + * + * @param tagId a defined tag constant, e.g. {@link #TAG_IMAGE_WIDTH}. + * @return the type. + * @see ExifTag#getDataType() + */ + public short getDefinedTagType(int tagId) { + int info = getTagInfo().get(tagId); + if (info == 0) { + return -1; + } + return getTypeFromInfo(info); + } + + /** + * Returns true if tag TID is one of the following: {@link TAG_EXIF_IFD}, + * {@link TAG_GPS_IFD}, {@link TAG_JPEG_INTERCHANGE_FORMAT}, + * {@link TAG_STRIP_OFFSETS}, {@link TAG_INTEROPERABILITY_IFD} + *

+ * Note: defining tags with these TID's is disallowed. + * + * @param tag a tag's TID (can be obtained from a defined tag constant with + * {@link #getTrueTagKey}). + * @return true if the TID is that of an offset tag. + */ + protected static boolean isOffsetTag(short tag) { + return sOffsetTags.contains(tag); + } + + /** + * Creates a tag for a defined tag constant in a given IFD if that IFD is + * allowed for the tag. This method will fail anytime the appropriate + * {@link ExifTag#setValue} for this tag's datatype would fail. + * + * @param tagId a tag constant, e.g. {@link #TAG_IMAGE_WIDTH}. + * @param ifdId the IFD that the tag should be in. + * @param val the value of the tag to set. + * @return an ExifTag object or null if one could not be constructed. + * @see #buildTag + */ + public ExifTag buildTag(int tagId, int ifdId, Object val) { + int info = getTagInfo().get(tagId); + if (info == 0 || val == null) { + return null; + } + short type = getTypeFromInfo(info); + int definedCount = getComponentCountFromInfo(info); + boolean hasDefinedCount = (definedCount != ExifTag.SIZE_UNDEFINED); + if (!ExifInterface.isIfdAllowed(info, ifdId)) { + return null; + } + ExifTag t = new ExifTag(getTrueTagKey(tagId), type, definedCount, ifdId, hasDefinedCount); + if (!t.setValue(val)) { + return null; + } + return t; + } + + /** + * Creates a tag for a defined tag constant in the tag's default IFD. + * + * @param tagId a tag constant, e.g. {@link #TAG_IMAGE_WIDTH}. + * @param val the tag's value. + * @return an ExifTag object. + */ + public ExifTag buildTag(int tagId, Object val) { + int ifdId = getTrueIfd(tagId); + return buildTag(tagId, ifdId, val); + } + + protected ExifTag buildUninitializedTag(int tagId) { + int info = getTagInfo().get(tagId); + if (info == 0) { + return null; + } + short type = getTypeFromInfo(info); + int definedCount = getComponentCountFromInfo(info); + boolean hasDefinedCount = (definedCount != ExifTag.SIZE_UNDEFINED); + int ifdId = getTrueIfd(tagId); + ExifTag t = new ExifTag(getTrueTagKey(tagId), type, definedCount, ifdId, hasDefinedCount); + return t; + } + + /** + * Sets the value of an ExifTag if it exists in the given IFD. The value + * must be the correct type and length for that ExifTag. + * + * @param tagId a tag constant, e.g. {@link #TAG_IMAGE_WIDTH}. + * @param ifdId the IFD that the ExifTag is in. + * @param val the value to set. + * @return true if success, false if the ExifTag doesn't exist or the value + * is the wrong type/length. + * @see #setTagValue + */ + public boolean setTagValue(int tagId, int ifdId, Object val) { + ExifTag t = getTag(tagId, ifdId); + if (t == null) { + return false; + } + return t.setValue(val); + } + + /** + * Sets the value of an ExifTag if it exists it's default IFD. The value + * must be the correct type and length for that ExifTag. + * + * @param tagId a tag constant, e.g. {@link #TAG_IMAGE_WIDTH}. + * @param val the value to set. + * @return true if success, false if the ExifTag doesn't exist or the value + * is the wrong type/length. + */ + public boolean setTagValue(int tagId, Object val) { + int ifdId = getDefinedTagDefaultIfd(tagId); + return setTagValue(tagId, ifdId, val); + } + + /** + * Puts an ExifTag into this ExifInterface object's tags, removing a + * previous ExifTag with the same TID and IFD. The IFD it is put into will + * be the one the tag was created with in {@link #buildTag}. + * + * @param tag an ExifTag to put into this ExifInterface's tags. + * @return the previous ExifTag with the same TID and IFD or null if none + * exists. + */ + public ExifTag setTag(ExifTag tag) { + return mData.addTag(tag); + } + + /** + * Puts a collection of ExifTags into this ExifInterface objects's tags. Any + * previous ExifTags with the same TID and IFDs will be removed. + * + * @param tags a Collection of ExifTags. + * @see #setTag + */ + public void setTags(Collection tags) { + for (ExifTag t : tags) { + setTag(t); + } + } + + /** + * Removes the ExifTag for a tag constant from the given IFD. + * + * @param tagId a tag constant, e.g. {@link #TAG_IMAGE_WIDTH}. + * @param ifdId the IFD of the ExifTag to remove. + */ + public void deleteTag(int tagId, int ifdId) { + mData.removeTag(getTrueTagKey(tagId), ifdId); + } + + /** + * Removes the ExifTag for a tag constant from that tag's default IFD. + * + * @param tagId a tag constant, e.g. {@link #TAG_IMAGE_WIDTH}. + */ + public void deleteTag(int tagId) { + int ifdId = getDefinedTagDefaultIfd(tagId); + deleteTag(tagId, ifdId); + } + + /** + * Creates a new tag definition in this ExifInterface object for a given TID + * and default IFD. Creating a definition with the same TID and default IFD + * as a previous definition will override it. + * + * @param tagId the TID for the tag. + * @param defaultIfd the default IFD for the tag. + * @param tagType the type of the tag (see {@link ExifTag#getDataType()}). + * @param defaultComponentCount the number of elements of this tag's type in + * the tags value. + * @param allowedIfds the IFD's this tag is allowed to be put in. + * @return the defined tag constant (e.g. {@link #TAG_IMAGE_WIDTH}) or + * {@link #TAG_NULL} if the definition could not be made. + */ + public int setTagDefinition(short tagId, int defaultIfd, short tagType, + short defaultComponentCount, int[] allowedIfds) { + if (sBannedDefines.contains(tagId)) { + return TAG_NULL; + } + if (ExifTag.isValidType(tagType) && ExifTag.isValidIfd(defaultIfd)) { + int tagDef = defineTag(defaultIfd, tagId); + if (tagDef == TAG_NULL) { + return TAG_NULL; + } + int[] otherDefs = getTagDefinitionsForTagId(tagId); + SparseIntArray infos = getTagInfo(); + // Make sure defaultIfd is in allowedIfds + boolean defaultCheck = false; + for (int i : allowedIfds) { + if (defaultIfd == i) { + defaultCheck = true; + } + if (!ExifTag.isValidIfd(i)) { + return TAG_NULL; + } + } + if (!defaultCheck) { + return TAG_NULL; + } + + int ifdFlags = getFlagsFromAllowedIfds(allowedIfds); + // Make sure no identical tags can exist in allowedIfds + if (otherDefs != null) { + for (int def : otherDefs) { + int tagInfo = infos.get(def); + int allowedFlags = getAllowedIfdFlagsFromInfo(tagInfo); + if ((ifdFlags & allowedFlags) != 0) { + return TAG_NULL; + } + } + } + getTagInfo().put(tagDef, ifdFlags << 24 | (tagType << 16) | defaultComponentCount); + return tagDef; + } + return TAG_NULL; + } + + protected int getTagDefinition(short tagId, int defaultIfd) { + return getTagInfo().get(defineTag(defaultIfd, tagId)); + } + + protected int[] getTagDefinitionsForTagId(short tagId) { + int[] ifds = IfdData.getIfds(); + int[] defs = new int[ifds.length]; + int counter = 0; + SparseIntArray infos = getTagInfo(); + for (int i : ifds) { + int def = defineTag(i, tagId); + if (infos.get(def) != DEFINITION_NULL) { + defs[counter++] = def; + } + } + if (counter == 0) { + return null; + } + + return Arrays.copyOfRange(defs, 0, counter); + } + + protected int getTagDefinitionForTag(ExifTag tag) { + short type = tag.getDataType(); + int count = tag.getComponentCount(); + int ifd = tag.getIfd(); + return getTagDefinitionForTag(tag.getTagId(), type, count, ifd); + } + + protected int getTagDefinitionForTag(short tagId, short type, int count, int ifd) { + int[] defs = getTagDefinitionsForTagId(tagId); + if (defs == null) { + return TAG_NULL; + } + SparseIntArray infos = getTagInfo(); + int ret = TAG_NULL; + for (int i : defs) { + int info = infos.get(i); + short defType = getTypeFromInfo(info); + int defCount = getComponentCountFromInfo(info); + int[] defIfds = getAllowedIfdsFromInfo(info); + boolean validIfd = false; + for (int j : defIfds) { + if (j == ifd) { + validIfd = true; + break; + } + } + if (validIfd && type == defType + && (count == defCount || defCount == ExifTag.SIZE_UNDEFINED)) { + ret = i; + break; + } + } + return ret; + } + + /** + * Removes a tag definition for given defined tag constant. + * + * @param tagId a defined tag constant, e.g. {@link #TAG_IMAGE_WIDTH}. + */ + public void removeTagDefinition(int tagId) { + getTagInfo().delete(tagId); + } + + /** + * Resets tag definitions to the default ones. + */ + public void resetTagDefinitions() { + mTagInfo = null; + } + + /** + * Returns the thumbnail from IFD1 as a bitmap, or null if none exists. + * + * @return the thumbnail as a bitmap. + */ + public Bitmap getThumbnailBitmap() { + if (mData.hasCompressedThumbnail()) { + byte[] thumb = mData.getCompressedThumbnail(); + return BitmapFactory.decodeByteArray(thumb, 0, thumb.length); + } else if (mData.hasUncompressedStrip()) { + // TODO: implement uncompressed + } + return null; + } + + /** + * Returns the thumbnail from IFD1 as a byte array, or null if none exists. + * The bytes may either be an uncompressed strip as specified in the exif + * standard or a jpeg compressed image. + * + * @return the thumbnail as a byte array. + */ + public byte[] getThumbnailBytes() { + if (mData.hasCompressedThumbnail()) { + return mData.getCompressedThumbnail(); + } else if (mData.hasUncompressedStrip()) { + // TODO: implement this + } + return null; + } + + /** + * Returns the thumbnail if it is jpeg compressed, or null if none exists. + * + * @return the thumbnail as a byte array. + */ + public byte[] getThumbnail() { + return mData.getCompressedThumbnail(); + } + + /** + * Check if thumbnail is compressed. + * + * @return true if the thumbnail is compressed. + */ + public boolean isThumbnailCompressed() { + return mData.hasCompressedThumbnail(); + } + + /** + * Check if thumbnail exists. + * + * @return true if a compressed thumbnail exists. + */ + public boolean hasThumbnail() { + // TODO: add back in uncompressed strip + return mData.hasCompressedThumbnail(); + } + + // TODO: uncompressed thumbnail setters + + /** + * Sets the thumbnail to be a jpeg compressed image. Clears any prior + * thumbnail. + * + * @param thumb a byte array containing a jpeg compressed image. + * @return true if the thumbnail was set. + */ + public boolean setCompressedThumbnail(byte[] thumb) { + mData.clearThumbnailAndStrips(); + mData.setCompressedThumbnail(thumb); + return true; + } + + /** + * Sets the thumbnail to be a jpeg compressed bitmap. Clears any prior + * thumbnail. + * + * @param thumb a bitmap to compress to a jpeg thumbnail. + * @return true if the thumbnail was set. + */ + public boolean setCompressedThumbnail(Bitmap thumb) { + ByteArrayOutputStream thumbnail = new ByteArrayOutputStream(); + if (!thumb.compress(Bitmap.CompressFormat.JPEG, 90, thumbnail)) { + return false; + } + return setCompressedThumbnail(thumbnail.toByteArray()); + } + + /** + * Clears the compressed thumbnail if it exists. + */ + public void removeCompressedThumbnail() { + mData.setCompressedThumbnail(null); + } + + // Convenience methods: + + /** + * Decodes the user comment tag into string as specified in the EXIF + * standard. Returns null if decoding failed. + */ + public String getUserComment() { + return mData.getUserComment(); + } + + /** + * Returns the Orientation ExifTag value for a given number of degrees. + * + * @param degrees the amount an image is rotated in degrees. + */ + public static short getOrientationValueForRotation(int degrees) { + degrees %= 360; + if (degrees < 0) { + degrees += 360; + } + if (degrees < 90) { + return Orientation.TOP_LEFT; // 0 degrees + } else if (degrees < 180) { + return Orientation.RIGHT_TOP; // 90 degrees cw + } else if (degrees < 270) { + return Orientation.BOTTOM_LEFT; // 180 degrees + } else { + return Orientation.RIGHT_BOTTOM; // 270 degrees cw + } + } + + /** + * Returns the rotation degrees corresponding to an ExifTag Orientation + * value. + * + * @param orientation the ExifTag Orientation value. + */ + public static int getRotationForOrientationValue(short orientation) { + switch (orientation) { + case Orientation.TOP_LEFT: + return 0; + case Orientation.RIGHT_TOP: + return 90; + case Orientation.BOTTOM_LEFT: + return 180; + case Orientation.RIGHT_BOTTOM: + return 270; + default: + return 0; + } + } + + public static OrientationParams getOrientationParams(int orientation) { + OrientationParams params = new OrientationParams(); + switch (orientation) { + case Orientation.TOP_RIGHT: // Flip horizontal + params.scaleX = -1; + break; + case Orientation.BOTTOM_RIGHT: // Flip vertical + params.scaleY = -1; + break; + case Orientation.BOTTOM_LEFT: // Rotate 180 + params.rotation = 180; + break; + case Orientation.RIGHT_BOTTOM: // Rotate 270 + params.rotation = 270; + params.invertDimensions = true; + break; + case Orientation.RIGHT_TOP: // Rotate 90 + params.rotation = 90; + params.invertDimensions = true; + break; + case Orientation.LEFT_TOP: // Transpose + params.rotation = 90; + params.scaleX = -1; + params.invertDimensions = true; + break; + case Orientation.LEFT_BOTTOM: // Transverse + params.rotation = 270; + params.scaleX = -1; + params.invertDimensions = true; + break; + } + return params; + } + + public static class OrientationParams { + public int rotation = 0; + public int scaleX = 1; + public int scaleY = 1; + public boolean invertDimensions = false; + } + + /** + * Gets the double representation of the GPS latitude or longitude + * coordinate. + * + * @param coordinate an array of 3 Rationals representing the degrees, + * minutes, and seconds of the GPS location as defined in the + * exif specification. + * @param reference a GPS reference reperesented by a String containing "N", + * "S", "E", or "W". + * @return the GPS coordinate represented as degrees + minutes/60 + + * seconds/3600 + */ + public static double convertLatOrLongToDouble(Rational[] coordinate, String reference) { + try { + double degrees = coordinate[0].toDouble(); + double minutes = coordinate[1].toDouble(); + double seconds = coordinate[2].toDouble(); + double result = degrees + minutes / 60.0 + seconds / 3600.0; + if ((reference.equals("S") || reference.equals("W"))) { + return -result; + } + return result; + } catch (ArrayIndexOutOfBoundsException e) { + throw new IllegalArgumentException(); + } + } + + /** + * Gets the GPS latitude and longitude as a pair of doubles from this + * ExifInterface object's tags, or null if the necessary tags do not exist. + * + * @return an array of 2 doubles containing the latitude, and longitude + * respectively. + * @see #convertLatOrLongToDouble + */ + public double[] getLatLongAsDoubles() { + Rational[] latitude = getTagRationalValues(TAG_GPS_LATITUDE); + String latitudeRef = getTagStringValue(TAG_GPS_LATITUDE_REF); + Rational[] longitude = getTagRationalValues(TAG_GPS_LONGITUDE); + String longitudeRef = getTagStringValue(TAG_GPS_LONGITUDE_REF); + if (latitude == null || longitude == null || latitudeRef == null || longitudeRef == null + || latitude.length < 3 || longitude.length < 3) { + return null; + } + double[] latLon = new double[2]; + latLon[0] = convertLatOrLongToDouble(latitude, latitudeRef); + latLon[1] = convertLatOrLongToDouble(longitude, longitudeRef); + return latLon; + } + + private static final String GPS_DATE_FORMAT_STR = "yyyy:MM:dd"; + private static final String DATETIME_FORMAT_STR = "yyyy:MM:dd kk:mm:ss"; + private final DateFormat mDateTimeStampFormat = new SimpleDateFormat(DATETIME_FORMAT_STR); + private final DateFormat mGPSDateStampFormat = new SimpleDateFormat(GPS_DATE_FORMAT_STR); + private final Calendar mGPSTimeStampCalendar = Calendar + .getInstance(TimeZone.getTimeZone("UTC")); + + /** + * Creates, formats, and sets the DateTimeStamp tag for one of: + * {@link #TAG_DATE_TIME}, {@link #TAG_DATE_TIME_DIGITIZED}, + * {@link #TAG_DATE_TIME_ORIGINAL}. + * + * @param tagId one of the DateTimeStamp tags. + * @param timestamp a timestamp to format. + * @param timezone a TimeZone object. + * @return true if success, false if the tag could not be set. + */ + public boolean addDateTimeStampTag(int tagId, long timestamp, TimeZone timezone) { + if (tagId == TAG_DATE_TIME || tagId == TAG_DATE_TIME_DIGITIZED + || tagId == TAG_DATE_TIME_ORIGINAL) { + mDateTimeStampFormat.setTimeZone(timezone); + ExifTag t = buildTag(tagId, mDateTimeStampFormat.format(timestamp)); + if (t == null) { + return false; + } + setTag(t); + } else { + return false; + } + return true; + } + + /** + * Creates and sets all to the GPS tags for a give latitude and longitude. + * + * @param latitude a GPS latitude coordinate. + * @param longitude a GPS longitude coordinate. + * @return true if success, false if they could not be created or set. + */ + public boolean addGpsTags(double latitude, double longitude) { + ExifTag latTag = buildTag(TAG_GPS_LATITUDE, toExifLatLong(latitude)); + ExifTag longTag = buildTag(TAG_GPS_LONGITUDE, toExifLatLong(longitude)); + ExifTag latRefTag = buildTag(TAG_GPS_LATITUDE_REF, + latitude >= 0 ? GpsLatitudeRef.NORTH + : GpsLatitudeRef.SOUTH); + ExifTag longRefTag = buildTag(TAG_GPS_LONGITUDE_REF, + longitude >= 0 ? GpsLongitudeRef.EAST + : GpsLongitudeRef.WEST); + if (latTag == null || longTag == null || latRefTag == null || longRefTag == null) { + return false; + } + setTag(latTag); + setTag(longTag); + setTag(latRefTag); + setTag(longRefTag); + return true; + } + + /** + * Creates and sets the GPS timestamp tag. + * + * @param timestamp a GPS timestamp. + * @return true if success, false if could not be created or set. + */ + public boolean addGpsDateTimeStampTag(long timestamp) { + ExifTag t = buildTag(TAG_GPS_DATE_STAMP, mGPSDateStampFormat.format(timestamp)); + if (t == null) { + return false; + } + setTag(t); + mGPSTimeStampCalendar.setTimeInMillis(timestamp); + t = buildTag(TAG_GPS_TIME_STAMP, new Rational[] { + new Rational(mGPSTimeStampCalendar.get(Calendar.HOUR_OF_DAY), 1), + new Rational(mGPSTimeStampCalendar.get(Calendar.MINUTE), 1), + new Rational(mGPSTimeStampCalendar.get(Calendar.SECOND), 1) + }); + if (t == null) { + return false; + } + setTag(t); + return true; + } + + private static Rational[] toExifLatLong(double value) { + // convert to the format dd/1 mm/1 ssss/100 + value = Math.abs(value); + int degrees = (int) value; + value = (value - degrees) * 60; + int minutes = (int) value; + value = (value - minutes) * 6000; + int seconds = (int) value; + return new Rational[] { + new Rational(degrees, 1), new Rational(minutes, 1), new Rational(seconds, 100) + }; + } + + private void doExifStreamIO(InputStream is, OutputStream os) throws IOException { + byte[] buf = new byte[1024]; + int ret = is.read(buf, 0, 1024); + while (ret != -1) { + os.write(buf, 0, ret); + ret = is.read(buf, 0, 1024); + } + } + + protected static void closeSilently(Closeable c) { + if (c != null) { + try { + c.close(); + } catch (Throwable e) { + // ignored + } + } + } + + private SparseIntArray mTagInfo = null; + + protected SparseIntArray getTagInfo() { + if (mTagInfo == null) { + mTagInfo = new SparseIntArray(); + initTagInfo(); + } + return mTagInfo; + } + + private void initTagInfo() { + /** + * We put tag information in a 4-bytes integer. The first byte a bitmask + * representing the allowed IFDs of the tag, the second byte is the data + * type, and the last two byte are a short value indicating the default + * component count of this tag. + */ + // IFD0 tags + int[] ifdAllowedIfds = { + IfdId.TYPE_IFD_0, IfdId.TYPE_IFD_1 + }; + int ifdFlags = getFlagsFromAllowedIfds(ifdAllowedIfds) << 24; + mTagInfo.put(ExifInterface.TAG_MAKE, + ifdFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_IMAGE_WIDTH, + ifdFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1); + mTagInfo.put(ExifInterface.TAG_IMAGE_LENGTH, + ifdFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1); + mTagInfo.put(ExifInterface.TAG_BITS_PER_SAMPLE, + ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 3); + mTagInfo.put(ExifInterface.TAG_COMPRESSION, + ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_PHOTOMETRIC_INTERPRETATION, + ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_ORIENTATION, ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 + | 1); + mTagInfo.put(ExifInterface.TAG_SAMPLES_PER_PIXEL, + ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_PLANAR_CONFIGURATION, + ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_Y_CB_CR_SUB_SAMPLING, + ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 2); + mTagInfo.put(ExifInterface.TAG_Y_CB_CR_POSITIONING, + ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_X_RESOLUTION, + ifdFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_Y_RESOLUTION, + ifdFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_RESOLUTION_UNIT, + ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_STRIP_OFFSETS, + ifdFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_ROWS_PER_STRIP, + ifdFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1); + mTagInfo.put(ExifInterface.TAG_STRIP_BYTE_COUNTS, + ifdFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_TRANSFER_FUNCTION, + ifdFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 3 * 256); + mTagInfo.put(ExifInterface.TAG_WHITE_POINT, + ifdFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 2); + mTagInfo.put(ExifInterface.TAG_PRIMARY_CHROMATICITIES, + ifdFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 6); + mTagInfo.put(ExifInterface.TAG_Y_CB_CR_COEFFICIENTS, + ifdFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 3); + mTagInfo.put(ExifInterface.TAG_REFERENCE_BLACK_WHITE, + ifdFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 6); + mTagInfo.put(ExifInterface.TAG_DATE_TIME, + ifdFlags | ExifTag.TYPE_ASCII << 16 | 20); + mTagInfo.put(ExifInterface.TAG_IMAGE_DESCRIPTION, + ifdFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_MAKE, + ifdFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_MODEL, + ifdFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_SOFTWARE, + ifdFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_ARTIST, + ifdFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_COPYRIGHT, + ifdFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_EXIF_IFD, + ifdFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1); + mTagInfo.put(ExifInterface.TAG_GPS_IFD, + ifdFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1); + // IFD1 tags + int[] ifd1AllowedIfds = { + IfdId.TYPE_IFD_1 + }; + int ifdFlags1 = getFlagsFromAllowedIfds(ifd1AllowedIfds) << 24; + mTagInfo.put(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT, + ifdFlags1 | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1); + mTagInfo.put(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH, + ifdFlags1 | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1); + // Exif tags + int[] exifAllowedIfds = { + IfdId.TYPE_IFD_EXIF + }; + int exifFlags = getFlagsFromAllowedIfds(exifAllowedIfds) << 24; + mTagInfo.put(ExifInterface.TAG_EXIF_VERSION, + exifFlags | ExifTag.TYPE_UNDEFINED << 16 | 4); + mTagInfo.put(ExifInterface.TAG_FLASHPIX_VERSION, + exifFlags | ExifTag.TYPE_UNDEFINED << 16 | 4); + mTagInfo.put(ExifInterface.TAG_COLOR_SPACE, + exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_COMPONENTS_CONFIGURATION, + exifFlags | ExifTag.TYPE_UNDEFINED << 16 | 4); + mTagInfo.put(ExifInterface.TAG_COMPRESSED_BITS_PER_PIXEL, + exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_PIXEL_X_DIMENSION, + exifFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1); + mTagInfo.put(ExifInterface.TAG_PIXEL_Y_DIMENSION, + exifFlags | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1); + mTagInfo.put(ExifInterface.TAG_MAKER_NOTE, + exifFlags | ExifTag.TYPE_UNDEFINED << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_USER_COMMENT, + exifFlags | ExifTag.TYPE_UNDEFINED << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_RELATED_SOUND_FILE, + exifFlags | ExifTag.TYPE_ASCII << 16 | 13); + mTagInfo.put(ExifInterface.TAG_DATE_TIME_ORIGINAL, + exifFlags | ExifTag.TYPE_ASCII << 16 | 20); + mTagInfo.put(ExifInterface.TAG_DATE_TIME_DIGITIZED, + exifFlags | ExifTag.TYPE_ASCII << 16 | 20); + mTagInfo.put(ExifInterface.TAG_SUB_SEC_TIME, + exifFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_SUB_SEC_TIME_ORIGINAL, + exifFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_SUB_SEC_TIME_DIGITIZED, + exifFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_IMAGE_UNIQUE_ID, + exifFlags | ExifTag.TYPE_ASCII << 16 | 33); + mTagInfo.put(ExifInterface.TAG_EXPOSURE_TIME, + exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_F_NUMBER, + exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_EXPOSURE_PROGRAM, + exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_SPECTRAL_SENSITIVITY, + exifFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_ISO_SPEED_RATINGS, + exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_OECF, + exifFlags | ExifTag.TYPE_UNDEFINED << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_SHUTTER_SPEED_VALUE, + exifFlags | ExifTag.TYPE_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_APERTURE_VALUE, + exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_BRIGHTNESS_VALUE, + exifFlags | ExifTag.TYPE_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_EXPOSURE_BIAS_VALUE, + exifFlags | ExifTag.TYPE_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_MAX_APERTURE_VALUE, + exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_SUBJECT_DISTANCE, + exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_METERING_MODE, + exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_LIGHT_SOURCE, + exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_FLASH, + exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_FOCAL_LENGTH, + exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_SUBJECT_AREA, + exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_FLASH_ENERGY, + exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_SPATIAL_FREQUENCY_RESPONSE, + exifFlags | ExifTag.TYPE_UNDEFINED << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_FOCAL_PLANE_X_RESOLUTION, + exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_FOCAL_PLANE_Y_RESOLUTION, + exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_FOCAL_PLANE_RESOLUTION_UNIT, + exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_SUBJECT_LOCATION, + exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 2); + mTagInfo.put(ExifInterface.TAG_EXPOSURE_INDEX, + exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_SENSING_METHOD, + exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_FILE_SOURCE, + exifFlags | ExifTag.TYPE_UNDEFINED << 16 | 1); + mTagInfo.put(ExifInterface.TAG_SCENE_TYPE, + exifFlags | ExifTag.TYPE_UNDEFINED << 16 | 1); + mTagInfo.put(ExifInterface.TAG_CFA_PATTERN, + exifFlags | ExifTag.TYPE_UNDEFINED << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_CUSTOM_RENDERED, + exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_EXPOSURE_MODE, + exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_WHITE_BALANCE, + exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_DIGITAL_ZOOM_RATIO, + exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_FOCAL_LENGTH_IN_35_MM_FILE, + exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_SCENE_CAPTURE_TYPE, + exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_GAIN_CONTROL, + exifFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_CONTRAST, + exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_SATURATION, + exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_SHARPNESS, + exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_DEVICE_SETTING_DESCRIPTION, + exifFlags | ExifTag.TYPE_UNDEFINED << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_SUBJECT_DISTANCE_RANGE, + exifFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 1); + mTagInfo.put(ExifInterface.TAG_INTEROPERABILITY_IFD, exifFlags + | ExifTag.TYPE_UNSIGNED_LONG << 16 | 1); + // GPS tag + int[] gpsAllowedIfds = { + IfdId.TYPE_IFD_GPS + }; + int gpsFlags = getFlagsFromAllowedIfds(gpsAllowedIfds) << 24; + mTagInfo.put(ExifInterface.TAG_GPS_VERSION_ID, + gpsFlags | ExifTag.TYPE_UNSIGNED_BYTE << 16 | 4); + mTagInfo.put(ExifInterface.TAG_GPS_LATITUDE_REF, + gpsFlags | ExifTag.TYPE_ASCII << 16 | 2); + mTagInfo.put(ExifInterface.TAG_GPS_LONGITUDE_REF, + gpsFlags | ExifTag.TYPE_ASCII << 16 | 2); + mTagInfo.put(ExifInterface.TAG_GPS_LATITUDE, + gpsFlags | ExifTag.TYPE_RATIONAL << 16 | 3); + mTagInfo.put(ExifInterface.TAG_GPS_LONGITUDE, + gpsFlags | ExifTag.TYPE_RATIONAL << 16 | 3); + mTagInfo.put(ExifInterface.TAG_GPS_ALTITUDE_REF, + gpsFlags | ExifTag.TYPE_UNSIGNED_BYTE << 16 | 1); + mTagInfo.put(ExifInterface.TAG_GPS_ALTITUDE, + gpsFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_GPS_TIME_STAMP, + gpsFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 3); + mTagInfo.put(ExifInterface.TAG_GPS_SATTELLITES, + gpsFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_GPS_STATUS, + gpsFlags | ExifTag.TYPE_ASCII << 16 | 2); + mTagInfo.put(ExifInterface.TAG_GPS_MEASURE_MODE, + gpsFlags | ExifTag.TYPE_ASCII << 16 | 2); + mTagInfo.put(ExifInterface.TAG_GPS_DOP, + gpsFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_GPS_SPEED_REF, + gpsFlags | ExifTag.TYPE_ASCII << 16 | 2); + mTagInfo.put(ExifInterface.TAG_GPS_SPEED, + gpsFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_GPS_TRACK_REF, + gpsFlags | ExifTag.TYPE_ASCII << 16 | 2); + mTagInfo.put(ExifInterface.TAG_GPS_TRACK, + gpsFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_GPS_IMG_DIRECTION_REF, + gpsFlags | ExifTag.TYPE_ASCII << 16 | 2); + mTagInfo.put(ExifInterface.TAG_GPS_IMG_DIRECTION, + gpsFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_GPS_MAP_DATUM, + gpsFlags | ExifTag.TYPE_ASCII << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_GPS_DEST_LATITUDE_REF, + gpsFlags | ExifTag.TYPE_ASCII << 16 | 2); + mTagInfo.put(ExifInterface.TAG_GPS_DEST_LATITUDE, + gpsFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_GPS_DEST_BEARING_REF, + gpsFlags | ExifTag.TYPE_ASCII << 16 | 2); + mTagInfo.put(ExifInterface.TAG_GPS_DEST_BEARING, + gpsFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_GPS_DEST_DISTANCE_REF, + gpsFlags | ExifTag.TYPE_ASCII << 16 | 2); + mTagInfo.put(ExifInterface.TAG_GPS_DEST_DISTANCE, + gpsFlags | ExifTag.TYPE_UNSIGNED_RATIONAL << 16 | 1); + mTagInfo.put(ExifInterface.TAG_GPS_PROCESSING_METHOD, + gpsFlags | ExifTag.TYPE_UNDEFINED << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_GPS_AREA_INFORMATION, + gpsFlags | ExifTag.TYPE_UNDEFINED << 16 | ExifTag.SIZE_UNDEFINED); + mTagInfo.put(ExifInterface.TAG_GPS_DATE_STAMP, + gpsFlags | ExifTag.TYPE_ASCII << 16 | 11); + mTagInfo.put(ExifInterface.TAG_GPS_DIFFERENTIAL, + gpsFlags | ExifTag.TYPE_UNSIGNED_SHORT << 16 | 11); + // Interoperability tag + int[] interopAllowedIfds = { + IfdId.TYPE_IFD_INTEROPERABILITY + }; + int interopFlags = getFlagsFromAllowedIfds(interopAllowedIfds) << 24; + mTagInfo.put(TAG_INTEROPERABILITY_INDEX, interopFlags | ExifTag.TYPE_ASCII << 16 + | ExifTag.SIZE_UNDEFINED); + } + + protected static int getAllowedIfdFlagsFromInfo(int info) { + return info >>> 24; + } + + protected static int[] getAllowedIfdsFromInfo(int info) { + int ifdFlags = getAllowedIfdFlagsFromInfo(info); + int[] ifds = IfdData.getIfds(); + ArrayList l = new ArrayList(); + for (int i = 0; i < IfdId.TYPE_IFD_COUNT; i++) { + int flag = (ifdFlags >> i) & 1; + if (flag == 1) { + l.add(ifds[i]); + } + } + if (l.size() <= 0) { + return null; + } + int[] ret = new int[l.size()]; + int j = 0; + for (int i : l) { + ret[j++] = i; + } + return ret; + } + + protected static boolean isIfdAllowed(int info, int ifd) { + int[] ifds = IfdData.getIfds(); + int ifdFlags = getAllowedIfdFlagsFromInfo(info); + for (int i = 0; i < ifds.length; i++) { + if (ifd == ifds[i] && ((ifdFlags >> i) & 1) == 1) { + return true; + } + } + return false; + } + + protected static int getFlagsFromAllowedIfds(int[] allowedIfds) { + if (allowedIfds == null || allowedIfds.length == 0) { + return 0; + } + int flags = 0; + int[] ifds = IfdData.getIfds(); + for (int i = 0; i < IfdId.TYPE_IFD_COUNT; i++) { + for (int j : allowedIfds) { + if (ifds[i] == j) { + flags |= 1 << i; + break; + } + } + } + return flags; + } + + protected static short getTypeFromInfo(int info) { + return (short) ((info >> 16) & 0x0ff); + } + + protected static int getComponentCountFromInfo(int info) { + return info & 0x0ffff; + } + +} diff --git a/src/com/android/messaging/util/exif/ExifInvalidFormatException.java b/src/com/android/messaging/util/exif/ExifInvalidFormatException.java new file mode 100644 index 0000000..a38f8a3 --- /dev/null +++ b/src/com/android/messaging/util/exif/ExifInvalidFormatException.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util.exif; + +public class ExifInvalidFormatException extends Exception { + public ExifInvalidFormatException(String meg) { + super(meg); + } +} diff --git a/src/com/android/messaging/util/exif/ExifModifier.java b/src/com/android/messaging/util/exif/ExifModifier.java new file mode 100644 index 0000000..274022c --- /dev/null +++ b/src/com/android/messaging/util/exif/ExifModifier.java @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util.exif; + +import android.util.Log; +import com.android.messaging.util.LogUtil; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; + +class ExifModifier { + public static final String TAG = LogUtil.BUGLE_TAG; + public static final boolean DEBUG = false; + private final ByteBuffer mByteBuffer; + private final ExifData mTagToModified; + private final List mTagOffsets = new ArrayList(); + private final ExifInterface mInterface; + private int mOffsetBase; + + private static class TagOffset { + final int mOffset; + final ExifTag mTag; + + TagOffset(ExifTag tag, int offset) { + mTag = tag; + mOffset = offset; + } + } + + protected ExifModifier(ByteBuffer byteBuffer, ExifInterface iRef) throws IOException, + ExifInvalidFormatException { + mByteBuffer = byteBuffer; + mOffsetBase = byteBuffer.position(); + mInterface = iRef; + InputStream is = null; + try { + is = new ByteBufferInputStream(byteBuffer); + // Do not require any IFD; + ExifParser parser = ExifParser.parse(is, mInterface); + mTagToModified = new ExifData(parser.getByteOrder()); + mOffsetBase += parser.getTiffStartPosition(); + mByteBuffer.position(0); + } finally { + ExifInterface.closeSilently(is); + } + } + + protected ByteOrder getByteOrder() { + return mTagToModified.getByteOrder(); + } + + protected boolean commit() throws IOException, ExifInvalidFormatException { + InputStream is = null; + try { + is = new ByteBufferInputStream(mByteBuffer); + int flag = 0; + IfdData[] ifdDatas = new IfdData[] { + mTagToModified.getIfdData(IfdId.TYPE_IFD_0), + mTagToModified.getIfdData(IfdId.TYPE_IFD_1), + mTagToModified.getIfdData(IfdId.TYPE_IFD_EXIF), + mTagToModified.getIfdData(IfdId.TYPE_IFD_INTEROPERABILITY), + mTagToModified.getIfdData(IfdId.TYPE_IFD_GPS) + }; + + if (ifdDatas[IfdId.TYPE_IFD_0] != null) { + flag |= ExifParser.OPTION_IFD_0; + } + if (ifdDatas[IfdId.TYPE_IFD_1] != null) { + flag |= ExifParser.OPTION_IFD_1; + } + if (ifdDatas[IfdId.TYPE_IFD_EXIF] != null) { + flag |= ExifParser.OPTION_IFD_EXIF; + } + if (ifdDatas[IfdId.TYPE_IFD_GPS] != null) { + flag |= ExifParser.OPTION_IFD_GPS; + } + if (ifdDatas[IfdId.TYPE_IFD_INTEROPERABILITY] != null) { + flag |= ExifParser.OPTION_IFD_INTEROPERABILITY; + } + + ExifParser parser = ExifParser.parse(is, flag, mInterface); + int event = parser.next(); + IfdData currIfd = null; + while (event != ExifParser.EVENT_END) { + switch (event) { + case ExifParser.EVENT_START_OF_IFD: + currIfd = ifdDatas[parser.getCurrentIfd()]; + if (currIfd == null) { + parser.skipRemainingTagsInCurrentIfd(); + } + break; + case ExifParser.EVENT_NEW_TAG: + ExifTag oldTag = parser.getTag(); + ExifTag newTag = currIfd.getTag(oldTag.getTagId()); + if (newTag != null) { + if (newTag.getComponentCount() != oldTag.getComponentCount() + || newTag.getDataType() != oldTag.getDataType()) { + return false; + } else { + mTagOffsets.add(new TagOffset(newTag, oldTag.getOffset())); + currIfd.removeTag(oldTag.getTagId()); + if (currIfd.getTagCount() == 0) { + parser.skipRemainingTagsInCurrentIfd(); + } + } + } + break; + } + event = parser.next(); + } + for (IfdData ifd : ifdDatas) { + if (ifd != null && ifd.getTagCount() > 0) { + return false; + } + } + modify(); + } finally { + ExifInterface.closeSilently(is); + } + return true; + } + + private void modify() { + mByteBuffer.order(getByteOrder()); + for (TagOffset tagOffset : mTagOffsets) { + writeTagValue(tagOffset.mTag, tagOffset.mOffset); + } + } + + private void writeTagValue(ExifTag tag, int offset) { + if (DEBUG) { + Log.v(TAG, "modifying tag to: \n" + tag.toString()); + Log.v(TAG, "at offset: " + offset); + } + mByteBuffer.position(offset + mOffsetBase); + switch (tag.getDataType()) { + case ExifTag.TYPE_ASCII: + byte buf[] = tag.getStringByte(); + if (buf.length == tag.getComponentCount()) { + buf[buf.length - 1] = 0; + mByteBuffer.put(buf); + } else { + mByteBuffer.put(buf); + mByteBuffer.put((byte) 0); + } + break; + case ExifTag.TYPE_LONG: + case ExifTag.TYPE_UNSIGNED_LONG: + for (int i = 0, n = tag.getComponentCount(); i < n; i++) { + mByteBuffer.putInt((int) tag.getValueAt(i)); + } + break; + case ExifTag.TYPE_RATIONAL: + case ExifTag.TYPE_UNSIGNED_RATIONAL: + for (int i = 0, n = tag.getComponentCount(); i < n; i++) { + Rational v = tag.getRational(i); + mByteBuffer.putInt((int) v.getNumerator()); + mByteBuffer.putInt((int) v.getDenominator()); + } + break; + case ExifTag.TYPE_UNDEFINED: + case ExifTag.TYPE_UNSIGNED_BYTE: + buf = new byte[tag.getComponentCount()]; + tag.getBytes(buf); + mByteBuffer.put(buf); + break; + case ExifTag.TYPE_UNSIGNED_SHORT: + for (int i = 0, n = tag.getComponentCount(); i < n; i++) { + mByteBuffer.putShort((short) tag.getValueAt(i)); + } + break; + } + } + + public void modifyTag(ExifTag tag) { + mTagToModified.addTag(tag); + } +} diff --git a/src/com/android/messaging/util/exif/ExifOutputStream.java b/src/com/android/messaging/util/exif/ExifOutputStream.java new file mode 100644 index 0000000..2016da4 --- /dev/null +++ b/src/com/android/messaging/util/exif/ExifOutputStream.java @@ -0,0 +1,522 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util.exif; + +import android.util.Log; +import com.android.messaging.util.LogUtil; + +import java.io.BufferedOutputStream; +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; + +/** + * This class provides a way to replace the Exif header of a JPEG image. + *

+ * Below is an example of writing EXIF data into a file + * + *

+ * public static void writeExif(byte[] jpeg, ExifData exif, String path) {
+ *     OutputStream os = null;
+ *     try {
+ *         os = new FileOutputStream(path);
+ *         ExifOutputStream eos = new ExifOutputStream(os);
+ *         // Set the exif header
+ *         eos.setExifData(exif);
+ *         // Write the original jpeg out, the header will be add into the file.
+ *         eos.write(jpeg);
+ *     } catch (FileNotFoundException e) {
+ *         e.printStackTrace();
+ *     } catch (IOException e) {
+ *         e.printStackTrace();
+ *     } finally {
+ *         if (os != null) {
+ *             try {
+ *                 os.close();
+ *             } catch (IOException e) {
+ *                 e.printStackTrace();
+ *             }
+ *         }
+ *     }
+ * }
+ * 
+ */ +class ExifOutputStream extends FilterOutputStream { + private static final String TAG = LogUtil.BUGLE_TAG; + private static final boolean DEBUG = false; + private static final int STREAMBUFFER_SIZE = 0x00010000; // 64Kb + + private static final int STATE_SOI = 0; + private static final int STATE_FRAME_HEADER = 1; + private static final int STATE_JPEG_DATA = 2; + + private static final int EXIF_HEADER = 0x45786966; + private static final short TIFF_HEADER = 0x002A; + private static final short TIFF_BIG_ENDIAN = 0x4d4d; + private static final short TIFF_LITTLE_ENDIAN = 0x4949; + private static final short TAG_SIZE = 12; + private static final short TIFF_HEADER_SIZE = 8; + private static final int MAX_EXIF_SIZE = 65535; + + private ExifData mExifData; + private int mState = STATE_SOI; + private int mByteToSkip; + private int mByteToCopy; + private final byte[] mSingleByteArray = new byte[1]; + private final ByteBuffer mBuffer = ByteBuffer.allocate(4); + private final ExifInterface mInterface; + + protected ExifOutputStream(OutputStream ou, ExifInterface iRef) { + super(new BufferedOutputStream(ou, STREAMBUFFER_SIZE)); + mInterface = iRef; + } + + /** + * Sets the ExifData to be written into the JPEG file. Should be called + * before writing image data. + */ + protected void setExifData(ExifData exifData) { + mExifData = exifData; + } + + /** + * Gets the Exif header to be written into the JPEF file. + */ + protected ExifData getExifData() { + return mExifData; + } + + private int requestByteToBuffer(int requestByteCount, byte[] buffer + , int offset, int length) { + int byteNeeded = requestByteCount - mBuffer.position(); + int byteToRead = length > byteNeeded ? byteNeeded : length; + mBuffer.put(buffer, offset, byteToRead); + return byteToRead; + } + + /** + * Writes the image out. The input data should be a valid JPEG format. After + * writing, it's Exif header will be replaced by the given header. + */ + @Override + public void write(byte[] buffer, int offset, int length) throws IOException { + while ((mByteToSkip > 0 || mByteToCopy > 0 || mState != STATE_JPEG_DATA) + && length > 0) { + if (mByteToSkip > 0) { + int byteToProcess = length > mByteToSkip ? mByteToSkip : length; + length -= byteToProcess; + mByteToSkip -= byteToProcess; + offset += byteToProcess; + } + if (mByteToCopy > 0) { + int byteToProcess = length > mByteToCopy ? mByteToCopy : length; + out.write(buffer, offset, byteToProcess); + length -= byteToProcess; + mByteToCopy -= byteToProcess; + offset += byteToProcess; + } + if (length == 0) { + return; + } + switch (mState) { + case STATE_SOI: + int byteRead = requestByteToBuffer(2, buffer, offset, length); + offset += byteRead; + length -= byteRead; + if (mBuffer.position() < 2) { + return; + } + mBuffer.rewind(); + if (mBuffer.getShort() != JpegHeader.SOI) { + throw new IOException("Not a valid jpeg image, cannot write exif"); + } + out.write(mBuffer.array(), 0, 2); + mState = STATE_FRAME_HEADER; + mBuffer.rewind(); + writeExifData(); + break; + case STATE_FRAME_HEADER: + // We ignore the APP1 segment and copy all other segments + // until SOF tag. + byteRead = requestByteToBuffer(4, buffer, offset, length); + offset += byteRead; + length -= byteRead; + // Check if this image data doesn't contain SOF. + if (mBuffer.position() == 2) { + short tag = mBuffer.getShort(); + if (tag == JpegHeader.EOI) { + out.write(mBuffer.array(), 0, 2); + mBuffer.rewind(); + } + } + if (mBuffer.position() < 4) { + return; + } + mBuffer.rewind(); + short marker = mBuffer.getShort(); + if (marker == JpegHeader.APP1) { + mByteToSkip = (mBuffer.getShort() & 0x0000ffff) - 2; + mState = STATE_JPEG_DATA; + } else if (!JpegHeader.isSofMarker(marker)) { + out.write(mBuffer.array(), 0, 4); + mByteToCopy = (mBuffer.getShort() & 0x0000ffff) - 2; + } else { + out.write(mBuffer.array(), 0, 4); + mState = STATE_JPEG_DATA; + } + mBuffer.rewind(); + } + } + if (length > 0) { + out.write(buffer, offset, length); + } + } + + /** + * Writes the one bytes out. The input data should be a valid JPEG format. + * After writing, it's Exif header will be replaced by the given header. + */ + @Override + public void write(int oneByte) throws IOException { + mSingleByteArray[0] = (byte) (0xff & oneByte); + write(mSingleByteArray); + } + + /** + * Equivalent to calling write(buffer, 0, buffer.length). + */ + @Override + public void write(byte[] buffer) throws IOException { + write(buffer, 0, buffer.length); + } + + private void writeExifData() throws IOException { + if (mExifData == null) { + return; + } + if (DEBUG) { + Log.v(TAG, "Writing exif data..."); + } + ArrayList nullTags = stripNullValueTags(mExifData); + createRequiredIfdAndTag(); + int exifSize = calculateAllOffset(); + if (exifSize + 8 > MAX_EXIF_SIZE) { + throw new IOException("Exif header is too large (>64Kb)"); + } + OrderedDataOutputStream dataOutputStream = new OrderedDataOutputStream(out); + dataOutputStream.setByteOrder(ByteOrder.BIG_ENDIAN); + dataOutputStream.writeShort(JpegHeader.APP1); + dataOutputStream.writeShort((short) (exifSize + 8)); + dataOutputStream.writeInt(EXIF_HEADER); + dataOutputStream.writeShort((short) 0x0000); + if (mExifData.getByteOrder() == ByteOrder.BIG_ENDIAN) { + dataOutputStream.writeShort(TIFF_BIG_ENDIAN); + } else { + dataOutputStream.writeShort(TIFF_LITTLE_ENDIAN); + } + dataOutputStream.setByteOrder(mExifData.getByteOrder()); + dataOutputStream.writeShort(TIFF_HEADER); + dataOutputStream.writeInt(8); + writeAllTags(dataOutputStream); + writeThumbnail(dataOutputStream); + for (ExifTag t : nullTags) { + mExifData.addTag(t); + } + } + + private ArrayList stripNullValueTags(ExifData data) { + ArrayList nullTags = new ArrayList(); + if (data.getAllTags() == null) { + return nullTags; + } + for (ExifTag t : data.getAllTags()) { + if (t.getValue() == null && !ExifInterface.isOffsetTag(t.getTagId())) { + data.removeTag(t.getTagId(), t.getIfd()); + nullTags.add(t); + } + } + return nullTags; + } + + private void writeThumbnail(OrderedDataOutputStream dataOutputStream) throws IOException { + if (mExifData.hasCompressedThumbnail()) { + dataOutputStream.write(mExifData.getCompressedThumbnail()); + } else if (mExifData.hasUncompressedStrip()) { + for (int i = 0; i < mExifData.getStripCount(); i++) { + dataOutputStream.write(mExifData.getStrip(i)); + } + } + } + + private void writeAllTags(OrderedDataOutputStream dataOutputStream) throws IOException { + writeIfd(mExifData.getIfdData(IfdId.TYPE_IFD_0), dataOutputStream); + writeIfd(mExifData.getIfdData(IfdId.TYPE_IFD_EXIF), dataOutputStream); + IfdData interoperabilityIfd = mExifData.getIfdData(IfdId.TYPE_IFD_INTEROPERABILITY); + if (interoperabilityIfd != null) { + writeIfd(interoperabilityIfd, dataOutputStream); + } + IfdData gpsIfd = mExifData.getIfdData(IfdId.TYPE_IFD_GPS); + if (gpsIfd != null) { + writeIfd(gpsIfd, dataOutputStream); + } + IfdData ifd1 = mExifData.getIfdData(IfdId.TYPE_IFD_1); + if (ifd1 != null) { + writeIfd(mExifData.getIfdData(IfdId.TYPE_IFD_1), dataOutputStream); + } + } + + private void writeIfd(IfdData ifd, OrderedDataOutputStream dataOutputStream) + throws IOException { + ExifTag[] tags = ifd.getAllTags(); + dataOutputStream.writeShort((short) tags.length); + for (ExifTag tag : tags) { + dataOutputStream.writeShort(tag.getTagId()); + dataOutputStream.writeShort(tag.getDataType()); + dataOutputStream.writeInt(tag.getComponentCount()); + if (DEBUG) { + Log.v(TAG, "\n" + tag.toString()); + } + if (tag.getDataSize() > 4) { + dataOutputStream.writeInt(tag.getOffset()); + } else { + ExifOutputStream.writeTagValue(tag, dataOutputStream); + for (int i = 0, n = 4 - tag.getDataSize(); i < n; i++) { + dataOutputStream.write(0); + } + } + } + dataOutputStream.writeInt(ifd.getOffsetToNextIfd()); + for (ExifTag tag : tags) { + if (tag.getDataSize() > 4) { + ExifOutputStream.writeTagValue(tag, dataOutputStream); + } + } + } + + private int calculateOffsetOfIfd(IfdData ifd, int offset) { + offset += 2 + ifd.getTagCount() * TAG_SIZE + 4; + ExifTag[] tags = ifd.getAllTags(); + for (ExifTag tag : tags) { + if (tag.getDataSize() > 4) { + tag.setOffset(offset); + offset += tag.getDataSize(); + } + } + return offset; + } + + private void createRequiredIfdAndTag() throws IOException { + // IFD0 is required for all file + IfdData ifd0 = mExifData.getIfdData(IfdId.TYPE_IFD_0); + if (ifd0 == null) { + ifd0 = new IfdData(IfdId.TYPE_IFD_0); + mExifData.addIfdData(ifd0); + } + ExifTag exifOffsetTag = mInterface.buildUninitializedTag(ExifInterface.TAG_EXIF_IFD); + if (exifOffsetTag == null) { + throw new IOException("No definition for crucial exif tag: " + + ExifInterface.TAG_EXIF_IFD); + } + ifd0.setTag(exifOffsetTag); + + // Exif IFD is required for all files. + IfdData exifIfd = mExifData.getIfdData(IfdId.TYPE_IFD_EXIF); + if (exifIfd == null) { + exifIfd = new IfdData(IfdId.TYPE_IFD_EXIF); + mExifData.addIfdData(exifIfd); + } + + // GPS IFD + IfdData gpsIfd = mExifData.getIfdData(IfdId.TYPE_IFD_GPS); + if (gpsIfd != null) { + ExifTag gpsOffsetTag = mInterface.buildUninitializedTag(ExifInterface.TAG_GPS_IFD); + if (gpsOffsetTag == null) { + throw new IOException("No definition for crucial exif tag: " + + ExifInterface.TAG_GPS_IFD); + } + ifd0.setTag(gpsOffsetTag); + } + + // Interoperability IFD + IfdData interIfd = mExifData.getIfdData(IfdId.TYPE_IFD_INTEROPERABILITY); + if (interIfd != null) { + ExifTag interOffsetTag = mInterface + .buildUninitializedTag(ExifInterface.TAG_INTEROPERABILITY_IFD); + if (interOffsetTag == null) { + throw new IOException("No definition for crucial exif tag: " + + ExifInterface.TAG_INTEROPERABILITY_IFD); + } + exifIfd.setTag(interOffsetTag); + } + + IfdData ifd1 = mExifData.getIfdData(IfdId.TYPE_IFD_1); + + // thumbnail + if (mExifData.hasCompressedThumbnail()) { + + if (ifd1 == null) { + ifd1 = new IfdData(IfdId.TYPE_IFD_1); + mExifData.addIfdData(ifd1); + } + + ExifTag offsetTag = mInterface + .buildUninitializedTag(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT); + if (offsetTag == null) { + throw new IOException("No definition for crucial exif tag: " + + ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT); + } + + ifd1.setTag(offsetTag); + ExifTag lengthTag = mInterface + .buildUninitializedTag(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH); + if (lengthTag == null) { + throw new IOException("No definition for crucial exif tag: " + + ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH); + } + + lengthTag.setValue(mExifData.getCompressedThumbnail().length); + ifd1.setTag(lengthTag); + + // Get rid of tags for uncompressed if they exist. + ifd1.removeTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_STRIP_OFFSETS)); + ifd1.removeTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_STRIP_BYTE_COUNTS)); + } else if (mExifData.hasUncompressedStrip()) { + if (ifd1 == null) { + ifd1 = new IfdData(IfdId.TYPE_IFD_1); + mExifData.addIfdData(ifd1); + } + int stripCount = mExifData.getStripCount(); + ExifTag offsetTag = mInterface.buildUninitializedTag(ExifInterface.TAG_STRIP_OFFSETS); + if (offsetTag == null) { + throw new IOException("No definition for crucial exif tag: " + + ExifInterface.TAG_STRIP_OFFSETS); + } + ExifTag lengthTag = mInterface + .buildUninitializedTag(ExifInterface.TAG_STRIP_BYTE_COUNTS); + if (lengthTag == null) { + throw new IOException("No definition for crucial exif tag: " + + ExifInterface.TAG_STRIP_BYTE_COUNTS); + } + long[] lengths = new long[stripCount]; + for (int i = 0; i < mExifData.getStripCount(); i++) { + lengths[i] = mExifData.getStrip(i).length; + } + lengthTag.setValue(lengths); + ifd1.setTag(offsetTag); + ifd1.setTag(lengthTag); + // Get rid of tags for compressed if they exist. + ifd1.removeTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT)); + ifd1.removeTag(ExifInterface + .getTrueTagKey(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH)); + } else if (ifd1 != null) { + // Get rid of offset and length tags if there is no thumbnail. + ifd1.removeTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_STRIP_OFFSETS)); + ifd1.removeTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_STRIP_BYTE_COUNTS)); + ifd1.removeTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT)); + ifd1.removeTag(ExifInterface + .getTrueTagKey(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH)); + } + } + + private int calculateAllOffset() { + int offset = TIFF_HEADER_SIZE; + IfdData ifd0 = mExifData.getIfdData(IfdId.TYPE_IFD_0); + offset = calculateOffsetOfIfd(ifd0, offset); + ifd0.getTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_EXIF_IFD)).setValue(offset); + + IfdData exifIfd = mExifData.getIfdData(IfdId.TYPE_IFD_EXIF); + offset = calculateOffsetOfIfd(exifIfd, offset); + + IfdData interIfd = mExifData.getIfdData(IfdId.TYPE_IFD_INTEROPERABILITY); + if (interIfd != null) { + exifIfd.getTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_INTEROPERABILITY_IFD)) + .setValue(offset); + offset = calculateOffsetOfIfd(interIfd, offset); + } + + IfdData gpsIfd = mExifData.getIfdData(IfdId.TYPE_IFD_GPS); + if (gpsIfd != null) { + ifd0.getTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_GPS_IFD)).setValue(offset); + offset = calculateOffsetOfIfd(gpsIfd, offset); + } + + IfdData ifd1 = mExifData.getIfdData(IfdId.TYPE_IFD_1); + if (ifd1 != null) { + ifd0.setOffsetToNextIfd(offset); + offset = calculateOffsetOfIfd(ifd1, offset); + } + + // thumbnail + if (mExifData.hasCompressedThumbnail()) { + ifd1.getTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT)) + .setValue(offset); + offset += mExifData.getCompressedThumbnail().length; + } else if (mExifData.hasUncompressedStrip()) { + int stripCount = mExifData.getStripCount(); + long[] offsets = new long[stripCount]; + for (int i = 0; i < mExifData.getStripCount(); i++) { + offsets[i] = offset; + offset += mExifData.getStrip(i).length; + } + ifd1.getTag(ExifInterface.getTrueTagKey(ExifInterface.TAG_STRIP_OFFSETS)).setValue( + offsets); + } + return offset; + } + + static void writeTagValue(ExifTag tag, OrderedDataOutputStream dataOutputStream) + throws IOException { + switch (tag.getDataType()) { + case ExifTag.TYPE_ASCII: + byte buf[] = tag.getStringByte(); + if (buf.length == tag.getComponentCount()) { + buf[buf.length - 1] = 0; + dataOutputStream.write(buf); + } else { + dataOutputStream.write(buf); + dataOutputStream.write(0); + } + break; + case ExifTag.TYPE_LONG: + case ExifTag.TYPE_UNSIGNED_LONG: + for (int i = 0, n = tag.getComponentCount(); i < n; i++) { + dataOutputStream.writeInt((int) tag.getValueAt(i)); + } + break; + case ExifTag.TYPE_RATIONAL: + case ExifTag.TYPE_UNSIGNED_RATIONAL: + for (int i = 0, n = tag.getComponentCount(); i < n; i++) { + dataOutputStream.writeRational(tag.getRational(i)); + } + break; + case ExifTag.TYPE_UNDEFINED: + case ExifTag.TYPE_UNSIGNED_BYTE: + buf = new byte[tag.getComponentCount()]; + tag.getBytes(buf); + dataOutputStream.write(buf); + break; + case ExifTag.TYPE_UNSIGNED_SHORT: + for (int i = 0, n = tag.getComponentCount(); i < n; i++) { + dataOutputStream.writeShort((short) tag.getValueAt(i)); + } + break; + } + } +} diff --git a/src/com/android/messaging/util/exif/ExifParser.java b/src/com/android/messaging/util/exif/ExifParser.java new file mode 100644 index 0000000..4b6cf68 --- /dev/null +++ b/src/com/android/messaging/util/exif/ExifParser.java @@ -0,0 +1,918 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util.exif; + +import android.util.Log; +import com.android.messaging.util.LogUtil; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteOrder; +import java.nio.charset.Charset; +import java.util.Map.Entry; +import java.util.TreeMap; + +/** + * This class provides a low-level EXIF parsing API. Given a JPEG format + * InputStream, the caller can request which IFD's to read via + * {@link #parse(java.io.InputStream, int)} with given options. + *

+ * Below is an example of getting EXIF data from IFD 0 and EXIF IFD using the + * parser. + * + *

+ * void parse() {
+ *     ExifParser parser = ExifParser.parse(mImageInputStream,
+ *             ExifParser.OPTION_IFD_0 | ExifParser.OPTIONS_IFD_EXIF);
+ *     int event = parser.next();
+ *     while (event != ExifParser.EVENT_END) {
+ *         switch (event) {
+ *             case ExifParser.EVENT_START_OF_IFD:
+ *                 break;
+ *             case ExifParser.EVENT_NEW_TAG:
+ *                 ExifTag tag = parser.getTag();
+ *                 if (!tag.hasValue()) {
+ *                     parser.registerForTagValue(tag);
+ *                 } else {
+ *                     processTag(tag);
+ *                 }
+ *                 break;
+ *             case ExifParser.EVENT_VALUE_OF_REGISTERED_TAG:
+ *                 tag = parser.getTag();
+ *                 if (tag.getDataType() != ExifTag.TYPE_UNDEFINED) {
+ *                     processTag(tag);
+ *                 }
+ *                 break;
+ *         }
+ *         event = parser.next();
+ *     }
+ * }
+ *
+ * void processTag(ExifTag tag) {
+ *     // process the tag as you like.
+ * }
+ * 
+ */ +public class ExifParser { + private static final boolean LOGV = false; + private static final String TAG = LogUtil.BUGLE_TAG; + /** + * When the parser reaches a new IFD area. Call {@link #getCurrentIfd()} to + * know which IFD we are in. + */ + public static final int EVENT_START_OF_IFD = 0; + /** + * When the parser reaches a new tag. Call {@link #getTag()}to get the + * corresponding tag. + */ + public static final int EVENT_NEW_TAG = 1; + /** + * When the parser reaches the value area of tag that is registered by + * {@link #registerForTagValue(ExifTag)} previously. Call {@link #getTag()} + * to get the corresponding tag. + */ + public static final int EVENT_VALUE_OF_REGISTERED_TAG = 2; + + /** + * When the parser reaches the compressed image area. + */ + public static final int EVENT_COMPRESSED_IMAGE = 3; + /** + * When the parser reaches the uncompressed image strip. Call + * {@link #getStripIndex()} to get the index of the strip. + * + * @see #getStripIndex() + * @see #getStripCount() + */ + public static final int EVENT_UNCOMPRESSED_STRIP = 4; + /** + * When there is nothing more to parse. + */ + public static final int EVENT_END = 5; + + /** + * Option bit to request to parse IFD0. + */ + public static final int OPTION_IFD_0 = 1 << 0; + /** + * Option bit to request to parse IFD1. + */ + public static final int OPTION_IFD_1 = 1 << 1; + /** + * Option bit to request to parse Exif-IFD. + */ + public static final int OPTION_IFD_EXIF = 1 << 2; + /** + * Option bit to request to parse GPS-IFD. + */ + public static final int OPTION_IFD_GPS = 1 << 3; + /** + * Option bit to request to parse Interoperability-IFD. + */ + public static final int OPTION_IFD_INTEROPERABILITY = 1 << 4; + /** + * Option bit to request to parse thumbnail. + */ + public static final int OPTION_THUMBNAIL = 1 << 5; + + protected static final int EXIF_HEADER = 0x45786966; // EXIF header "Exif" + protected static final short EXIF_HEADER_TAIL = (short) 0x0000; // EXIF header in APP1 + + // TIFF header + protected static final short LITTLE_ENDIAN_TAG = (short) 0x4949; // "II" + protected static final short BIG_ENDIAN_TAG = (short) 0x4d4d; // "MM" + protected static final short TIFF_HEADER_TAIL = 0x002A; + + protected static final int TAG_SIZE = 12; + protected static final int OFFSET_SIZE = 2; + + private static final Charset US_ASCII = Charset.forName("US-ASCII"); + + protected static final int DEFAULT_IFD0_OFFSET = 8; + + private final CountedDataInputStream mTiffStream; + private final int mOptions; + private int mIfdStartOffset = 0; + private int mNumOfTagInIfd = 0; + private int mIfdType; + private ExifTag mTag; + private ImageEvent mImageEvent; + private int mStripCount; + private ExifTag mStripSizeTag; + private ExifTag mJpegSizeTag; + private boolean mNeedToParseOffsetsInCurrentIfd; + private boolean mContainExifData = false; + private int mApp1End; + private int mOffsetToApp1EndFromSOF = 0; + private byte[] mDataAboveIfd0; + private int mIfd0Position; + private int mTiffStartPosition; + private final ExifInterface mInterface; + + private static final short TAG_EXIF_IFD = ExifInterface + .getTrueTagKey(ExifInterface.TAG_EXIF_IFD); + private static final short TAG_GPS_IFD = ExifInterface.getTrueTagKey(ExifInterface.TAG_GPS_IFD); + private static final short TAG_INTEROPERABILITY_IFD = ExifInterface + .getTrueTagKey(ExifInterface.TAG_INTEROPERABILITY_IFD); + private static final short TAG_JPEG_INTERCHANGE_FORMAT = ExifInterface + .getTrueTagKey(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT); + private static final short TAG_JPEG_INTERCHANGE_FORMAT_LENGTH = ExifInterface + .getTrueTagKey(ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH); + private static final short TAG_STRIP_OFFSETS = ExifInterface + .getTrueTagKey(ExifInterface.TAG_STRIP_OFFSETS); + private static final short TAG_STRIP_BYTE_COUNTS = ExifInterface + .getTrueTagKey(ExifInterface.TAG_STRIP_BYTE_COUNTS); + + private final TreeMap mCorrespondingEvent = new TreeMap(); + + private boolean isIfdRequested(int ifdType) { + switch (ifdType) { + case IfdId.TYPE_IFD_0: + return (mOptions & OPTION_IFD_0) != 0; + case IfdId.TYPE_IFD_1: + return (mOptions & OPTION_IFD_1) != 0; + case IfdId.TYPE_IFD_EXIF: + return (mOptions & OPTION_IFD_EXIF) != 0; + case IfdId.TYPE_IFD_GPS: + return (mOptions & OPTION_IFD_GPS) != 0; + case IfdId.TYPE_IFD_INTEROPERABILITY: + return (mOptions & OPTION_IFD_INTEROPERABILITY) != 0; + } + return false; + } + + private boolean isThumbnailRequested() { + return (mOptions & OPTION_THUMBNAIL) != 0; + } + + private ExifParser(InputStream inputStream, int options, ExifInterface iRef) + throws IOException, ExifInvalidFormatException { + if (inputStream == null) { + throw new IOException("Null argument inputStream to ExifParser"); + } + if (LOGV) { + Log.v(TAG, "Reading exif..."); + } + mInterface = iRef; + mContainExifData = seekTiffData(inputStream); + mTiffStream = new CountedDataInputStream(inputStream); + mOptions = options; + if (!mContainExifData) { + return; + } + + parseTiffHeader(); + long offset = mTiffStream.readUnsignedInt(); + if (offset > Integer.MAX_VALUE) { + throw new ExifInvalidFormatException("Invalid offset " + offset); + } + mIfd0Position = (int) offset; + mIfdType = IfdId.TYPE_IFD_0; + if (isIfdRequested(IfdId.TYPE_IFD_0) || needToParseOffsetsInCurrentIfd()) { + registerIfd(IfdId.TYPE_IFD_0, offset); + if (offset != DEFAULT_IFD0_OFFSET) { + mDataAboveIfd0 = new byte[(int) offset - DEFAULT_IFD0_OFFSET]; + read(mDataAboveIfd0); + } + } + } + + /** + * Parses the the given InputStream with the given options + * + * @exception java.io.IOException + * @exception ExifInvalidFormatException + */ + protected static ExifParser parse(InputStream inputStream, int options, ExifInterface iRef) + throws IOException, ExifInvalidFormatException { + return new ExifParser(inputStream, options, iRef); + } + + /** + * Parses the the given InputStream with default options; that is, every IFD + * and thumbnaill will be parsed. + * + * @exception java.io.IOException + * @exception ExifInvalidFormatException + * @see #parse(java.io.InputStream, int) + */ + protected static ExifParser parse(InputStream inputStream, ExifInterface iRef) + throws IOException, ExifInvalidFormatException { + return new ExifParser(inputStream, OPTION_IFD_0 | OPTION_IFD_1 + | OPTION_IFD_EXIF | OPTION_IFD_GPS | OPTION_IFD_INTEROPERABILITY + | OPTION_THUMBNAIL, iRef); + } + + /** + * Moves the parser forward and returns the next parsing event + * + * @exception java.io.IOException + * @exception ExifInvalidFormatException + * @see #EVENT_START_OF_IFD + * @see #EVENT_NEW_TAG + * @see #EVENT_VALUE_OF_REGISTERED_TAG + * @see #EVENT_COMPRESSED_IMAGE + * @see #EVENT_UNCOMPRESSED_STRIP + * @see #EVENT_END + */ + protected int next() throws IOException, ExifInvalidFormatException { + if (!mContainExifData) { + return EVENT_END; + } + int offset = mTiffStream.getReadByteCount(); + int endOfTags = mIfdStartOffset + OFFSET_SIZE + TAG_SIZE * mNumOfTagInIfd; + if (offset < endOfTags) { + mTag = readTag(); + if (mTag == null) { + return next(); + } + if (mNeedToParseOffsetsInCurrentIfd) { + checkOffsetOrImageTag(mTag); + } + return EVENT_NEW_TAG; + } else if (offset == endOfTags) { + // There is a link to ifd1 at the end of ifd0 + if (mIfdType == IfdId.TYPE_IFD_0) { + long ifdOffset = readUnsignedLong(); + if (isIfdRequested(IfdId.TYPE_IFD_1) || isThumbnailRequested()) { + if (ifdOffset != 0) { + registerIfd(IfdId.TYPE_IFD_1, ifdOffset); + } + } + } else { + int offsetSize = 4; + // Some camera models use invalid length of the offset + if (mCorrespondingEvent.size() > 0) { + offsetSize = mCorrespondingEvent.firstEntry().getKey() - + mTiffStream.getReadByteCount(); + } + if (offsetSize < 4) { + Log.w(TAG, "Invalid size of link to next IFD: " + offsetSize); + } else { + long ifdOffset = readUnsignedLong(); + if (ifdOffset != 0) { + Log.w(TAG, "Invalid link to next IFD: " + ifdOffset); + } + } + } + } + while (mCorrespondingEvent.size() != 0) { + Entry entry = mCorrespondingEvent.pollFirstEntry(); + Object event = entry.getValue(); + try { + skipTo(entry.getKey()); + } catch (IOException e) { + Log.w(TAG, "Failed to skip to data at: " + entry.getKey() + + " for " + event.getClass().getName() + ", the file may be broken."); + continue; + } + if (event instanceof IfdEvent) { + mIfdType = ((IfdEvent) event).ifd; + mNumOfTagInIfd = mTiffStream.readUnsignedShort(); + mIfdStartOffset = entry.getKey(); + + if (mNumOfTagInIfd * TAG_SIZE + mIfdStartOffset + OFFSET_SIZE > mApp1End) { + Log.w(TAG, "Invalid size of IFD " + mIfdType); + return EVENT_END; + } + + mNeedToParseOffsetsInCurrentIfd = needToParseOffsetsInCurrentIfd(); + if (((IfdEvent) event).isRequested) { + return EVENT_START_OF_IFD; + } else { + skipRemainingTagsInCurrentIfd(); + } + } else if (event instanceof ImageEvent) { + mImageEvent = (ImageEvent) event; + return mImageEvent.type; + } else { + ExifTagEvent tagEvent = (ExifTagEvent) event; + mTag = tagEvent.tag; + if (mTag.getDataType() != ExifTag.TYPE_UNDEFINED) { + readFullTagValue(mTag); + checkOffsetOrImageTag(mTag); + } + if (tagEvent.isRequested) { + return EVENT_VALUE_OF_REGISTERED_TAG; + } + } + } + return EVENT_END; + } + + /** + * Skips the tags area of current IFD, if the parser is not in the tag area, + * nothing will happen. + * + * @throws java.io.IOException + * @throws ExifInvalidFormatException + */ + protected void skipRemainingTagsInCurrentIfd() throws IOException, ExifInvalidFormatException { + int endOfTags = mIfdStartOffset + OFFSET_SIZE + TAG_SIZE * mNumOfTagInIfd; + int offset = mTiffStream.getReadByteCount(); + if (offset > endOfTags) { + return; + } + if (mNeedToParseOffsetsInCurrentIfd) { + while (offset < endOfTags) { + mTag = readTag(); + offset += TAG_SIZE; + if (mTag == null) { + continue; + } + checkOffsetOrImageTag(mTag); + } + } else { + skipTo(endOfTags); + } + long ifdOffset = readUnsignedLong(); + // For ifd0, there is a link to ifd1 in the end of all tags + if (mIfdType == IfdId.TYPE_IFD_0 + && (isIfdRequested(IfdId.TYPE_IFD_1) || isThumbnailRequested())) { + if (ifdOffset > 0) { + registerIfd(IfdId.TYPE_IFD_1, ifdOffset); + } + } + } + + private boolean needToParseOffsetsInCurrentIfd() { + switch (mIfdType) { + case IfdId.TYPE_IFD_0: + return isIfdRequested(IfdId.TYPE_IFD_EXIF) || isIfdRequested(IfdId.TYPE_IFD_GPS) + || isIfdRequested(IfdId.TYPE_IFD_INTEROPERABILITY) + || isIfdRequested(IfdId.TYPE_IFD_1); + case IfdId.TYPE_IFD_1: + return isThumbnailRequested(); + case IfdId.TYPE_IFD_EXIF: + // The offset to interoperability IFD is located in Exif IFD + return isIfdRequested(IfdId.TYPE_IFD_INTEROPERABILITY); + default: + return false; + } + } + + /** + * If {@link #next()} return {@link #EVENT_NEW_TAG} or + * {@link #EVENT_VALUE_OF_REGISTERED_TAG}, call this function to get the + * corresponding tag. + *

+ * For {@link #EVENT_NEW_TAG}, the tag may not contain the value if the size + * of the value is greater than 4 bytes. One should call + * {@link ExifTag#hasValue()} to check if the tag contains value. If there + * is no value,call {@link #registerForTagValue(ExifTag)} to have the parser + * emit {@link #EVENT_VALUE_OF_REGISTERED_TAG} when it reaches the area + * pointed by the offset. + *

+ * When {@link #EVENT_VALUE_OF_REGISTERED_TAG} is emitted, the value of the + * tag will have already been read except for tags of undefined type. For + * tags of undefined type, call one of the read methods to get the value. + * + * @see #registerForTagValue(ExifTag) + * @see #read(byte[]) + * @see #read(byte[], int, int) + * @see #readLong() + * @see #readRational() + * @see #readString(int) + * @see #readString(int, java.nio.charset.Charset) + */ + protected ExifTag getTag() { + return mTag; + } + + /** + * Gets number of tags in the current IFD area. + */ + protected int getTagCountInCurrentIfd() { + return mNumOfTagInIfd; + } + + /** + * Gets the ID of current IFD. + * + * @see IfdId#TYPE_IFD_0 + * @see IfdId#TYPE_IFD_1 + * @see IfdId#TYPE_IFD_GPS + * @see IfdId#TYPE_IFD_INTEROPERABILITY + * @see IfdId#TYPE_IFD_EXIF + */ + protected int getCurrentIfd() { + return mIfdType; + } + + /** + * When receiving {@link #EVENT_UNCOMPRESSED_STRIP}, call this function to + * get the index of this strip. + * + * @see #getStripCount() + */ + protected int getStripIndex() { + return mImageEvent.stripIndex; + } + + /** + * When receiving {@link #EVENT_UNCOMPRESSED_STRIP}, call this function to + * get the number of strip data. + * + * @see #getStripIndex() + */ + protected int getStripCount() { + return mStripCount; + } + + /** + * When receiving {@link #EVENT_UNCOMPRESSED_STRIP}, call this function to + * get the strip size. + */ + protected int getStripSize() { + if (mStripSizeTag == null) { + return 0; + } + return (int) mStripSizeTag.getValueAt(0); + } + + /** + * When receiving {@link #EVENT_COMPRESSED_IMAGE}, call this function to get + * the image data size. + */ + protected int getCompressedImageSize() { + if (mJpegSizeTag == null) { + return 0; + } + return (int) mJpegSizeTag.getValueAt(0); + } + + private void skipTo(int offset) throws IOException { + mTiffStream.skipTo(offset); + while (!mCorrespondingEvent.isEmpty() && mCorrespondingEvent.firstKey() < offset) { + mCorrespondingEvent.pollFirstEntry(); + } + } + + /** + * When getting {@link #EVENT_NEW_TAG} in the tag area of IFD, the tag may + * not contain the value if the size of the value is greater than 4 bytes. + * When the value is not available here, call this method so that the parser + * will emit {@link #EVENT_VALUE_OF_REGISTERED_TAG} when it reaches the area + * where the value is located. + * + * @see #EVENT_VALUE_OF_REGISTERED_TAG + */ + protected void registerForTagValue(ExifTag tag) { + if (tag.getOffset() >= mTiffStream.getReadByteCount()) { + mCorrespondingEvent.put(tag.getOffset(), new ExifTagEvent(tag, true)); + } + } + + private void registerIfd(int ifdType, long offset) { + // Cast unsigned int to int since the offset is always smaller + // than the size of APP1 (65536) + mCorrespondingEvent.put((int) offset, new IfdEvent(ifdType, isIfdRequested(ifdType))); + } + + private void registerCompressedImage(long offset) { + mCorrespondingEvent.put((int) offset, new ImageEvent(EVENT_COMPRESSED_IMAGE)); + } + + private void registerUncompressedStrip(int stripIndex, long offset) { + mCorrespondingEvent.put((int) offset, new ImageEvent(EVENT_UNCOMPRESSED_STRIP + , stripIndex)); + } + + private ExifTag readTag() throws IOException, ExifInvalidFormatException { + short tagId = mTiffStream.readShort(); + short dataFormat = mTiffStream.readShort(); + long numOfComp = mTiffStream.readUnsignedInt(); + if (numOfComp > Integer.MAX_VALUE) { + throw new ExifInvalidFormatException( + "Number of component is larger then Integer.MAX_VALUE"); + } + // Some invalid image file contains invalid data type. Ignore those tags + if (!ExifTag.isValidType(dataFormat)) { + Log.w(TAG, String.format("Tag %04x: Invalid data type %d", tagId, dataFormat)); + mTiffStream.skip(4); + return null; + } + // TODO: handle numOfComp overflow + ExifTag tag = new ExifTag(tagId, dataFormat, (int) numOfComp, mIfdType, + ((int) numOfComp) != ExifTag.SIZE_UNDEFINED); + int dataSize = tag.getDataSize(); + if (dataSize > 4) { + long offset = mTiffStream.readUnsignedInt(); + if (offset > Integer.MAX_VALUE) { + throw new ExifInvalidFormatException( + "offset is larger then Integer.MAX_VALUE"); + } + // Some invalid images put some undefined data before IFD0. + // Read the data here. + if ((offset < mIfd0Position) && (dataFormat == ExifTag.TYPE_UNDEFINED)) { + byte[] buf = new byte[(int) numOfComp]; + System.arraycopy(mDataAboveIfd0, (int) offset - DEFAULT_IFD0_OFFSET, + buf, 0, (int) numOfComp); + tag.setValue(buf); + } else { + tag.setOffset((int) offset); + } + } else { + boolean defCount = tag.hasDefinedCount(); + // Set defined count to 0 so we can add \0 to non-terminated strings + tag.setHasDefinedCount(false); + // Read value + readFullTagValue(tag); + tag.setHasDefinedCount(defCount); + mTiffStream.skip(4 - dataSize); + // Set the offset to the position of value. + tag.setOffset(mTiffStream.getReadByteCount() - 4); + } + return tag; + } + + /** + * Check the tag, if the tag is one of the offset tag that points to the IFD + * or image the caller is interested in, register the IFD or image. + */ + private void checkOffsetOrImageTag(ExifTag tag) { + // Some invalid formattd image contains tag with 0 size. + if (tag.getComponentCount() == 0) { + return; + } + short tid = tag.getTagId(); + int ifd = tag.getIfd(); + if (tid == TAG_EXIF_IFD && checkAllowed(ifd, ExifInterface.TAG_EXIF_IFD)) { + if (isIfdRequested(IfdId.TYPE_IFD_EXIF) + || isIfdRequested(IfdId.TYPE_IFD_INTEROPERABILITY)) { + registerIfd(IfdId.TYPE_IFD_EXIF, tag.getValueAt(0)); + } + } else if (tid == TAG_GPS_IFD && checkAllowed(ifd, ExifInterface.TAG_GPS_IFD)) { + if (isIfdRequested(IfdId.TYPE_IFD_GPS)) { + registerIfd(IfdId.TYPE_IFD_GPS, tag.getValueAt(0)); + } + } else if (tid == TAG_INTEROPERABILITY_IFD + && checkAllowed(ifd, ExifInterface.TAG_INTEROPERABILITY_IFD)) { + if (isIfdRequested(IfdId.TYPE_IFD_INTEROPERABILITY)) { + registerIfd(IfdId.TYPE_IFD_INTEROPERABILITY, tag.getValueAt(0)); + } + } else if (tid == TAG_JPEG_INTERCHANGE_FORMAT + && checkAllowed(ifd, ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT)) { + if (isThumbnailRequested()) { + registerCompressedImage(tag.getValueAt(0)); + } + } else if (tid == TAG_JPEG_INTERCHANGE_FORMAT_LENGTH + && checkAllowed(ifd, ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH)) { + if (isThumbnailRequested()) { + mJpegSizeTag = tag; + } + } else if (tid == TAG_STRIP_OFFSETS && checkAllowed(ifd, ExifInterface.TAG_STRIP_OFFSETS)) { + if (isThumbnailRequested()) { + if (tag.hasValue()) { + for (int i = 0; i < tag.getComponentCount(); i++) { + if (tag.getDataType() == ExifTag.TYPE_UNSIGNED_SHORT) { + registerUncompressedStrip(i, tag.getValueAt(i)); + } else { + registerUncompressedStrip(i, tag.getValueAt(i)); + } + } + } else { + mCorrespondingEvent.put(tag.getOffset(), new ExifTagEvent(tag, false)); + } + } + } else if (tid == TAG_STRIP_BYTE_COUNTS + && checkAllowed(ifd, ExifInterface.TAG_STRIP_BYTE_COUNTS) + && isThumbnailRequested() && tag.hasValue()) { + mStripSizeTag = tag; + } + } + + private boolean checkAllowed(int ifd, int tagId) { + int info = mInterface.getTagInfo().get(tagId); + if (info == ExifInterface.DEFINITION_NULL) { + return false; + } + return ExifInterface.isIfdAllowed(info, ifd); + } + + protected void readFullTagValue(ExifTag tag) throws IOException { + // Some invalid images contains tags with wrong size, check it here + short type = tag.getDataType(); + if (type == ExifTag.TYPE_ASCII || type == ExifTag.TYPE_UNDEFINED || + type == ExifTag.TYPE_UNSIGNED_BYTE) { + int size = tag.getComponentCount(); + if (mCorrespondingEvent.size() > 0) { + if (mCorrespondingEvent.firstEntry().getKey() < mTiffStream.getReadByteCount() + + size) { + Object event = mCorrespondingEvent.firstEntry().getValue(); + if (event instanceof ImageEvent) { + // Tag value overlaps thumbnail, ignore thumbnail. + Log.w(TAG, "Thumbnail overlaps value for tag: \n" + tag.toString()); + Entry entry = mCorrespondingEvent.pollFirstEntry(); + Log.w(TAG, "Invalid thumbnail offset: " + entry.getKey()); + } else { + // Tag value overlaps another tag, shorten count + if (event instanceof IfdEvent) { + Log.w(TAG, "Ifd " + ((IfdEvent) event).ifd + + " overlaps value for tag: \n" + tag.toString()); + } else if (event instanceof ExifTagEvent) { + Log.w(TAG, "Tag value for tag: \n" + + ((ExifTagEvent) event).tag.toString() + + " overlaps value for tag: \n" + tag.toString()); + } + size = mCorrespondingEvent.firstEntry().getKey() + - mTiffStream.getReadByteCount(); + Log.w(TAG, "Invalid size of tag: \n" + tag.toString() + + " setting count to: " + size); + tag.forceSetComponentCount(size); + } + } + } + } + switch (tag.getDataType()) { + case ExifTag.TYPE_UNSIGNED_BYTE: + case ExifTag.TYPE_UNDEFINED: { + byte buf[] = new byte[tag.getComponentCount()]; + read(buf); + tag.setValue(buf); + } + break; + case ExifTag.TYPE_ASCII: + tag.setValue(readString(tag.getComponentCount())); + break; + case ExifTag.TYPE_UNSIGNED_LONG: { + long value[] = new long[tag.getComponentCount()]; + for (int i = 0, n = value.length; i < n; i++) { + value[i] = readUnsignedLong(); + } + tag.setValue(value); + } + break; + case ExifTag.TYPE_UNSIGNED_RATIONAL: { + Rational value[] = new Rational[tag.getComponentCount()]; + for (int i = 0, n = value.length; i < n; i++) { + value[i] = readUnsignedRational(); + } + tag.setValue(value); + } + break; + case ExifTag.TYPE_UNSIGNED_SHORT: { + int value[] = new int[tag.getComponentCount()]; + for (int i = 0, n = value.length; i < n; i++) { + value[i] = readUnsignedShort(); + } + tag.setValue(value); + } + break; + case ExifTag.TYPE_LONG: { + int value[] = new int[tag.getComponentCount()]; + for (int i = 0, n = value.length; i < n; i++) { + value[i] = readLong(); + } + tag.setValue(value); + } + break; + case ExifTag.TYPE_RATIONAL: { + Rational value[] = new Rational[tag.getComponentCount()]; + for (int i = 0, n = value.length; i < n; i++) { + value[i] = readRational(); + } + tag.setValue(value); + } + break; + } + if (LOGV) { + Log.v(TAG, "\n" + tag.toString()); + } + } + + private void parseTiffHeader() throws IOException, + ExifInvalidFormatException { + short byteOrder = mTiffStream.readShort(); + if (LITTLE_ENDIAN_TAG == byteOrder) { + mTiffStream.setByteOrder(ByteOrder.LITTLE_ENDIAN); + } else if (BIG_ENDIAN_TAG == byteOrder) { + mTiffStream.setByteOrder(ByteOrder.BIG_ENDIAN); + } else { + throw new ExifInvalidFormatException("Invalid TIFF header"); + } + + if (mTiffStream.readShort() != TIFF_HEADER_TAIL) { + throw new ExifInvalidFormatException("Invalid TIFF header"); + } + } + + private boolean seekTiffData(InputStream inputStream) throws IOException, + ExifInvalidFormatException { + CountedDataInputStream dataStream = new CountedDataInputStream(inputStream); + if (dataStream.readShort() != JpegHeader.SOI) { + throw new ExifInvalidFormatException("Invalid JPEG format"); + } + + short marker = dataStream.readShort(); + while (marker != JpegHeader.EOI + && !JpegHeader.isSofMarker(marker)) { + int length = dataStream.readUnsignedShort(); + // Some invalid formatted image contains multiple APP1, + // try to find the one with Exif data. + if (marker == JpegHeader.APP1) { + int header = 0; + short headerTail = 0; + if (length >= 8) { + header = dataStream.readInt(); + headerTail = dataStream.readShort(); + length -= 6; + if (header == EXIF_HEADER && headerTail == EXIF_HEADER_TAIL) { + mTiffStartPosition = dataStream.getReadByteCount(); + mApp1End = length; + mOffsetToApp1EndFromSOF = mTiffStartPosition + mApp1End; + return true; + } + } + } + if (length < 2 || (length - 2) != dataStream.skip(length - 2)) { + Log.w(TAG, "Invalid JPEG format."); + return false; + } + marker = dataStream.readShort(); + } + return false; + } + + protected int getOffsetToExifEndFromSOF() { + return mOffsetToApp1EndFromSOF; + } + + protected int getTiffStartPosition() { + return mTiffStartPosition; + } + + /** + * Reads bytes from the InputStream. + */ + protected int read(byte[] buffer, int offset, int length) throws IOException { + return mTiffStream.read(buffer, offset, length); + } + + /** + * Equivalent to read(buffer, 0, buffer.length). + */ + protected int read(byte[] buffer) throws IOException { + return mTiffStream.read(buffer); + } + + /** + * Reads a String from the InputStream with US-ASCII charset. The parser + * will read n bytes and convert it to ascii string. This is used for + * reading values of type {@link ExifTag#TYPE_ASCII}. + */ + protected String readString(int n) throws IOException { + return readString(n, US_ASCII); + } + + /** + * Reads a String from the InputStream with the given charset. The parser + * will read n bytes and convert it to string. This is used for reading + * values of type {@link ExifTag#TYPE_ASCII}. + */ + protected String readString(int n, Charset charset) throws IOException { + if (n > 0) { + return mTiffStream.readString(n, charset); + } else { + return ""; + } + } + + /** + * Reads value of type {@link ExifTag#TYPE_UNSIGNED_SHORT} from the + * InputStream. + */ + protected int readUnsignedShort() throws IOException { + return mTiffStream.readShort() & 0xffff; + } + + /** + * Reads value of type {@link ExifTag#TYPE_UNSIGNED_LONG} from the + * InputStream. + */ + protected long readUnsignedLong() throws IOException { + return readLong() & 0xffffffffL; + } + + /** + * Reads value of type {@link ExifTag#TYPE_UNSIGNED_RATIONAL} from the + * InputStream. + */ + protected Rational readUnsignedRational() throws IOException { + long nomi = readUnsignedLong(); + long denomi = readUnsignedLong(); + return new Rational(nomi, denomi); + } + + /** + * Reads value of type {@link ExifTag#TYPE_LONG} from the InputStream. + */ + protected int readLong() throws IOException { + return mTiffStream.readInt(); + } + + /** + * Reads value of type {@link ExifTag#TYPE_RATIONAL} from the InputStream. + */ + protected Rational readRational() throws IOException { + int nomi = readLong(); + int denomi = readLong(); + return new Rational(nomi, denomi); + } + + private static class ImageEvent { + int stripIndex; + int type; + + ImageEvent(int type) { + this.stripIndex = 0; + this.type = type; + } + + ImageEvent(int type, int stripIndex) { + this.type = type; + this.stripIndex = stripIndex; + } + } + + private static class IfdEvent { + int ifd; + boolean isRequested; + + IfdEvent(int ifd, boolean isInterestedIfd) { + this.ifd = ifd; + this.isRequested = isInterestedIfd; + } + } + + private static class ExifTagEvent { + ExifTag tag; + boolean isRequested; + + ExifTagEvent(ExifTag tag, boolean isRequireByUser) { + this.tag = tag; + this.isRequested = isRequireByUser; + } + } + + /** + * Gets the byte order of the current InputStream. + */ + protected ByteOrder getByteOrder() { + return mTiffStream.getByteOrder(); + } +} diff --git a/src/com/android/messaging/util/exif/ExifReader.java b/src/com/android/messaging/util/exif/ExifReader.java new file mode 100644 index 0000000..eece48a --- /dev/null +++ b/src/com/android/messaging/util/exif/ExifReader.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util.exif; + +import android.util.Log; +import com.android.messaging.util.LogUtil; + +import java.io.IOException; +import java.io.InputStream; + +/** + * This class reads the EXIF header of a JPEG file and stores it in + * {@link ExifData}. + */ +class ExifReader { + private static final String TAG = LogUtil.BUGLE_TAG; + + private final ExifInterface mInterface; + + ExifReader(ExifInterface iRef) { + mInterface = iRef; + } + + /** + * Parses the inputStream and and returns the EXIF data in an + * {@link ExifData}. + * + * @throws ExifInvalidFormatException + * @throws java.io.IOException + */ + protected ExifData read(InputStream inputStream) throws ExifInvalidFormatException, + IOException { + ExifParser parser = ExifParser.parse(inputStream, mInterface); + ExifData exifData = new ExifData(parser.getByteOrder()); + ExifTag tag = null; + + int event = parser.next(); + while (event != ExifParser.EVENT_END) { + switch (event) { + case ExifParser.EVENT_START_OF_IFD: + exifData.addIfdData(new IfdData(parser.getCurrentIfd())); + break; + case ExifParser.EVENT_NEW_TAG: + tag = parser.getTag(); + if (!tag.hasValue()) { + parser.registerForTagValue(tag); + } else { + exifData.getIfdData(tag.getIfd()).setTag(tag); + } + break; + case ExifParser.EVENT_VALUE_OF_REGISTERED_TAG: + tag = parser.getTag(); + if (tag.getDataType() == ExifTag.TYPE_UNDEFINED) { + parser.readFullTagValue(tag); + } + exifData.getIfdData(tag.getIfd()).setTag(tag); + break; + case ExifParser.EVENT_COMPRESSED_IMAGE: + byte buf[] = new byte[parser.getCompressedImageSize()]; + if (buf.length == parser.read(buf)) { + exifData.setCompressedThumbnail(buf); + } else { + Log.w(TAG, "Failed to read the compressed thumbnail"); + } + break; + case ExifParser.EVENT_UNCOMPRESSED_STRIP: + buf = new byte[parser.getStripSize()]; + if (buf.length == parser.read(buf)) { + exifData.setStripBytes(parser.getStripIndex(), buf); + } else { + Log.w(TAG, "Failed to read the strip bytes"); + } + break; + } + event = parser.next(); + } + return exifData; + } +} diff --git a/src/com/android/messaging/util/exif/ExifTag.java b/src/com/android/messaging/util/exif/ExifTag.java new file mode 100644 index 0000000..da6f4ed --- /dev/null +++ b/src/com/android/messaging/util/exif/ExifTag.java @@ -0,0 +1,1008 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util.exif; + +import java.nio.charset.Charset; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; + +/** + * This class stores information of an EXIF tag. For more information about + * defined EXIF tags, please read the Jeita EXIF 2.2 standard. Tags should be + * instantiated using {@link ExifInterface#buildTag}. + * + * @see ExifInterface + */ +public class ExifTag { + /** + * The BYTE type in the EXIF standard. An 8-bit unsigned integer. + */ + public static final short TYPE_UNSIGNED_BYTE = 1; + /** + * The ASCII type in the EXIF standard. An 8-bit byte containing one 7-bit + * ASCII code. The final byte is terminated with NULL. + */ + public static final short TYPE_ASCII = 2; + /** + * The SHORT type in the EXIF standard. A 16-bit (2-byte) unsigned integer + */ + public static final short TYPE_UNSIGNED_SHORT = 3; + /** + * The LONG type in the EXIF standard. A 32-bit (4-byte) unsigned integer + */ + public static final short TYPE_UNSIGNED_LONG = 4; + /** + * The RATIONAL type of EXIF standard. It consists of two LONGs. The first + * one is the numerator and the second one expresses the denominator. + */ + public static final short TYPE_UNSIGNED_RATIONAL = 5; + /** + * The UNDEFINED type in the EXIF standard. An 8-bit byte that can take any + * value depending on the field definition. + */ + public static final short TYPE_UNDEFINED = 7; + /** + * The SLONG type in the EXIF standard. A 32-bit (4-byte) signed integer + * (2's complement notation). + */ + public static final short TYPE_LONG = 9; + /** + * The SRATIONAL type of EXIF standard. It consists of two SLONGs. The first + * one is the numerator and the second one is the denominator. + */ + public static final short TYPE_RATIONAL = 10; + + private static Charset US_ASCII = Charset.forName("US-ASCII"); + private static final int TYPE_TO_SIZE_MAP[] = new int[11]; + private static final int UNSIGNED_SHORT_MAX = 65535; + private static final long UNSIGNED_LONG_MAX = 4294967295L; + private static final long LONG_MAX = Integer.MAX_VALUE; + private static final long LONG_MIN = Integer.MIN_VALUE; + + static { + TYPE_TO_SIZE_MAP[TYPE_UNSIGNED_BYTE] = 1; + TYPE_TO_SIZE_MAP[TYPE_ASCII] = 1; + TYPE_TO_SIZE_MAP[TYPE_UNSIGNED_SHORT] = 2; + TYPE_TO_SIZE_MAP[TYPE_UNSIGNED_LONG] = 4; + TYPE_TO_SIZE_MAP[TYPE_UNSIGNED_RATIONAL] = 8; + TYPE_TO_SIZE_MAP[TYPE_UNDEFINED] = 1; + TYPE_TO_SIZE_MAP[TYPE_LONG] = 4; + TYPE_TO_SIZE_MAP[TYPE_RATIONAL] = 8; + } + + static final int SIZE_UNDEFINED = 0; + + // Exif TagId + private final short mTagId; + // Exif Tag Type + private final short mDataType; + // If tag has defined count + private boolean mHasDefinedDefaultComponentCount; + // Actual data count in tag (should be number of elements in value array) + private int mComponentCountActual; + // The ifd that this tag should be put in + private int mIfd; + // The value (array of elements of type Tag Type) + private Object mValue; + // Value offset in exif header. + private int mOffset; + + private static final SimpleDateFormat TIME_FORMAT = new SimpleDateFormat("yyyy:MM:dd kk:mm:ss"); + + /** + * Returns true if the given IFD is a valid IFD. + */ + public static boolean isValidIfd(int ifdId) { + return ifdId == IfdId.TYPE_IFD_0 || ifdId == IfdId.TYPE_IFD_1 + || ifdId == IfdId.TYPE_IFD_EXIF || ifdId == IfdId.TYPE_IFD_INTEROPERABILITY + || ifdId == IfdId.TYPE_IFD_GPS; + } + + /** + * Returns true if a given type is a valid tag type. + */ + public static boolean isValidType(short type) { + return type == TYPE_UNSIGNED_BYTE || type == TYPE_ASCII || + type == TYPE_UNSIGNED_SHORT || type == TYPE_UNSIGNED_LONG || + type == TYPE_UNSIGNED_RATIONAL || type == TYPE_UNDEFINED || + type == TYPE_LONG || type == TYPE_RATIONAL; + } + + // Use builtTag in ExifInterface instead of constructor. + ExifTag(short tagId, short type, int componentCount, int ifd, + boolean hasDefinedComponentCount) { + mTagId = tagId; + mDataType = type; + mComponentCountActual = componentCount; + mHasDefinedDefaultComponentCount = hasDefinedComponentCount; + mIfd = ifd; + mValue = null; + } + + /** + * Gets the element size of the given data type in bytes. + * + * @see #TYPE_ASCII + * @see #TYPE_LONG + * @see #TYPE_RATIONAL + * @see #TYPE_UNDEFINED + * @see #TYPE_UNSIGNED_BYTE + * @see #TYPE_UNSIGNED_LONG + * @see #TYPE_UNSIGNED_RATIONAL + * @see #TYPE_UNSIGNED_SHORT + */ + public static int getElementSize(short type) { + return TYPE_TO_SIZE_MAP[type]; + } + + /** + * Returns the ID of the IFD this tag belongs to. + * + * @see IfdId#TYPE_IFD_0 + * @see IfdId#TYPE_IFD_1 + * @see IfdId#TYPE_IFD_EXIF + * @see IfdId#TYPE_IFD_GPS + * @see IfdId#TYPE_IFD_INTEROPERABILITY + */ + public int getIfd() { + return mIfd; + } + + protected void setIfd(int ifdId) { + mIfd = ifdId; + } + + /** + * Gets the TID of this tag. + */ + public short getTagId() { + return mTagId; + } + + /** + * Gets the data type of this tag + * + * @see #TYPE_ASCII + * @see #TYPE_LONG + * @see #TYPE_RATIONAL + * @see #TYPE_UNDEFINED + * @see #TYPE_UNSIGNED_BYTE + * @see #TYPE_UNSIGNED_LONG + * @see #TYPE_UNSIGNED_RATIONAL + * @see #TYPE_UNSIGNED_SHORT + */ + public short getDataType() { + return mDataType; + } + + /** + * Gets the total data size in bytes of the value of this tag. + */ + public int getDataSize() { + return getComponentCount() * getElementSize(getDataType()); + } + + /** + * Gets the component count of this tag. + */ + + // TODO: fix integer overflows with this + public int getComponentCount() { + return mComponentCountActual; + } + + /** + * Sets the component count of this tag. Call this function before + * setValue() if the length of value does not match the component count. + */ + protected void forceSetComponentCount(int count) { + mComponentCountActual = count; + } + + /** + * Returns true if this ExifTag contains value; otherwise, this tag will + * contain an offset value that is determined when the tag is written. + */ + public boolean hasValue() { + return mValue != null; + } + + /** + * Sets integer values into this tag. This method should be used for tags of + * type {@link #TYPE_UNSIGNED_SHORT}. This method will fail if: + *

    + *
  • The component type of this tag is not {@link #TYPE_UNSIGNED_SHORT}, + * {@link #TYPE_UNSIGNED_LONG}, or {@link #TYPE_LONG}.
  • + *
  • The value overflows.
  • + *
  • The value.length does NOT match the component count in the definition + * for this tag.
  • + *
+ */ + public boolean setValue(int[] value) { + if (checkBadComponentCount(value.length)) { + return false; + } + if (mDataType != TYPE_UNSIGNED_SHORT && mDataType != TYPE_LONG && + mDataType != TYPE_UNSIGNED_LONG) { + return false; + } + if (mDataType == TYPE_UNSIGNED_SHORT && checkOverflowForUnsignedShort(value)) { + return false; + } else if (mDataType == TYPE_UNSIGNED_LONG && checkOverflowForUnsignedLong(value)) { + return false; + } + + long[] data = new long[value.length]; + for (int i = 0; i < value.length; i++) { + data[i] = value[i]; + } + mValue = data; + mComponentCountActual = value.length; + return true; + } + + /** + * Sets integer value into this tag. This method should be used for tags of + * type {@link #TYPE_UNSIGNED_SHORT}, or {@link #TYPE_LONG}. This method + * will fail if: + *
    + *
  • The component type of this tag is not {@link #TYPE_UNSIGNED_SHORT}, + * {@link #TYPE_UNSIGNED_LONG}, or {@link #TYPE_LONG}.
  • + *
  • The value overflows.
  • + *
  • The component count in the definition of this tag is not 1.
  • + *
+ */ + public boolean setValue(int value) { + return setValue(new int[] { + value + }); + } + + /** + * Sets long values into this tag. This method should be used for tags of + * type {@link #TYPE_UNSIGNED_LONG}. This method will fail if: + *
    + *
  • The component type of this tag is not {@link #TYPE_UNSIGNED_LONG}.
  • + *
  • The value overflows.
  • + *
  • The value.length does NOT match the component count in the definition + * for this tag.
  • + *
+ */ + public boolean setValue(long[] value) { + if (checkBadComponentCount(value.length) || mDataType != TYPE_UNSIGNED_LONG) { + return false; + } + if (checkOverflowForUnsignedLong(value)) { + return false; + } + mValue = value; + mComponentCountActual = value.length; + return true; + } + + /** + * Sets long values into this tag. This method should be used for tags of + * type {@link #TYPE_UNSIGNED_LONG}. This method will fail if: + *
    + *
  • The component type of this tag is not {@link #TYPE_UNSIGNED_LONG}.
  • + *
  • The value overflows.
  • + *
  • The component count in the definition for this tag is not 1.
  • + *
+ */ + public boolean setValue(long value) { + return setValue(new long[] { + value + }); + } + + /** + * Sets a string value into this tag. This method should be used for tags of + * type {@link #TYPE_ASCII}. The string is converted to an ASCII string. + * Characters that cannot be converted are replaced with '?'. The length of + * the string must be equal to either (component count -1) or (component + * count). The final byte will be set to the string null terminator '\0', + * overwriting the last character in the string if the value.length is equal + * to the component count. This method will fail if: + *
    + *
  • The data type is not {@link #TYPE_ASCII} or {@link #TYPE_UNDEFINED}.
  • + *
  • The length of the string is not equal to (component count -1) or + * (component count) in the definition for this tag.
  • + *
+ */ + public boolean setValue(String value) { + if (mDataType != TYPE_ASCII && mDataType != TYPE_UNDEFINED) { + return false; + } + + byte[] buf = value.getBytes(US_ASCII); + byte[] finalBuf = buf; + if (buf.length > 0) { + finalBuf = (buf[buf.length - 1] == 0 || mDataType == TYPE_UNDEFINED) ? buf : Arrays + .copyOf(buf, buf.length + 1); + } else if (mDataType == TYPE_ASCII && mComponentCountActual == 1) { + finalBuf = new byte[] { 0 }; + } + int count = finalBuf.length; + if (checkBadComponentCount(count)) { + return false; + } + mComponentCountActual = count; + mValue = finalBuf; + return true; + } + + /** + * Sets Rational values into this tag. This method should be used for tags + * of type {@link #TYPE_UNSIGNED_RATIONAL}, or {@link #TYPE_RATIONAL}. This + * method will fail if: + *
    + *
  • The component type of this tag is not {@link #TYPE_UNSIGNED_RATIONAL} + * or {@link #TYPE_RATIONAL}.
  • + *
  • The value overflows.
  • + *
  • The value.length does NOT match the component count in the definition + * for this tag.
  • + *
+ * + * @see Rational + */ + public boolean setValue(Rational[] value) { + if (checkBadComponentCount(value.length)) { + return false; + } + if (mDataType != TYPE_UNSIGNED_RATIONAL && mDataType != TYPE_RATIONAL) { + return false; + } + if (mDataType == TYPE_UNSIGNED_RATIONAL && checkOverflowForUnsignedRational(value)) { + return false; + } else if (mDataType == TYPE_RATIONAL && checkOverflowForRational(value)) { + return false; + } + + mValue = value; + mComponentCountActual = value.length; + return true; + } + + /** + * Sets a Rational value into this tag. This method should be used for tags + * of type {@link #TYPE_UNSIGNED_RATIONAL}, or {@link #TYPE_RATIONAL}. This + * method will fail if: + *
    + *
  • The component type of this tag is not {@link #TYPE_UNSIGNED_RATIONAL} + * or {@link #TYPE_RATIONAL}.
  • + *
  • The value overflows.
  • + *
  • The component count in the definition for this tag is not 1.
  • + *
+ * + * @see Rational + */ + public boolean setValue(Rational value) { + return setValue(new Rational[] { + value + }); + } + + /** + * Sets byte values into this tag. This method should be used for tags of + * type {@link #TYPE_UNSIGNED_BYTE} or {@link #TYPE_UNDEFINED}. This method + * will fail if: + *
    + *
  • The component type of this tag is not {@link #TYPE_UNSIGNED_BYTE} or + * {@link #TYPE_UNDEFINED} .
  • + *
  • The length does NOT match the component count in the definition for + * this tag.
  • + *
+ */ + public boolean setValue(byte[] value, int offset, int length) { + if (checkBadComponentCount(length)) { + return false; + } + if (mDataType != TYPE_UNSIGNED_BYTE && mDataType != TYPE_UNDEFINED) { + return false; + } + mValue = new byte[length]; + System.arraycopy(value, offset, mValue, 0, length); + mComponentCountActual = length; + return true; + } + + /** + * Equivalent to setValue(value, 0, value.length). + */ + public boolean setValue(byte[] value) { + return setValue(value, 0, value.length); + } + + /** + * Sets byte value into this tag. This method should be used for tags of + * type {@link #TYPE_UNSIGNED_BYTE} or {@link #TYPE_UNDEFINED}. This method + * will fail if: + *
    + *
  • The component type of this tag is not {@link #TYPE_UNSIGNED_BYTE} or + * {@link #TYPE_UNDEFINED} .
  • + *
  • The component count in the definition for this tag is not 1.
  • + *
+ */ + public boolean setValue(byte value) { + return setValue(new byte[] { + value + }); + } + + /** + * Sets the value for this tag using an appropriate setValue method for the + * given object. This method will fail if: + *
    + *
  • The corresponding setValue method for the class of the object passed + * in would fail.
  • + *
  • There is no obvious way to cast the object passed in into an EXIF tag + * type.
  • + *
+ */ + public boolean setValue(Object obj) { + if (obj == null) { + return false; + } else if (obj instanceof Short) { + return setValue(((Short) obj).shortValue() & 0x0ffff); + } else if (obj instanceof String) { + return setValue((String) obj); + } else if (obj instanceof int[]) { + return setValue((int[]) obj); + } else if (obj instanceof long[]) { + return setValue((long[]) obj); + } else if (obj instanceof Rational) { + return setValue((Rational) obj); + } else if (obj instanceof Rational[]) { + return setValue((Rational[]) obj); + } else if (obj instanceof byte[]) { + return setValue((byte[]) obj); + } else if (obj instanceof Integer) { + return setValue(((Integer) obj).intValue()); + } else if (obj instanceof Long) { + return setValue(((Long) obj).longValue()); + } else if (obj instanceof Byte) { + return setValue(((Byte) obj).byteValue()); + } else if (obj instanceof Short[]) { + // Nulls in this array are treated as zeroes. + Short[] arr = (Short[]) obj; + int[] fin = new int[arr.length]; + for (int i = 0; i < arr.length; i++) { + fin[i] = (arr[i] == null) ? 0 : arr[i].shortValue() & 0x0ffff; + } + return setValue(fin); + } else if (obj instanceof Integer[]) { + // Nulls in this array are treated as zeroes. + Integer[] arr = (Integer[]) obj; + int[] fin = new int[arr.length]; + for (int i = 0; i < arr.length; i++) { + fin[i] = (arr[i] == null) ? 0 : arr[i].intValue(); + } + return setValue(fin); + } else if (obj instanceof Long[]) { + // Nulls in this array are treated as zeroes. + Long[] arr = (Long[]) obj; + long[] fin = new long[arr.length]; + for (int i = 0; i < arr.length; i++) { + fin[i] = (arr[i] == null) ? 0 : arr[i].longValue(); + } + return setValue(fin); + } else if (obj instanceof Byte[]) { + // Nulls in this array are treated as zeroes. + Byte[] arr = (Byte[]) obj; + byte[] fin = new byte[arr.length]; + for (int i = 0; i < arr.length; i++) { + fin[i] = (arr[i] == null) ? 0 : arr[i].byteValue(); + } + return setValue(fin); + } else { + return false; + } + } + + /** + * Sets a timestamp to this tag. The method converts the timestamp with the + * format of "yyyy:MM:dd kk:mm:ss" and calls {@link #setValue(String)}. This + * method will fail if the data type is not {@link #TYPE_ASCII} or the + * component count of this tag is not 20 or undefined. + * + * @param time the number of milliseconds since Jan. 1, 1970 GMT + * @return true on success + */ + public boolean setTimeValue(long time) { + // synchronized on TIME_FORMAT as SimpleDateFormat is not thread safe + synchronized (TIME_FORMAT) { + return setValue(TIME_FORMAT.format(new Date(time))); + } + } + + /** + * Gets the value as a String. This method should be used for tags of type + * {@link #TYPE_ASCII}. + * + * @return the value as a String, or null if the tag's value does not exist + * or cannot be converted to a String. + */ + public String getValueAsString() { + if (mValue == null) { + return null; + } else if (mValue instanceof String) { + return (String) mValue; + } else if (mValue instanceof byte[]) { + return new String((byte[]) mValue, US_ASCII); + } + return null; + } + + /** + * Gets the value as a String. This method should be used for tags of type + * {@link #TYPE_ASCII}. + * + * @param defaultValue the String to return if the tag's value does not + * exist or cannot be converted to a String. + * @return the tag's value as a String, or the defaultValue. + */ + public String getValueAsString(String defaultValue) { + String s = getValueAsString(); + if (s == null) { + return defaultValue; + } + return s; + } + + /** + * Gets the value as a byte array. This method should be used for tags of + * type {@link #TYPE_UNDEFINED} or {@link #TYPE_UNSIGNED_BYTE}. + * + * @return the value as a byte array, or null if the tag's value does not + * exist or cannot be converted to a byte array. + */ + public byte[] getValueAsBytes() { + if (mValue instanceof byte[]) { + return (byte[]) mValue; + } + return null; + } + + /** + * Gets the value as a byte. If there are more than 1 bytes in this value, + * gets the first byte. This method should be used for tags of type + * {@link #TYPE_UNDEFINED} or {@link #TYPE_UNSIGNED_BYTE}. + * + * @param defaultValue the byte to return if tag's value does not exist or + * cannot be converted to a byte. + * @return the tag's value as a byte, or the defaultValue. + */ + public byte getValueAsByte(byte defaultValue) { + byte[] b = getValueAsBytes(); + if (b == null || b.length < 1) { + return defaultValue; + } + return b[0]; + } + + /** + * Gets the value as an array of Rationals. This method should be used for + * tags of type {@link #TYPE_RATIONAL} or {@link #TYPE_UNSIGNED_RATIONAL}. + * + * @return the value as as an array of Rationals, or null if the tag's value + * does not exist or cannot be converted to an array of Rationals. + */ + public Rational[] getValueAsRationals() { + if (mValue instanceof Rational[]) { + return (Rational[]) mValue; + } + return null; + } + + /** + * Gets the value as a Rational. If there are more than 1 Rationals in this + * value, gets the first one. This method should be used for tags of type + * {@link #TYPE_RATIONAL} or {@link #TYPE_UNSIGNED_RATIONAL}. + * + * @param defaultValue the Rational to return if tag's value does not exist + * or cannot be converted to a Rational. + * @return the tag's value as a Rational, or the defaultValue. + */ + public Rational getValueAsRational(Rational defaultValue) { + Rational[] r = getValueAsRationals(); + if (r == null || r.length < 1) { + return defaultValue; + } + return r[0]; + } + + /** + * Gets the value as a Rational. If there are more than 1 Rationals in this + * value, gets the first one. This method should be used for tags of type + * {@link #TYPE_RATIONAL} or {@link #TYPE_UNSIGNED_RATIONAL}. + * + * @param defaultValue the numerator of the Rational to return if tag's + * value does not exist or cannot be converted to a Rational (the + * denominator will be 1). + * @return the tag's value as a Rational, or the defaultValue. + */ + public Rational getValueAsRational(long defaultValue) { + Rational defaultVal = new Rational(defaultValue, 1); + return getValueAsRational(defaultVal); + } + + /** + * Gets the value as an array of ints. This method should be used for tags + * of type {@link #TYPE_UNSIGNED_SHORT}, {@link #TYPE_UNSIGNED_LONG}. + * + * @return the value as as an array of ints, or null if the tag's value does + * not exist or cannot be converted to an array of ints. + */ + public int[] getValueAsInts() { + if (mValue == null) { + return null; + } else if (mValue instanceof long[]) { + long[] val = (long[]) mValue; + int[] arr = new int[val.length]; + for (int i = 0; i < val.length; i++) { + arr[i] = (int) val[i]; // Truncates + } + return arr; + } + return null; + } + + /** + * Gets the value as an int. If there are more than 1 ints in this value, + * gets the first one. This method should be used for tags of type + * {@link #TYPE_UNSIGNED_SHORT}, {@link #TYPE_UNSIGNED_LONG}. + * + * @param defaultValue the int to return if tag's value does not exist or + * cannot be converted to an int. + * @return the tag's value as a int, or the defaultValue. + */ + public int getValueAsInt(int defaultValue) { + int[] i = getValueAsInts(); + if (i == null || i.length < 1) { + return defaultValue; + } + return i[0]; + } + + /** + * Gets the value as an array of longs. This method should be used for tags + * of type {@link #TYPE_UNSIGNED_LONG}. + * + * @return the value as as an array of longs, or null if the tag's value + * does not exist or cannot be converted to an array of longs. + */ + public long[] getValueAsLongs() { + if (mValue instanceof long[]) { + return (long[]) mValue; + } + return null; + } + + /** + * Gets the value or null if none exists. If there are more than 1 longs in + * this value, gets the first one. This method should be used for tags of + * type {@link #TYPE_UNSIGNED_LONG}. + * + * @param defaultValue the long to return if tag's value does not exist or + * cannot be converted to a long. + * @return the tag's value as a long, or the defaultValue. + */ + public long getValueAsLong(long defaultValue) { + long[] l = getValueAsLongs(); + if (l == null || l.length < 1) { + return defaultValue; + } + return l[0]; + } + + /** + * Gets the tag's value or null if none exists. + */ + public Object getValue() { + return mValue; + } + + /** + * Gets a long representation of the value. + * + * @param defaultValue value to return if there is no value or value is a + * rational with a denominator of 0. + * @return the tag's value as a long, or defaultValue if no representation + * exists. + */ + public long forceGetValueAsLong(long defaultValue) { + long[] l = getValueAsLongs(); + if (l != null && l.length >= 1) { + return l[0]; + } + byte[] b = getValueAsBytes(); + if (b != null && b.length >= 1) { + return b[0]; + } + Rational[] r = getValueAsRationals(); + if (r != null && r.length >= 1 && r[0].getDenominator() != 0) { + return (long) r[0].toDouble(); + } + return defaultValue; + } + + /** + * Gets a string representation of the value. + */ + public String forceGetValueAsString() { + if (mValue == null) { + return ""; + } else if (mValue instanceof byte[]) { + if (mDataType == TYPE_ASCII) { + return new String((byte[]) mValue, US_ASCII); + } else { + return Arrays.toString((byte[]) mValue); + } + } else if (mValue instanceof long[]) { + if (((long[]) mValue).length == 1) { + return String.valueOf(((long[]) mValue)[0]); + } else { + return Arrays.toString((long[]) mValue); + } + } else if (mValue instanceof Object[]) { + if (((Object[]) mValue).length == 1) { + Object val = ((Object[]) mValue)[0]; + if (val == null) { + return ""; + } else { + return val.toString(); + } + } else { + return Arrays.toString((Object[]) mValue); + } + } else { + return mValue.toString(); + } + } + + /** + * Gets the value for type {@link #TYPE_ASCII}, {@link #TYPE_LONG}, + * {@link #TYPE_UNDEFINED}, {@link #TYPE_UNSIGNED_BYTE}, + * {@link #TYPE_UNSIGNED_LONG}, or {@link #TYPE_UNSIGNED_SHORT}. For + * {@link #TYPE_RATIONAL} or {@link #TYPE_UNSIGNED_RATIONAL}, call + * {@link #getRational(int)} instead. + * + * @exception IllegalArgumentException if the data type is + * {@link #TYPE_RATIONAL} or {@link #TYPE_UNSIGNED_RATIONAL}. + */ + protected long getValueAt(int index) { + if (mValue instanceof long[]) { + return ((long[]) mValue)[index]; + } else if (mValue instanceof byte[]) { + return ((byte[]) mValue)[index]; + } + throw new IllegalArgumentException("Cannot get integer value from " + + convertTypeToString(mDataType)); + } + + /** + * Gets the {@link #TYPE_ASCII} data. + * + * @exception IllegalArgumentException If the type is NOT + * {@link #TYPE_ASCII}. + */ + protected String getString() { + if (mDataType != TYPE_ASCII) { + throw new IllegalArgumentException("Cannot get ASCII value from " + + convertTypeToString(mDataType)); + } + return new String((byte[]) mValue, US_ASCII); + } + + /* + * Get the converted ascii byte. Used by ExifOutputStream. + */ + protected byte[] getStringByte() { + return (byte[]) mValue; + } + + /** + * Gets the {@link #TYPE_RATIONAL} or {@link #TYPE_UNSIGNED_RATIONAL} data. + * + * @exception IllegalArgumentException If the type is NOT + * {@link #TYPE_RATIONAL} or {@link #TYPE_UNSIGNED_RATIONAL}. + */ + protected Rational getRational(int index) { + if ((mDataType != TYPE_RATIONAL) && (mDataType != TYPE_UNSIGNED_RATIONAL)) { + throw new IllegalArgumentException("Cannot get RATIONAL value from " + + convertTypeToString(mDataType)); + } + return ((Rational[]) mValue)[index]; + } + + /** + * Equivalent to getBytes(buffer, 0, buffer.length). + */ + protected void getBytes(byte[] buf) { + getBytes(buf, 0, buf.length); + } + + /** + * Gets the {@link #TYPE_UNDEFINED} or {@link #TYPE_UNSIGNED_BYTE} data. + * + * @param buf the byte array in which to store the bytes read. + * @param offset the initial position in buffer to store the bytes. + * @param length the maximum number of bytes to store in buffer. If length > + * component count, only the valid bytes will be stored. + * @exception IllegalArgumentException If the type is NOT + * {@link #TYPE_UNDEFINED} or {@link #TYPE_UNSIGNED_BYTE}. + */ + protected void getBytes(byte[] buf, int offset, int length) { + if ((mDataType != TYPE_UNDEFINED) && (mDataType != TYPE_UNSIGNED_BYTE)) { + throw new IllegalArgumentException("Cannot get BYTE value from " + + convertTypeToString(mDataType)); + } + System.arraycopy(mValue, 0, buf, offset, + (length > mComponentCountActual) ? mComponentCountActual : length); + } + + /** + * Gets the offset of this tag. This is only valid if this data size > 4 and + * contains an offset to the location of the actual value. + */ + protected int getOffset() { + return mOffset; + } + + /** + * Sets the offset of this tag. + */ + protected void setOffset(int offset) { + mOffset = offset; + } + + protected void setHasDefinedCount(boolean d) { + mHasDefinedDefaultComponentCount = d; + } + + protected boolean hasDefinedCount() { + return mHasDefinedDefaultComponentCount; + } + + private boolean checkBadComponentCount(int count) { + if (mHasDefinedDefaultComponentCount && (mComponentCountActual != count)) { + return true; + } + return false; + } + + private static String convertTypeToString(short type) { + switch (type) { + case TYPE_UNSIGNED_BYTE: + return "UNSIGNED_BYTE"; + case TYPE_ASCII: + return "ASCII"; + case TYPE_UNSIGNED_SHORT: + return "UNSIGNED_SHORT"; + case TYPE_UNSIGNED_LONG: + return "UNSIGNED_LONG"; + case TYPE_UNSIGNED_RATIONAL: + return "UNSIGNED_RATIONAL"; + case TYPE_UNDEFINED: + return "UNDEFINED"; + case TYPE_LONG: + return "LONG"; + case TYPE_RATIONAL: + return "RATIONAL"; + default: + return ""; + } + } + + private boolean checkOverflowForUnsignedShort(int[] value) { + for (int v : value) { + if (v > UNSIGNED_SHORT_MAX || v < 0) { + return true; + } + } + return false; + } + + private boolean checkOverflowForUnsignedLong(long[] value) { + for (long v : value) { + if (v < 0 || v > UNSIGNED_LONG_MAX) { + return true; + } + } + return false; + } + + private boolean checkOverflowForUnsignedLong(int[] value) { + for (int v : value) { + if (v < 0) { + return true; + } + } + return false; + } + + private boolean checkOverflowForUnsignedRational(Rational[] value) { + for (Rational v : value) { + if (v.getNumerator() < 0 || v.getDenominator() < 0 + || v.getNumerator() > UNSIGNED_LONG_MAX + || v.getDenominator() > UNSIGNED_LONG_MAX) { + return true; + } + } + return false; + } + + private boolean checkOverflowForRational(Rational[] value) { + for (Rational v : value) { + if (v.getNumerator() < LONG_MIN || v.getDenominator() < LONG_MIN + || v.getNumerator() > LONG_MAX + || v.getDenominator() > LONG_MAX) { + return true; + } + } + return false; + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (obj instanceof ExifTag) { + ExifTag tag = (ExifTag) obj; + if (tag.mTagId != this.mTagId + || tag.mComponentCountActual != this.mComponentCountActual + || tag.mDataType != this.mDataType) { + return false; + } + if (mValue != null) { + if (tag.mValue == null) { + return false; + } else if (mValue instanceof long[]) { + if (!(tag.mValue instanceof long[])) { + return false; + } + return Arrays.equals((long[]) mValue, (long[]) tag.mValue); + } else if (mValue instanceof Rational[]) { + if (!(tag.mValue instanceof Rational[])) { + return false; + } + return Arrays.equals((Rational[]) mValue, (Rational[]) tag.mValue); + } else if (mValue instanceof byte[]) { + if (!(tag.mValue instanceof byte[])) { + return false; + } + return Arrays.equals((byte[]) mValue, (byte[]) tag.mValue); + } else { + return mValue.equals(tag.mValue); + } + } else { + return tag.mValue == null; + } + } + return false; + } + + @Override + public String toString() { + return String.format("tag id: %04X\n", mTagId) + "ifd id: " + mIfd + "\ntype: " + + convertTypeToString(mDataType) + "\ncount: " + mComponentCountActual + + "\noffset: " + mOffset + "\nvalue: " + forceGetValueAsString() + "\n"; + } + +} diff --git a/src/com/android/messaging/util/exif/IfdData.java b/src/com/android/messaging/util/exif/IfdData.java new file mode 100644 index 0000000..6b8c293 --- /dev/null +++ b/src/com/android/messaging/util/exif/IfdData.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util.exif; + +import java.util.HashMap; +import java.util.Map; + +/** + * This class stores all the tags in an IFD. + * + * @see ExifData + * @see ExifTag + */ +class IfdData { + + private final int mIfdId; + private final Map mExifTags = new HashMap(); + private int mOffsetToNextIfd = 0; + private static final int[] sIfds = { + IfdId.TYPE_IFD_0, IfdId.TYPE_IFD_1, IfdId.TYPE_IFD_EXIF, + IfdId.TYPE_IFD_INTEROPERABILITY, IfdId.TYPE_IFD_GPS + }; + /** + * Creates an IfdData with given IFD ID. + * + * @see IfdId#TYPE_IFD_0 + * @see IfdId#TYPE_IFD_1 + * @see IfdId#TYPE_IFD_EXIF + * @see IfdId#TYPE_IFD_GPS + * @see IfdId#TYPE_IFD_INTEROPERABILITY + */ + IfdData(int ifdId) { + mIfdId = ifdId; + } + + protected static int[] getIfds() { + return sIfds; + } + + /** + * Get a array the contains all {@link ExifTag} in this IFD. + */ + protected ExifTag[] getAllTags() { + return mExifTags.values().toArray(new ExifTag[mExifTags.size()]); + } + + /** + * Gets the ID of this IFD. + * + * @see IfdId#TYPE_IFD_0 + * @see IfdId#TYPE_IFD_1 + * @see IfdId#TYPE_IFD_EXIF + * @see IfdId#TYPE_IFD_GPS + * @see IfdId#TYPE_IFD_INTEROPERABILITY + */ + protected int getId() { + return mIfdId; + } + + /** + * Gets the {@link ExifTag} with given tag id. Return null if there is no + * such tag. + */ + protected ExifTag getTag(short tagId) { + return mExifTags.get(tagId); + } + + /** + * Adds or replaces a {@link ExifTag}. + */ + protected ExifTag setTag(ExifTag tag) { + tag.setIfd(mIfdId); + return mExifTags.put(tag.getTagId(), tag); + } + + protected boolean checkCollision(short tagId) { + return mExifTags.get(tagId) != null; + } + + /** + * Removes the tag of the given ID + */ + protected void removeTag(short tagId) { + mExifTags.remove(tagId); + } + + /** + * Gets the tags count in the IFD. + */ + protected int getTagCount() { + return mExifTags.size(); + } + + /** + * Sets the offset of next IFD. + */ + protected void setOffsetToNextIfd(int offset) { + mOffsetToNextIfd = offset; + } + + /** + * Gets the offset of next IFD. + */ + protected int getOffsetToNextIfd() { + return mOffsetToNextIfd; + } + + /** + * Returns true if all tags in this two IFDs are equal. Note that tags of + * IFDs offset or thumbnail offset will be ignored. + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (obj instanceof IfdData) { + IfdData data = (IfdData) obj; + if (data.getId() == mIfdId && data.getTagCount() == getTagCount()) { + ExifTag[] tags = data.getAllTags(); + for (ExifTag tag : tags) { + if (ExifInterface.isOffsetTag(tag.getTagId())) { + continue; + } + ExifTag tag2 = mExifTags.get(tag.getTagId()); + if (!tag.equals(tag2)) { + return false; + } + } + return true; + } + } + return false; + } +} diff --git a/src/com/android/messaging/util/exif/IfdId.java b/src/com/android/messaging/util/exif/IfdId.java new file mode 100644 index 0000000..06a820d --- /dev/null +++ b/src/com/android/messaging/util/exif/IfdId.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util.exif; + +/** + * The constants of the IFD ID defined in EXIF spec. + */ +public interface IfdId { + public static final int TYPE_IFD_0 = 0; + public static final int TYPE_IFD_1 = 1; + public static final int TYPE_IFD_EXIF = 2; + public static final int TYPE_IFD_INTEROPERABILITY = 3; + public static final int TYPE_IFD_GPS = 4; + /* This is used in ExifData to allocate enough IfdData */ + static final int TYPE_IFD_COUNT = 5; + +} diff --git a/src/com/android/messaging/util/exif/JpegHeader.java b/src/com/android/messaging/util/exif/JpegHeader.java new file mode 100644 index 0000000..1dd12a5 --- /dev/null +++ b/src/com/android/messaging/util/exif/JpegHeader.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util.exif; + +class JpegHeader { + public static final short SOI = (short) 0xFFD8; + public static final short APP1 = (short) 0xFFE1; + public static final short APP0 = (short) 0xFFE0; + public static final short EOI = (short) 0xFFD9; + + /** + * SOF (start of frame). All value between SOF0 and SOF15 is SOF marker except for DHT, JPG, + * and DAC marker. + */ + public static final short SOF0 = (short) 0xFFC0; + public static final short SOF15 = (short) 0xFFCF; + public static final short DHT = (short) 0xFFC4; + public static final short JPG = (short) 0xFFC8; + public static final short DAC = (short) 0xFFCC; + + public static final boolean isSofMarker(short marker) { + return marker >= SOF0 && marker <= SOF15 && marker != DHT && marker != JPG + && marker != DAC; + } +} diff --git a/src/com/android/messaging/util/exif/OrderedDataOutputStream.java b/src/com/android/messaging/util/exif/OrderedDataOutputStream.java new file mode 100644 index 0000000..cf64805 --- /dev/null +++ b/src/com/android/messaging/util/exif/OrderedDataOutputStream.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util.exif; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +class OrderedDataOutputStream extends FilterOutputStream { + private final ByteBuffer mByteBuffer = ByteBuffer.allocate(4); + + public OrderedDataOutputStream(OutputStream out) { + super(out); + } + + public OrderedDataOutputStream setByteOrder(ByteOrder order) { + mByteBuffer.order(order); + return this; + } + + public OrderedDataOutputStream writeShort(short value) throws IOException { + mByteBuffer.rewind(); + mByteBuffer.putShort(value); + out.write(mByteBuffer.array(), 0, 2); + return this; + } + + public OrderedDataOutputStream writeInt(int value) throws IOException { + mByteBuffer.rewind(); + mByteBuffer.putInt(value); + out.write(mByteBuffer.array()); + return this; + } + + public OrderedDataOutputStream writeRational(Rational rational) throws IOException { + writeInt((int) rational.getNumerator()); + writeInt((int) rational.getDenominator()); + return this; + } +} diff --git a/src/com/android/messaging/util/exif/Rational.java b/src/com/android/messaging/util/exif/Rational.java new file mode 100644 index 0000000..b42ceb7 --- /dev/null +++ b/src/com/android/messaging/util/exif/Rational.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util.exif; + +/** + * The rational data type of EXIF tag. Contains a pair of longs representing the + * numerator and denominator of a Rational number. + */ +public class Rational { + + private final long mNumerator; + private final long mDenominator; + + /** + * Create a Rational with a given numerator and denominator. + * + * @param nominator + * @param denominator + */ + public Rational(long nominator, long denominator) { + mNumerator = nominator; + mDenominator = denominator; + } + + /** + * Create a copy of a Rational. + */ + public Rational(Rational r) { + mNumerator = r.mNumerator; + mDenominator = r.mDenominator; + } + + /** + * Gets the numerator of the rational. + */ + public long getNumerator() { + return mNumerator; + } + + /** + * Gets the denominator of the rational + */ + public long getDenominator() { + return mDenominator; + } + + /** + * Gets the rational value as type double. Will cause a divide-by-zero error + * if the denominator is 0. + */ + public double toDouble() { + return mNumerator / (double) mDenominator; + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (this == obj) { + return true; + } + if (obj instanceof Rational) { + Rational data = (Rational) obj; + return mNumerator == data.mNumerator && mDenominator == data.mDenominator; + } + return false; + } + + @Override + public String toString() { + return mNumerator + "/" + mDenominator; + } +} diff --git a/src/com/android/messaging/widget/BaseWidgetFactory.java b/src/com/android/messaging/widget/BaseWidgetFactory.java new file mode 100644 index 0000000..30b80ae --- /dev/null +++ b/src/com/android/messaging/widget/BaseWidgetFactory.java @@ -0,0 +1,232 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.widget; + +import android.appwidget.AppWidgetManager; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Typeface; +import android.net.Uri; +import android.os.Binder; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.style.StyleSpan; +import android.widget.RemoteViews; +import android.widget.RemoteViewsService; + +import com.android.messaging.R; +import com.android.messaging.datamodel.media.AvatarGroupRequestDescriptor; +import com.android.messaging.datamodel.media.AvatarRequestDescriptor; +import com.android.messaging.datamodel.media.ImageRequestDescriptor; +import com.android.messaging.datamodel.media.ImageResource; +import com.android.messaging.datamodel.media.MediaRequest; +import com.android.messaging.datamodel.media.MediaResourceManager; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.LogUtil; + +/** + * Remote Views Factory for Bugle Widget. + */ +abstract class BaseWidgetFactory implements RemoteViewsService.RemoteViewsFactory { + protected static final String TAG = LogUtil.BUGLE_WIDGET_TAG; + + protected static final int MAX_ITEMS_TO_SHOW = 25; + + /** + * Lock to avoid race condition between widgets. + */ + protected static final Object sWidgetLock = new Object(); + + protected final Context mContext; + protected final int mAppWidgetId; + protected boolean mShouldShowViewMore; + protected Cursor mCursor; + protected final AppWidgetManager mAppWidgetManager; + protected int mIconSize; + protected ImageResource mAvatarResource; + + public BaseWidgetFactory(Context context, Intent intent) { + mContext = context; + mAppWidgetId = intent.getIntExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); + mAppWidgetManager = AppWidgetManager.getInstance(context); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "BaseWidgetFactory intent: " + intent + "widget id: " + mAppWidgetId); + } + mIconSize = (int) context.getResources() + .getDimension(R.dimen.contact_icon_view_normal_size); + + } + + @Override + public void onCreate() { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "onCreate"); + } + } + + @Override + public void onDestroy() { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "onDestroy"); + } + synchronized (sWidgetLock) { + if (mCursor != null && !mCursor.isClosed()) { + mCursor.close(); + mCursor = null; + } + } + } + + @Override + public void onDataSetChanged() { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "onDataSetChanged"); + } + synchronized (sWidgetLock) { + if (mCursor != null) { + mCursor.close(); + mCursor = null; + } + final long token = Binder.clearCallingIdentity(); + try { + mCursor = doQuery(); + onLoadComplete(); + } finally { + Binder.restoreCallingIdentity(token); + } + } + } + + protected abstract Cursor doQuery(); + + /** + * Returns the number of items that should be shown in the widget list. This method also + * updates the boolean that indicates whether the "show more" item should be shown. + * @return the number of items to be displayed in the list. + */ + @Override + public int getCount() { + synchronized (sWidgetLock) { + if (mCursor == null) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "getCount: 0"); + } + return 0; + } + final int count = getItemCount(); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "getCount: " + count); + } + mShouldShowViewMore = count < mCursor.getCount(); + return count + (mShouldShowViewMore ? 1 : 0); + } + } + + /** + * Returns the number of messages that should be shown in the widget. This method + * doesn't update the boolean that indicates whether the "show more" item should be included + * in the list. + * @return + */ + protected int getItemCount() { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "getItemCount: " + mCursor.getCount()); + } + return Math.min(mCursor.getCount(), MAX_ITEMS_TO_SHOW); + } + + /* + * Make the given text bold if the item is unread + */ + protected CharSequence boldifyIfUnread(CharSequence text, final boolean unread) { + if (!unread) { + return text; + } + final SpannableStringBuilder builder = new SpannableStringBuilder(text); + builder.setSpan(new StyleSpan(Typeface.BOLD), 0, text.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return builder; + } + + protected Bitmap getAvatarBitmap(final Uri avatarUri) { + final String avatarType = avatarUri == null ? + null : AvatarUriUtil.getAvatarType(avatarUri); + ImageRequestDescriptor descriptor; + if (AvatarUriUtil.TYPE_GROUP_URI.equals(avatarType)) { + descriptor = new AvatarGroupRequestDescriptor(avatarUri, mIconSize, mIconSize); + } else { + descriptor = new AvatarRequestDescriptor(avatarUri, mIconSize, mIconSize); + } + + final MediaRequest imageRequest = + descriptor.buildSyncMediaRequest(mContext); + final ImageResource imageResource = + MediaResourceManager.get().requestMediaResourceSync(imageRequest); + if (imageResource != null) { + setAvatarResource(imageResource); + return mAvatarResource.getBitmap(); + } else { + releaseAvatarResource(); + return null; + } + } + + /** + * @return the "View more messages" view. When the user taps this item, they're + * taken to the conversation in Bugle. + */ + abstract protected RemoteViews getViewMoreItemsView(); + + @Override + public boolean hasStableIds() { + return true; + } + + @Override + public long getItemId(int position) { + return position; + } + + private void onLoadComplete() { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "onLoadComplete"); + } + final RemoteViews remoteViews = new RemoteViews(mContext.getPackageName(), + getMainLayoutId()); + mAppWidgetManager.partiallyUpdateAppWidget(mAppWidgetId, remoteViews); + } + + protected abstract int getMainLayoutId(); + + private void setAvatarResource(final ImageResource resource) { + if (mAvatarResource != resource) { + // Clear out any information for what is currently used + releaseAvatarResource(); + mAvatarResource = resource; + } + } + + private void releaseAvatarResource() { + if (mAvatarResource != null) { + mAvatarResource.release(); + } + mAvatarResource = null; + } +} diff --git a/src/com/android/messaging/widget/BaseWidgetProvider.java b/src/com/android/messaging/widget/BaseWidgetProvider.java new file mode 100644 index 0000000..431a6c7 --- /dev/null +++ b/src/com/android/messaging/widget/BaseWidgetProvider.java @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.widget; + +import android.appwidget.AppWidgetManager; +import android.appwidget.AppWidgetProvider; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import com.android.messaging.util.LogUtil; + +public abstract class BaseWidgetProvider extends AppWidgetProvider { + protected static final String TAG = LogUtil.BUGLE_WIDGET_TAG; + + public static final int WIDGET_CONVERSATION_REQUEST_CODE = 987; + + static final String WIDGET_SIZE_KEY = "widgetSizeKey"; + + public static final int SIZE_LARGE = 0; // undefined == 0, which is the default, large + public static final int SIZE_SMALL = 1; + public static final int SIZE_MEDIUM = 2; + public static final int SIZE_PRE_JB = 3; + + /** + * Update all widgets in the list + */ + @Override + public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { + super.onUpdate(context, appWidgetManager, appWidgetIds); + + for (int i = 0; i < appWidgetIds.length; ++i) { + updateWidget(context, appWidgetIds[i]); + } + } + + @Override + public void onReceive(Context context, Intent intent) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "onReceive intent: " + intent + " for " + this.getClass()); + } + final String action = intent.getAction(); + + // The base class AppWidgetProvider's onReceive handles the normal widget intents. Here + // we're looking for an intent sent by our app when it knows a message has + // been sent or received (or a conversation has been read) and is telling the widget it + // needs to update. + if (getAction().equals(action)) { + final AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context); + final int[] appWidgetIds = appWidgetManager.getAppWidgetIds(new ComponentName(context, + this.getClass())); + + if (appWidgetIds.length > 0) { + // We need to update all Bugle app widgets on the home screen. + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "onReceive notifyAppWidgetViewDataChanged listId: " + + getListId() + " first widgetId: " + appWidgetIds[0]); + } + appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, getListId()); + } + } else { + super.onReceive(context, intent); + } + } + + protected abstract String getAction(); + + protected abstract int getListId(); + + /** + * Update the widget appWidgetId + */ + protected abstract void updateWidget(Context context, int appWidgetId); + + private int getWidgetSize(AppWidgetManager appWidgetManager, + int appWidgetId) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "BaseWidgetProvider.getWidgetSize"); + } + + // Get the dimensions + final Bundle options = appWidgetManager.getAppWidgetOptions(appWidgetId); + + // Get min width and height. + final int minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH); + final int minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT); + + // First find out rows and columns based on width provided. + final int rows = getCellsForSize(minHeight); + final int columns = getCellsForSize(minWidth); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "BaseWidgetProvider.getWidgetSize row: " + rows + + " columns: " + columns); + } + + int size = SIZE_MEDIUM; + if (rows == 1) { + size = SIZE_SMALL; // Our widget doesn't let itself get this small. Perhaps in the + // future will add a super-mini widget. + } else if (columns > 3) { + size = SIZE_LARGE; + } + + // put the size in the bundle so our service know what size it's dealing with. + final int savedSize = options.getInt(WIDGET_SIZE_KEY); + if (savedSize != size) { + options.putInt(WIDGET_SIZE_KEY, size); + appWidgetManager.updateAppWidgetOptions(appWidgetId, options); + + // The size changed. We have to force the widget to rebuild the list. + appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, getListId()); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "BaseWidgetProvider.getWidgetSize old size: " + savedSize + + " new size saved: " + size); + } + } + + return size; + } + + /** + * Returns number of cells needed for given size of the widget. + * + * @param size Widget size in dp. + * @return Size in number of cells. + */ + private static int getCellsForSize(int size) { + // The hardwired sizes in this function come from the hardwired formula found in + // Android's UI guidelines for widget design: + // http://developer.android.com/guide/practices/ui_guidelines/widget_design.html + return (size + 30) / 70; + } + + @Override + public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, + int appWidgetId, Bundle newOptions) { + + final int widgetSize = getWidgetSize(appWidgetManager, appWidgetId); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "BaseWidgetProvider.onAppWidgetOptionsChanged new size: " + + widgetSize); + } + + super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions); + } + + protected void deletePreferences(final int widgetId) { + } + + /** + * Remove preferences when deleting widget + */ + @Override + public void onDeleted(Context context, int[] appWidgetIds) { + super.onDeleted(context, appWidgetIds); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "BaseWidgetProvider.onDeleted"); + } + + for (final int widgetId : appWidgetIds) { + deletePreferences(widgetId); + } + } + +} diff --git a/src/com/android/messaging/widget/BugleWidgetProvider.java b/src/com/android/messaging/widget/BugleWidgetProvider.java new file mode 100644 index 0000000..50c97b6 --- /dev/null +++ b/src/com/android/messaging/widget/BugleWidgetProvider.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.widget; + +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.widget.RemoteViews; + +import com.android.messaging.R; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.UiUtils; + +public class BugleWidgetProvider extends BaseWidgetProvider { + public static final String ACTION_NOTIFY_CONVERSATIONS_CHANGED = + "com.android.Bugle.intent.action.ACTION_NOTIFY_CONVERSATIONS_CHANGED"; + + public static final int WIDGET_NEW_CONVERSATION_REQUEST_CODE = 986; + + /** + * Update the widget appWidgetId + */ + @Override + protected void updateWidget(final Context context, final int appWidgetId) { + if (OsUtil.hasRequiredPermissions()) { + SafeAsyncTask.executeOnThreadPool(new Runnable() { + @Override + public void run() { + rebuildWidget(context, appWidgetId); + } + }); + } else { + AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, + UiUtils.getWidgetMissingPermissionView(context)); + } + } + + @Override + protected String getAction() { + return ACTION_NOTIFY_CONVERSATIONS_CHANGED; + } + + @Override + protected int getListId() { + return R.id.conversation_list; + } + + public static void rebuildWidget(final Context context, final int appWidgetId) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "BugleWidgetProvider.rebuildWidget appWidgetId: " + appWidgetId); + } + final RemoteViews remoteViews = new RemoteViews(context.getPackageName(), + R.layout.widget_conversation_list); + PendingIntent clickIntent; + + // Launch an intent to avoid ANRs + final Intent intent = new Intent(context, WidgetConversationListService.class); + intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); + intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME))); + remoteViews.setRemoteAdapter(appWidgetId, R.id.conversation_list, intent); + + remoteViews.setTextViewText(R.id.widget_label, context.getString(R.string.app_name)); + + // Open Bugle's app conversation list when click on header + clickIntent = UIIntents.get().getWidgetPendingIntentForConversationListActivity(context); + remoteViews.setOnClickPendingIntent(R.id.widget_header, clickIntent); + + // On click intent for Compose + clickIntent = UIIntents.get().getWidgetPendingIntentForConversationActivity(context, + null /*conversationId*/, WIDGET_NEW_CONVERSATION_REQUEST_CODE); + remoteViews.setOnClickPendingIntent(R.id.widget_compose, clickIntent); + + // On click intent for Conversation + // Note: the template intent has to be a "naked" intent without any extras. It turns out + // that if the template intent does have extras, those particular extras won't get + // replaced by the fill-in intent on each list item. + clickIntent = UIIntents.get().getWidgetPendingIntentForConversationActivity(context, + null /*conversationId*/, WIDGET_CONVERSATION_REQUEST_CODE); + remoteViews.setPendingIntentTemplate(R.id.conversation_list, clickIntent); + + AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, remoteViews); + } + + /* + * notifyDatasetChanged call when the conversation list changes so the Bugle widget will + * update and reflect the changes + */ + public static void notifyConversationListChanged(final Context context) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "notifyConversationListChanged"); + } + final Intent intent = new Intent(ACTION_NOTIFY_CONVERSATIONS_CHANGED); + context.sendBroadcast(intent); + } +} diff --git a/src/com/android/messaging/widget/WidgetConversationListService.java b/src/com/android/messaging/widget/WidgetConversationListService.java new file mode 100644 index 0000000..264b98c --- /dev/null +++ b/src/com/android/messaging/widget/WidgetConversationListService.java @@ -0,0 +1,281 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.widget; + +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Typeface; +import android.net.Uri; +import android.os.Bundle; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.TextPaint; +import android.text.TextUtils; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.view.View; +import android.widget.RemoteViews; +import android.widget.RemoteViewsService; + +import com.android.messaging.R; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.conversationlist.ConversationListItemView; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.Dates; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +public class WidgetConversationListService extends RemoteViewsService { + private static final String TAG = LogUtil.BUGLE_WIDGET_TAG; + + @Override + public RemoteViewsFactory onGetViewFactory(Intent intent) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "onGetViewFactory intent: " + intent); + } + return new WidgetConversationListFactory(getApplicationContext(), intent); + } + + /** + * Remote Views Factory for Bugle Widget. + */ + private static class WidgetConversationListFactory extends BaseWidgetFactory { + + public WidgetConversationListFactory(Context context, Intent intent) { + super(context, intent); + } + + @Override + protected Cursor doQuery() { + return mContext.getContentResolver().query(MessagingContentProvider.CONVERSATIONS_URI, + ConversationListItemData.PROJECTION, + ConversationListData.WHERE_NOT_ARCHIVED, + null, // selection args + ConversationListData.SORT_ORDER); + } + + /** + * @return the {@link RemoteViews} for a specific position in the list. + */ + @Override + public RemoteViews getViewAt(int position) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "getViewAt position: " + position); + } + synchronized (sWidgetLock) { + // "View more conversations" view. + if (mCursor == null + || (mShouldShowViewMore && position >= getItemCount())) { + return getViewMoreItemsView(); + } + + if (!mCursor.moveToPosition(position)) { + // If we ever fail to move to a position, return the "View More conversations" + // view. + LogUtil.w(TAG, "Failed to move to position: " + position); + return getViewMoreItemsView(); + } + + final ConversationListItemData conv = new ConversationListItemData(); + conv.bind(mCursor); + + // Inflate and fill out the remote view + final RemoteViews remoteViews = new RemoteViews( + mContext.getPackageName(), R.layout.widget_conversation_list_item); + + final boolean hasUnreadMessages = !conv.getIsRead(); + final Resources resources = mContext.getResources(); + final boolean isDefaultSmsApp = PhoneUtils.getDefault().isDefaultSmsApp(); + + final String timeStamp = conv.getIsSendRequested() ? + resources.getString(R.string.message_status_sending) : + Dates.getWidgetTimeString(conv.getTimestamp(), true /*abbreviated*/) + .toString(); + // Date/Timestamp or Sending or Error state -- all shown in the date item + remoteViews.setTextViewText(R.id.date, + boldifyIfUnread(timeStamp, hasUnreadMessages)); + + // From + remoteViews.setTextViewText(R.id.from, + boldifyIfUnread(conv.getName(), hasUnreadMessages)); + + // Notifications turned off mini-bell icon + remoteViews.setViewVisibility(R.id.conversation_notification_bell, + conv.getNotificationEnabled() ? View.GONE : View.VISIBLE); + + // On click intent. + final Intent intent = UIIntents.get().getIntentForConversationActivity(mContext, + conv.getConversationId(), null /* draft */); + + remoteViews.setOnClickFillInIntent(R.id.widget_conversation_list_item, intent); + + // Avatar + boolean includeAvatar; + if (OsUtil.isAtLeastJB()) { + final Bundle options = mAppWidgetManager.getAppWidgetOptions(mAppWidgetId); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "getViewAt BugleWidgetProvider.WIDGET_SIZE_KEY: " + + options.getInt(BugleWidgetProvider.WIDGET_SIZE_KEY)); + } + + includeAvatar = options.getInt(BugleWidgetProvider.WIDGET_SIZE_KEY) == + BugleWidgetProvider.SIZE_LARGE; + } else { + includeAvatar = true;; + } + + // Show the avatar when grande size, otherwise hide it. + remoteViews.setViewVisibility(R.id.avatarView, includeAvatar ? + View.VISIBLE : View.GONE); + + Uri iconUri = null; + if (conv.getIcon() != null) { + iconUri = Uri.parse(conv.getIcon()); + } + remoteViews.setImageViewBitmap(R.id.avatarView, includeAvatar ? + getAvatarBitmap(iconUri) : null); + + // Error + // Only show the fail icon if it is not a group conversation. + // And also require that we be the default sms app. + final boolean showError = conv.getIsFailedStatus() && + isDefaultSmsApp; + final boolean showDraft = conv.getShowDraft() && + isDefaultSmsApp; + remoteViews.setViewVisibility(R.id.conversation_failed_status_icon, + showError && includeAvatar ? + View.VISIBLE : View.GONE); + + if (showError || showDraft) { + remoteViews.setViewVisibility(R.id.snippet, View.GONE); + remoteViews.setViewVisibility(R.id.errorBlock, View.VISIBLE); + remoteViews.setTextViewText(R.id.errorSnippet, getSnippetText(conv)); + + if (showDraft) { + // Show italicized "Draft" on third line + final String text = resources.getString( + R.string.conversation_list_item_view_draft_message); + SpannableStringBuilder builder = new SpannableStringBuilder(text); + builder.setSpan(new StyleSpan(Typeface.ITALIC), 0, text.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.setSpan(new ForegroundColorSpan( + resources.getColor(R.color.widget_text_color)), + 0, text.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + remoteViews.setTextViewText(R.id.errorText, builder); + } else { + // Show error message on third line + int failureMessageId = R.string.message_status_download_failed; + if (conv.getIsMessageTypeOutgoing()) { + failureMessageId = MmsUtils.mapRawStatusToErrorResourceId( + conv.getMessageStatus(), + conv.getMessageRawTelephonyStatus()); + } + remoteViews.setTextViewText(R.id.errorText, + resources.getString(failureMessageId)); + } + } else { + remoteViews.setViewVisibility(R.id.errorBlock, View.GONE); + remoteViews.setViewVisibility(R.id.snippet, View.VISIBLE); + remoteViews.setTextViewText(R.id.snippet, + boldifyIfUnread(getSnippetText(conv), hasUnreadMessages)); + } + + // Set the accessibility TalkBack text + remoteViews.setContentDescription(R.id.widget_conversation_list_item, + ConversationListItemView.buildContentDescription(mContext.getResources(), + conv, new TextPaint())); + + return remoteViews; + } + } + + private String getSnippetText(final ConversationListItemData conv) { + String snippetText = conv.getShowDraft() ? + conv.getDraftSnippetText() : conv.getSnippetText(); + final String previewContentType = conv.getShowDraft() ? + conv.getDraftPreviewContentType() : conv.getPreviewContentType(); + if (TextUtils.isEmpty(snippetText)) { + Resources resources = mContext.getResources(); + // Use the attachment type as a snippet so the preview doesn't look odd + if (ContentType.isAudioType(previewContentType)) { + snippetText = resources.getString( + R.string.conversation_list_snippet_audio_clip); + } else if (ContentType.isImageType(previewContentType)) { + snippetText = resources.getString(R.string.conversation_list_snippet_picture); + } else if (ContentType.isVideoType(previewContentType)) { + snippetText = resources.getString(R.string.conversation_list_snippet_video); + } else if (ContentType.isVCardType(previewContentType)) { + snippetText = resources.getString(R.string.conversation_list_snippet_vcard); + } + } + return snippetText; + } + + /** + * @return the "View more conversations" view. When the user taps this item, they're + * taken to the Bugle's conversation list. + */ + @Override + protected RemoteViews getViewMoreItemsView() { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "getViewMoreItemsView"); + } + final RemoteViews view = new RemoteViews(mContext.getPackageName(), + R.layout.widget_loading); + view.setTextViewText( + R.id.loading_text, mContext.getText(R.string.view_more_conversations)); + + // Tapping this "More conversations" item should take us to the ConversationList. + // However, the list view is primed with an intent to go to the Conversation activity. + // Each normal conversation list item sets the fill-in intent with the + // ConversationId for that particular conversation. In other words, the only place + // we can go is the ConversationActivity. We add an extra here to tell the + // ConversationActivity to really take us to the ConversationListActivity. + final Intent intent = new Intent(); + intent.putExtra(UIIntents.UI_INTENT_EXTRA_GOTO_CONVERSATION_LIST, true); + view.setOnClickFillInIntent(R.id.widget_loading, intent); + return view; + } + + @Override + public RemoteViews getLoadingView() { + RemoteViews view = new RemoteViews(mContext.getPackageName(), R.layout.widget_loading); + view.setTextViewText( + R.id.loading_text, mContext.getText(R.string.loading_conversations)); + return view; + } + + @Override + public int getViewTypeCount() { + return 2; + } + + @Override + protected int getMainLayoutId() { + return R.layout.widget_conversation_list; + } + } + +} diff --git a/src/com/android/messaging/widget/WidgetConversationProvider.java b/src/com/android/messaging/widget/WidgetConversationProvider.java new file mode 100644 index 0000000..6ae5614 --- /dev/null +++ b/src/com/android/messaging/widget/WidgetConversationProvider.java @@ -0,0 +1,316 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.widget; + +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Looper; +import android.text.TextUtils; +import android.view.View; +import android.widget.RemoteViews; + +import com.android.messaging.R; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.WidgetPickConversationActivity; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.UiUtils; + +public class WidgetConversationProvider extends BaseWidgetProvider { + public static final String ACTION_NOTIFY_MESSAGES_CHANGED = + "com.android.Bugle.intent.action.ACTION_NOTIFY_MESSAGES_CHANGED"; + + public static final int WIDGET_CONVERSATION_TEMPLATE_REQUEST_CODE = 1985; + public static final int WIDGET_CONVERSATION_REPLY_CODE = 1987; + + // Intent extras + public static final String UI_INTENT_EXTRA_RECIPIENT = "recipient"; + public static final String UI_INTENT_EXTRA_ICON = "icon"; + + /** + * Update the widget appWidgetId + */ + @Override + protected void updateWidget(final Context context, final int appWidgetId) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "updateWidget appWidgetId: " + appWidgetId); + } + if (OsUtil.hasRequiredPermissions()) { + rebuildWidget(context, appWidgetId); + } else { + AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, + UiUtils.getWidgetMissingPermissionView(context)); + } + } + + @Override + protected String getAction() { + return ACTION_NOTIFY_MESSAGES_CHANGED; + } + + @Override + protected int getListId() { + return R.id.message_list; + } + + public static void rebuildWidget(final Context context, final int appWidgetId) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "WidgetConversationProvider.rebuildWidget appWidgetId: " + appWidgetId); + } + final RemoteViews remoteViews = new RemoteViews(context.getPackageName(), + R.layout.widget_conversation); + PendingIntent clickIntent; + final UIIntents uiIntents = UIIntents.get(); + if (!isWidgetConfigured(appWidgetId)) { + // Widget has not been configured yet. Hide the normal UI elements and show the + // configuration view instead. + remoteViews.setViewVisibility(R.id.widget_label, View.GONE); + remoteViews.setViewVisibility(R.id.message_list, View.GONE); + remoteViews.setViewVisibility(R.id.launcher_icon, View.VISIBLE); + remoteViews.setViewVisibility(R.id.widget_configuration, View.VISIBLE); + + remoteViews.setOnClickPendingIntent(R.id.widget_configuration, + uiIntents.getWidgetPendingIntentForConfigurationActivity(context, appWidgetId)); + + // On click intent for Goto Conversation List + clickIntent = uiIntents.getWidgetPendingIntentForConversationListActivity(context); + remoteViews.setOnClickPendingIntent(R.id.widget_header, clickIntent); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "WidgetConversationProvider.rebuildWidget appWidgetId: " + + appWidgetId + " going into configure state"); + } + } else { + remoteViews.setViewVisibility(R.id.widget_label, View.VISIBLE); + remoteViews.setViewVisibility(R.id.message_list, View.VISIBLE); + remoteViews.setViewVisibility(R.id.launcher_icon, View.GONE); + remoteViews.setViewVisibility(R.id.widget_configuration, View.GONE); + + final String conversationId = + WidgetPickConversationActivity.getConversationIdPref(appWidgetId); + final boolean isMainThread = Looper.myLooper() == Looper.getMainLooper(); + // If we're running on the UI thread, we can't do the DB access needed to get the + // conversation data. We'll do excute this again off of the UI thread. + final ConversationListItemData convData = isMainThread ? + null : getConversationData(context, conversationId); + + // Launch an intent to avoid ANRs + final Intent intent = new Intent(context, WidgetConversationService.class); + intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); + intent.putExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID, conversationId); + intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME))); + remoteViews.setRemoteAdapter(appWidgetId, R.id.message_list, intent); + + remoteViews.setTextViewText(R.id.widget_label, convData != null ? + convData.getName() : context.getString(R.string.app_name)); + + // On click intent for Goto Conversation List + clickIntent = uiIntents.getWidgetPendingIntentForConversationListActivity(context); + remoteViews.setOnClickPendingIntent(R.id.widget_goto_conversation_list, clickIntent); + + // Open the conversation when click on header + clickIntent = uiIntents.getWidgetPendingIntentForConversationActivity(context, + conversationId, WIDGET_CONVERSATION_REQUEST_CODE); + remoteViews.setOnClickPendingIntent(R.id.widget_header, clickIntent); + + // On click intent for Conversation + // Note: the template intent has to be a "naked" intent without any extras. It turns out + // that if the template intent does have extras, those particular extras won't get + // replaced by the fill-in intent on each list item. + clickIntent = uiIntents.getWidgetPendingIntentForConversationActivity(context, + conversationId, WIDGET_CONVERSATION_TEMPLATE_REQUEST_CODE); + remoteViews.setPendingIntentTemplate(R.id.message_list, clickIntent); + + if (isMainThread) { + // We're running on the UI thread and we couldn't update all the parts of the + // widget dependent on ConversationListItemData. However, we have to update + // the widget regardless, even with those missing pieces. Here we update the + // widget again in the background. + SafeAsyncTask.executeOnThreadPool(new Runnable() { + @Override + public void run() { + rebuildWidget(context, appWidgetId); + } + }); + } + } + + AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, remoteViews); + + } + + /* + * notifyMessagesChanged called when the conversation changes so the widget will + * update and reflect the changes + */ + public static void notifyMessagesChanged(final Context context, final String conversationId) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "notifyMessagesChanged"); + } + final Intent intent = new Intent(ACTION_NOTIFY_MESSAGES_CHANGED); + intent.putExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID, conversationId); + context.sendBroadcast(intent); + } + + /* + * notifyConversationDeleted is called when a conversation is deleted. Look through all the + * widgets and if they're displaying that conversation, force the widget into its + * configuration state. + */ + public static void notifyConversationDeleted(final Context context, + final String conversationId) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "notifyConversationDeleted convId: " + conversationId); + } + + final AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context); + for (final int appWidgetId : appWidgetManager.getAppWidgetIds(new ComponentName(context, + WidgetConversationProvider.class))) { + // Retrieve the persisted information for this widget from preferences. + final String widgetConvId = + WidgetPickConversationActivity.getConversationIdPref(appWidgetId); + + if (widgetConvId == null || widgetConvId.equals(conversationId)) { + if (widgetConvId != null) { + WidgetPickConversationActivity.deleteConversationIdPref(appWidgetId); + } + rebuildWidget(context, appWidgetId); + } + } + } + + /* + * notifyConversationRenamed is called when a conversation is renamed. Look through all the + * widgets and if they're displaying that conversation, force the widget to rebuild itself + */ + public static void notifyConversationRenamed(final Context context, + final String conversationId) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "notifyConversationRenamed convId: " + conversationId); + } + + final AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context); + for (final int appWidgetId : appWidgetManager.getAppWidgetIds(new ComponentName(context, + WidgetConversationProvider.class))) { + // Retrieve the persisted information for this widget from preferences. + final String widgetConvId = + WidgetPickConversationActivity.getConversationIdPref(appWidgetId); + + if (widgetConvId != null && widgetConvId.equals(conversationId)) { + rebuildWidget(context, appWidgetId); + } + } + } + + @Override + public void onReceive(final Context context, final Intent intent) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "WidgetConversationProvider onReceive intent: " + intent); + } + final String action = intent.getAction(); + + // The base class AppWidgetProvider's onReceive handles the normal widget intents. Here + // we're looking for an intent sent by our app when it knows a message has + // been sent or received (or a conversation has been read) and is telling the widget it + // needs to update. + if (getAction().equals(action)) { + final AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context); + final int[] appWidgetIds = appWidgetManager.getAppWidgetIds(new ComponentName(context, + this.getClass())); + + if (appWidgetIds.length == 0) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "WidgetConversationProvider onReceive no widget ids"); + } + return; + } + // Normally the conversation id points to a specific conversation and we only update + // widgets looking at that conversation. When the conversation id is null, that means + // there's been a massive change (such as the initial import) and we need to update + // every conversation widget. + final String conversationId = intent.getExtras() + .getString(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); + + // Only update the widgets that match the conversation id that changed. + for (final int widgetId : appWidgetIds) { + // Retrieve the persisted information for this widget from preferences. + final String widgetConvId = + WidgetPickConversationActivity.getConversationIdPref(widgetId); + if (conversationId == null || TextUtils.equals(conversationId, widgetConvId)) { + // Update the list portion (i.e. the message list) of the widget + appWidgetManager.notifyAppWidgetViewDataChanged(widgetId, getListId()); + } + } + } else { + super.onReceive(context, intent); + } + } + + private static ConversationListItemData getConversationData(final Context context, + final String conversationId) { + if (TextUtils.isEmpty(conversationId)) { + return null; + } + final Uri uri = MessagingContentProvider.buildConversationMetadataUri(conversationId); + Cursor cursor = null; + try { + cursor = context.getContentResolver().query(uri, + ConversationListItemData.PROJECTION, + null, // selection + null, // selection args + null); // sort order + if (cursor != null && cursor.getCount() > 0) { + final ConversationListItemData conv = new ConversationListItemData(); + cursor.moveToFirst(); + conv.bind(cursor); + return conv; + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + return null; + } + + @Override + protected void deletePreferences(final int widgetId) { + WidgetPickConversationActivity.deleteConversationIdPref(widgetId); + } + + /** + * When this widget is created, it's created for a particular conversation and that + * ConversationId is stored in shared prefs. If the associated conversation is deleted, + * the widget doesn't get deleted. Instead, it goes into a "tap to configure" state. This + * function determines whether the widget has been configured and has an associated + * ConversationId. + */ + public static boolean isWidgetConfigured(final int appWidgetId) { + final String conversationId = + WidgetPickConversationActivity.getConversationIdPref(appWidgetId); + return !TextUtils.isEmpty(conversationId); + } + +} diff --git a/src/com/android/messaging/widget/WidgetConversationService.java b/src/com/android/messaging/widget/WidgetConversationService.java new file mode 100644 index 0000000..4fd3934 --- /dev/null +++ b/src/com/android/messaging/widget/WidgetConversationService.java @@ -0,0 +1,521 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.widget; + +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Bundle; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.format.DateUtils; +import android.text.format.Formatter; +import android.text.style.ForegroundColorSpan; +import android.view.View; +import android.widget.RemoteViews; +import android.widget.RemoteViewsService; + +import com.android.messaging.R; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.ConversationMessageData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.media.ImageResource; +import com.android.messaging.datamodel.media.MediaRequest; +import com.android.messaging.datamodel.media.MediaResourceManager; +import com.android.messaging.datamodel.media.MessagePartImageRequestDescriptor; +import com.android.messaging.datamodel.media.MessagePartVideoThumbnailRequestDescriptor; +import com.android.messaging.datamodel.media.UriImageRequestDescriptor; +import com.android.messaging.datamodel.media.VideoThumbnailRequest; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.Dates; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +import java.util.List; + +public class WidgetConversationService extends RemoteViewsService { + private static final String TAG = LogUtil.BUGLE_WIDGET_TAG; + + private static final int IMAGE_ATTACHMENT_SIZE = 400; + + @Override + public RemoteViewsFactory onGetViewFactory(Intent intent) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "onGetViewFactory intent: " + intent); + } + return new WidgetConversationFactory(getApplicationContext(), intent); + } + + /** + * Remote Views Factory for the conversation widget. + */ + private static class WidgetConversationFactory extends BaseWidgetFactory { + private ImageResource mImageResource; + private String mConversationId; + + public WidgetConversationFactory(Context context, Intent intent) { + super(context, intent); + + mConversationId = intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "BugleFactory intent: " + intent + "widget id: " + mAppWidgetId); + } + mIconSize = (int) context.getResources() + .getDimension(R.dimen.contact_icon_view_normal_size); + } + + @Override + public void onCreate() { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "onCreate"); + } + super.onCreate(); + + // If the conversation for this widget has been removed, we want to update the widget to + // "Tap to configure" mode. + if (!WidgetConversationProvider.isWidgetConfigured(mAppWidgetId)) { + WidgetConversationProvider.rebuildWidget(mContext, mAppWidgetId); + } + } + + @Override + protected Cursor doQuery() { + if (TextUtils.isEmpty(mConversationId)) { + LogUtil.w(TAG, "doQuery no conversation id"); + return null; + } + final Uri uri = MessagingContentProvider.buildConversationMessagesUri(mConversationId); + if (uri != null) { + LogUtil.w(TAG, "doQuery uri: " + uri.toString()); + } + return mContext.getContentResolver().query(uri, + ConversationMessageData.getProjection(), + null, // where + null, // selection args + null // sort order + ); + } + + /** + * @return the {@link RemoteViews} for a specific position in the list. + */ + @Override + public RemoteViews getViewAt(final int originalPosition) { + synchronized (sWidgetLock) { + // "View more messages" view. + if (mCursor == null + || (mShouldShowViewMore && originalPosition == 0)) { + return getViewMoreItemsView(); + } + // The message cursor is in reverse order for performance reasons. + final int position = getCount() - originalPosition - 1; + if (!mCursor.moveToPosition(position)) { + // If we ever fail to move to a position, return the "View More messages" + // view. + LogUtil.w(TAG, "Failed to move to position: " + position); + return getViewMoreItemsView(); + } + + final ConversationMessageData message = new ConversationMessageData(); + message.bind(mCursor); + + // Inflate and fill out the remote view + final RemoteViews remoteViews = new RemoteViews( + mContext.getPackageName(), message.getIsIncoming() ? + R.layout.widget_message_item_incoming : + R.layout.widget_message_item_outgoing); + + final boolean hasUnreadMessages = false; //!message.getIsRead(); + + // Date + remoteViews.setTextViewText(R.id.date, boldifyIfUnread( + Dates.getWidgetTimeString(message.getReceivedTimeStamp(), + false /*abbreviated*/), + hasUnreadMessages)); + + // On click intent. + final Intent intent = UIIntents.get().getIntentForConversationActivity(mContext, + mConversationId, null /* draft */); + + // Attachments + int attachmentStringId = 0; + remoteViews.setViewVisibility(R.id.attachmentFrame, View.GONE); + + int scrollToPosition = originalPosition; + final int cursorCount = mCursor.getCount(); + if (cursorCount > MAX_ITEMS_TO_SHOW) { + scrollToPosition += cursorCount - MAX_ITEMS_TO_SHOW; + } + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "getViewAt position: " + originalPosition + + " computed position: " + position + + " scrollToPosition: " + scrollToPosition + + " cursorCount: " + cursorCount + + " MAX_ITEMS_TO_SHOW: " + MAX_ITEMS_TO_SHOW); + } + + intent.putExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, scrollToPosition); + if (message.hasAttachments()) { + final List attachments = message.getAttachments(); + for (MessagePartData part : attachments) { + final boolean videoWithThumbnail = part.isVideo() + && (VideoThumbnailRequest.shouldShowIncomingVideoThumbnails() + || !message.getIsIncoming()); + if (part.isImage() || videoWithThumbnail) { + final Uri uri = part.getContentUri(); + remoteViews.setViewVisibility(R.id.attachmentFrame, View.VISIBLE); + remoteViews.setViewVisibility(R.id.playButton, part.isVideo() ? + View.VISIBLE : View.GONE); + remoteViews.setImageViewBitmap(R.id.attachment, + getAttachmentBitmap(part)); + intent.putExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_URI , + uri.toString()); + intent.putExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_TYPE , + part.getContentType()); + break; + } else if (part.isVideo()) { + attachmentStringId = R.string.conversation_list_snippet_video; + break; + } + if (part.isAudio()) { + attachmentStringId = R.string.conversation_list_snippet_audio_clip; + break; + } + if (part.isVCard()) { + attachmentStringId = R.string.conversation_list_snippet_vcard; + break; + } + } + } + + remoteViews.setOnClickFillInIntent(message.getIsIncoming() ? + R.id.widget_message_item_incoming : + R.id.widget_message_item_outgoing, + intent); + + // Avatar + boolean includeAvatar; + if (OsUtil.isAtLeastJB()) { + final Bundle options = mAppWidgetManager.getAppWidgetOptions(mAppWidgetId); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "getViewAt BugleWidgetProvider.WIDGET_SIZE_KEY: " + + options.getInt(BugleWidgetProvider.WIDGET_SIZE_KEY)); + } + + includeAvatar = options.getInt(BugleWidgetProvider.WIDGET_SIZE_KEY) + == BugleWidgetProvider.SIZE_LARGE; + } else { + includeAvatar = true; + } + + // Show the avatar (and shadow) when grande size, otherwise hide it. + remoteViews.setViewVisibility(R.id.avatarView, includeAvatar ? + View.VISIBLE : View.GONE); + remoteViews.setViewVisibility(R.id.avatarShadow, includeAvatar ? + View.VISIBLE : View.GONE); + + final Uri avatarUri = AvatarUriUtil.createAvatarUri( + message.getSenderProfilePhotoUri(), + message.getSenderFullName(), + message.getSenderNormalizedDestination(), + message.getSenderContactLookupKey()); + + remoteViews.setImageViewBitmap(R.id.avatarView, includeAvatar ? + getAvatarBitmap(avatarUri) : null); + + String text = message.getText(); + if (attachmentStringId != 0) { + final String attachment = mContext.getString(attachmentStringId); + if (!TextUtils.isEmpty(text)) { + text += '\n' + attachment; + } else { + text = attachment; + } + } + + remoteViews.setViewVisibility(R.id.message, View.VISIBLE); + updateViewContent(text, message, remoteViews); + + return remoteViews; + } + } + + // updateViewContent figures out what to show in the message and date fields based on + // the message status. This code came from ConversationMessageView.updateViewContent, but + // had to be simplified to work with our simple widget list item. + // updateViewContent also builds the accessibility content description for the list item. + private void updateViewContent(final String messageText, + final ConversationMessageData message, + final RemoteViews remoteViews) { + int titleResId = -1; + int statusResId = -1; + boolean showInRed = false; + String statusText = null; + switch(message.getStatus()) { + case MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING: + case MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING: + case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD: + case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD: + titleResId = R.string.message_title_downloading; + statusResId = R.string.message_status_downloading; + break; + + case MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD: + if (!OsUtil.isSecondaryUser()) { + titleResId = R.string.message_title_manual_download; + statusResId = R.string.message_status_download; + } + break; + + case MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE: + if (!OsUtil.isSecondaryUser()) { + titleResId = R.string.message_title_download_failed; + statusResId = R.string.message_status_download_error; + showInRed = true; + } + break; + + case MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED: + if (!OsUtil.isSecondaryUser()) { + titleResId = R.string.message_title_download_failed; + statusResId = R.string.message_status_download; + showInRed = true; + } + break; + + case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND: + case MessageData.BUGLE_STATUS_OUTGOING_SENDING: + statusResId = R.string.message_status_sending; + break; + + case MessageData.BUGLE_STATUS_OUTGOING_RESENDING: + case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY: + statusResId = R.string.message_status_send_retrying; + break; + + case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER: + statusResId = R.string.message_status_send_failed_emergency_number; + showInRed = true; + break; + + case MessageData.BUGLE_STATUS_OUTGOING_FAILED: + // don't show the error state unless we're the default sms app + if (PhoneUtils.getDefault().isDefaultSmsApp()) { + statusResId = MmsUtils.mapRawStatusToErrorResourceId( + message.getStatus(), message.getRawTelephonyStatus()); + showInRed = true; + break; + } + // FALL THROUGH HERE + + case MessageData.BUGLE_STATUS_OUTGOING_COMPLETE: + case MessageData.BUGLE_STATUS_INCOMING_COMPLETE: + default: + if (!message.getCanClusterWithNextMessage()) { + statusText = Dates.getWidgetTimeString(message.getReceivedTimeStamp(), + false /*abbreviated*/).toString(); + } + break; + } + + // Build the content description while we're populating the various fields. + final StringBuilder description = new StringBuilder(); + final String separator = mContext.getString(R.string.enumeration_comma); + // Sender information + final boolean hasPlainTextMessage = !(TextUtils.isEmpty(message.getText())); + if (message.getIsIncoming()) { + int senderResId = hasPlainTextMessage + ? R.string.incoming_text_sender_content_description + : R.string.incoming_sender_content_description; + description.append(mContext.getString(senderResId, message.getSenderDisplayName())); + } else { + int senderResId = hasPlainTextMessage + ? R.string.outgoing_text_sender_content_description + : R.string.outgoing_sender_content_description; + description.append(mContext.getString(senderResId)); + } + + final boolean titleVisible = (titleResId >= 0); + if (titleVisible) { + final String titleText = mContext.getString(titleResId); + remoteViews.setTextViewText(R.id.message, titleText); + + final String mmsInfoText = mContext.getString( + R.string.mms_info, + Formatter.formatFileSize(mContext, message.getSmsMessageSize()), + DateUtils.formatDateTime( + mContext, + message.getMmsExpiry(), + DateUtils.FORMAT_SHOW_DATE | + DateUtils.FORMAT_SHOW_TIME | + DateUtils.FORMAT_NUMERIC_DATE | + DateUtils.FORMAT_NO_YEAR)); + remoteViews.setTextViewText(R.id.date, mmsInfoText); + description.append(separator); + description.append(mmsInfoText); + } else if (!TextUtils.isEmpty(messageText)) { + remoteViews.setTextViewText(R.id.message, messageText); + description.append(separator); + description.append(messageText); + } else { + remoteViews.setViewVisibility(R.id.message, View.GONE); + } + + final String subjectText = MmsUtils.cleanseMmsSubject(mContext.getResources(), + message.getMmsSubject()); + if (!TextUtils.isEmpty(subjectText)) { + description.append(separator); + description.append(subjectText); + } + + if (statusResId >= 0) { + statusText = mContext.getString(statusResId); + final Spannable colorStr = new SpannableString(statusText); + if (showInRed) { + colorStr.setSpan(new ForegroundColorSpan( + mContext.getResources().getColor(R.color.timestamp_text_failed)), + 0, statusText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + remoteViews.setTextViewText(R.id.date, colorStr); + description.append(separator); + description.append(colorStr); + } else { + description.append(separator); + description.append(Dates.getWidgetTimeString(message.getReceivedTimeStamp(), + false /*abbreviated*/)); + } + + if (message.hasAttachments()) { + final List attachments = message.getAttachments(); + int stringId; + for (MessagePartData part : attachments) { + if (part.isImage()) { + stringId = R.string.conversation_list_snippet_picture; + } else if (part.isVideo()) { + stringId = R.string.conversation_list_snippet_video; + } else if (part.isAudio()) { + stringId = R.string.conversation_list_snippet_audio_clip; + } else if (part.isVCard()) { + stringId = R.string.conversation_list_snippet_vcard; + } else { + stringId = 0; + } + if (stringId > 0) { + description.append(separator); + description.append(mContext.getString(stringId)); + } + } + } + remoteViews.setContentDescription(message.getIsIncoming() ? + R.id.widget_message_item_incoming : + R.id.widget_message_item_outgoing, description); + } + + private Bitmap getAttachmentBitmap(final MessagePartData part) { + UriImageRequestDescriptor descriptor; + if (part.isImage()) { + descriptor = new MessagePartImageRequestDescriptor(part, + IMAGE_ATTACHMENT_SIZE, // desiredWidth + IMAGE_ATTACHMENT_SIZE, // desiredHeight + true // isStatic + ); + } else if (part.isVideo()) { + descriptor = new MessagePartVideoThumbnailRequestDescriptor(part); + } else { + return null; + } + + final MediaRequest imageRequest = + descriptor.buildSyncMediaRequest(mContext); + final ImageResource imageResource = + MediaResourceManager.get().requestMediaResourceSync(imageRequest); + if (imageResource != null && imageResource.getBitmap() != null) { + setImageResource(imageResource); + return Bitmap.createBitmap(imageResource.getBitmap()); + } else { + releaseImageResource(); + return null; + } + } + + /** + * @return the "View more messages" view. When the user taps this item, they're + * taken to the conversation in Bugle. + */ + @Override + protected RemoteViews getViewMoreItemsView() { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "getViewMoreConversationsView"); + } + final RemoteViews view = new RemoteViews(mContext.getPackageName(), + R.layout.widget_loading); + view.setTextViewText( + R.id.loading_text, mContext.getText(R.string.view_more_messages)); + + // Tapping this "More messages" item should take us to the conversation. + final Intent intent = UIIntents.get().getIntentForConversationActivity(mContext, + mConversationId, null /* draft */); + view.setOnClickFillInIntent(R.id.widget_loading, intent); + return view; + } + + @Override + public RemoteViews getLoadingView() { + final RemoteViews view = new RemoteViews(mContext.getPackageName(), + R.layout.widget_loading); + view.setTextViewText( + R.id.loading_text, mContext.getText(R.string.loading_messages)); + return view; + } + + @Override + public int getViewTypeCount() { + return 3; // Number of different list items that can be returned - + // 1- incoming list item + // 2- outgoing list item + // 3- more items list item + } + + @Override + protected int getMainLayoutId() { + return R.layout.widget_conversation; + } + + private void setImageResource(final ImageResource resource) { + if (mImageResource != resource) { + // Clear out any information for what is currently used + releaseImageResource(); + mImageResource = resource; + } + } + + private void releaseImageResource() { + if (mImageResource != null) { + mImageResource.release(); + } + mImageResource = null; + } + } + +} diff --git a/tests/Android.mk b/tests/Android.mk new file mode 100644 index 0000000..f3f4752 --- /dev/null +++ b/tests/Android.mk @@ -0,0 +1,39 @@ +# Copyright (C) 2015 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +LOCAL_PATH:= $(call my-dir) +include $(CLEAR_VARS) + +LOCAL_MODULE_TAGS := tests + +# Only compile source java files in this apk. +LOCAL_SRC_FILES := $(call all-java-files-under, src) + +LOCAL_PACKAGE_NAME := messagingtests + +LOCAL_INSTRUMENTATION_FOR := messaging + +LOCAL_JACK_ENABLED := disabled + +# Matching ../Android.mk +LOCAL_SDK_VERSION := current + +LOCAL_CERTIFICATE := platform + +LOCAL_STATIC_JAVA_LIBRARIES := \ + mockito-target + +include $(BUILD_PACKAGE) + +include $(call all-makefiles-under,$(LOCAL_PATH)) diff --git a/tests/AndroidManifest.xml b/tests/AndroidManifest.xml new file mode 100644 index 0000000..867d8e1 --- /dev/null +++ b/tests/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/tests/src/com/android/messaging/BugleTestCase.java b/tests/src/com/android/messaging/BugleTestCase.java new file mode 100644 index 0000000..64c6c86 --- /dev/null +++ b/tests/src/com/android/messaging/BugleTestCase.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging; + +import android.content.Context; +import android.test.AndroidTestCase; + +/* + * Base class for service tests that takes care of housekeeping that is common amongst basic test + * cases. + */ +public abstract class BugleTestCase extends AndroidTestCase { + + static { + // Set flag during loading of test cases to prevent application initialization starting + setTestsRunning(); + } + + public static void setTestsRunning() { + BugleApplication.setTestsRunning(); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + TestUtil.testSetup(super.getContext(), this); + } + + @Override + protected void tearDown() throws Exception { + super.tearDown(); + TestUtil.testTeardown(this); + } + + @Override + public Context getContext() { + // This doesn't really get the "application context" - just the fake context + // that the factory has been initialized with for each test case. + return Factory.get().getApplicationContext(); + } + + public Context getTestContext() { + return super.getContext(); + } +} \ No newline at end of file diff --git a/tests/src/com/android/messaging/FakeContentProvider.java b/tests/src/com/android/messaging/FakeContentProvider.java new file mode 100644 index 0000000..53c29ba --- /dev/null +++ b/tests/src/com/android/messaging/FakeContentProvider.java @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging; + +import android.content.ContentProvider; +import android.content.ContentProviderClient; +import android.content.ContentValues; +import android.content.Context; +import android.content.pm.ProviderInfo; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.RemoteException; +import android.support.v4.util.SimpleArrayMap; +import android.text.TextUtils; + +import com.android.messaging.datamodel.FakeCursor; +import com.android.messaging.util.LogUtil; + +import java.util.ArrayList; + +public class FakeContentProvider extends ContentProvider { + + private static class ContentOverride { + private final String uri; + private final String where; + private final String args; + private final String[] columns; + private final Object[][] data; + + ContentOverride(final String uri, final String where, final String args, + final String[] columns, final Object[][] data) { + this.uri = uri; + this.where = where; + this.args = args; + this.columns = columns; + this.data = data; + } + + boolean match(final String uri, final String where, final String[] args) { + if (!this.uri.equals(uri) || !TextUtils.equals(this.where, where)) { + return false; + } + + if (this.args == null || args == null) { + return this.args == null && args == null; + } + + return this.args.equals(TextUtils.join(";", args)); + } + } + + private final Context mGlobalContext; + private final ArrayList mOverrides = new ArrayList(); + private final SimpleArrayMap mTypes = new SimpleArrayMap(); + private final ContentProviderClient mProvider; + private final Uri mUri; + + public FakeContentProvider(final Context context, final Uri uri, final boolean canDelegate) { + mGlobalContext = context; + mUri = uri; + if (canDelegate) { + mProvider = mGlobalContext.getContentResolver().acquireContentProviderClient(mUri); + } else { + mProvider = null; + } + + ProviderInfo providerInfo = new ProviderInfo(); + providerInfo.authority = uri.getAuthority(); + + this.attachInfo(mGlobalContext, providerInfo); + } + + public void addOverrideData(final Uri uri, final String where, final String args, + final String[] columns, final Object[][] data) { + mOverrides.add(new ContentOverride(uri.toString(), where, args, columns, data)); + } + + public void addOverrideType(final Uri uri, final String type) { + mTypes.put(uri.toString(), type); + } + + @Override + public boolean onCreate() { + return false; + } + + @Override + public void shutdown() { + if (mProvider != null) { + mProvider.release(); + } + } + + @Override + public Cursor query(final Uri uri, final String[] projection, final String selection, + final String[] selectionArgs, final String sortOrder) { + LogUtil.w(LogUtil.BUGLE_TAG, "FakeContentProvider: query " + uri.toString() + + " for " + (projection == null ? null : TextUtils.join(",", projection)) + + " where " + selection + + " with " + (selectionArgs == null ? null : TextUtils.join(";", selectionArgs))); + + for(final ContentOverride content : mOverrides) { + if (content.match(uri.toString(), selection, selectionArgs)) { + return new FakeCursor(projection, content.columns, content.data); + } + } + if (mProvider != null) { + try { + LogUtil.w(LogUtil.BUGLE_TAG, "FakeContentProvider: delgating"); + + final Cursor cursor = mProvider.query(uri, projection, selection, selectionArgs, + sortOrder); + + LogUtil.w(LogUtil.BUGLE_TAG, "FakeContentProvider: response size " + + cursor.getCount() + " contains " + TextUtils.join(",", + cursor.getColumnNames()) + " type(0) " + cursor.getType(0)); + + return cursor; + } catch (final RemoteException e) { + e.printStackTrace(); + } + } + return null; + } + + @Override + public String getType(final Uri uri) { + String type = mTypes.get(uri.toString()); + if (type == null) { + try { + type = mProvider.getType(uri); + } catch (final RemoteException e) { + e.printStackTrace(); + } + } + return type; + } + + @Override + public Uri insert(final Uri uri, final ContentValues values) { + // TODO: Add code to track insert operations and return correct status + throw new UnsupportedOperationException(); + } + + @Override + public int delete(final Uri uri, final String selection, final String[] selectionArgs) { + // TODO: Add code to track delete operations and return correct status + throw new UnsupportedOperationException(); + } + + @Override + public int update(final Uri uri, final ContentValues values, final String selection, + final String[] selectionArgs) { + // TODO: Add code to track update operations and return correct status + throw new UnsupportedOperationException(); + } + + public Bundle call(final String callingPkg, final String method, final String arg, + final Bundle extras) { + return null; + } +} diff --git a/tests/src/com/android/messaging/FakeContext.java b/tests/src/com/android/messaging/FakeContext.java new file mode 100644 index 0000000..671e0f3 --- /dev/null +++ b/tests/src/com/android/messaging/FakeContext.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging; + +import android.content.ComponentName; +import android.content.ContentProvider; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.test.RenamingDelegatingContext; +import android.test.mock.MockContentResolver; +import android.util.Log; + +import java.util.ArrayList; + +public class FakeContext extends RenamingDelegatingContext { + private static final String TAG = "FakeContext"; + + public interface FakeContextHost { + public String getServiceClassName(); + public void startServiceForStub(Intent intent); + public void onStartCommandForStub(Intent intent, int flags, int startid); + } + + ArrayList mStartedIntents; + boolean mServiceStarted = false; + private final FakeContextHost mService; + private final MockContentResolver mContentResolver; + + + public FakeContext(final Context context, final FakeContextHost service) { + super(context, "test_"); + mService = service; + mStartedIntents = new ArrayList(); + mContentResolver = new MockContentResolver(); + } + + public FakeContext(final Context context) { + this(context, null); + } + + public ArrayList extractIntents() { + final ArrayList intents = mStartedIntents; + mStartedIntents = new ArrayList(); + return intents; + } + + @Override + public ComponentName startService(final Intent intent) { + // Record that a startService occurred with the intent that was passed. + Log.d(TAG, "MockContext receiving startService. intent=" + intent.toString()); + mStartedIntents.add(intent); + if (mService == null) { + return super.startService(intent); + } else if (intent.getComponent() != null && + intent.getComponent().getClassName().equals(mService.getServiceClassName())) { + if (!mServiceStarted) { + Log.d(TAG, "MockContext first start service."); + mService.startServiceForStub(intent); + } else { + Log.d(TAG, "MockContext not first start service. Calling onStartCommand."); + mService.onStartCommandForStub(intent, 0, 0); + } + mServiceStarted = true; + return new ComponentName(this, intent.getComponent().getClassName()); + } + return null; + } + + @Override + public ContentResolver getContentResolver() { + // If you want to use a content provider in your test, then you need to add it + // explicitly. + return mContentResolver; + } + + public void addContentProvider(final String name, final ContentProvider provider) { + mContentResolver.addProvider(name, provider); + } + + public void addDefaultProvider(final Context context, final Uri uri) { + final FakeContentProvider provider = new FakeContentProvider(context, uri, true); + mContentResolver.addProvider(uri.getAuthority(), provider); + } + +} diff --git a/tests/src/com/android/messaging/FakeFactory.java b/tests/src/com/android/messaging/FakeFactory.java new file mode 100644 index 0000000..41ede77 --- /dev/null +++ b/tests/src/com/android/messaging/FakeFactory.java @@ -0,0 +1,288 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging; + +import android.content.ContentProvider; +import android.content.Context; +import android.net.Uri; +import android.provider.Settings; +import android.telephony.SubscriptionInfo; +import android.telephony.SubscriptionManager; + +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.MemoryCacheManager; +import com.android.messaging.datamodel.ParticipantRefresh.ContactContentObserver; +import com.android.messaging.datamodel.media.MediaCacheManager; +import com.android.messaging.datamodel.media.MediaResourceManager; +import com.android.messaging.sms.ApnDatabase; +import com.android.messaging.sms.BugleCarrierConfigValuesLoader; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.FakeBugleGservices; +import com.android.messaging.util.FakeBuglePrefs; +import com.android.messaging.util.MediaUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +import org.mockito.Matchers; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import java.util.List; + +public class FakeFactory extends Factory { + private Context mContext; + private FakeContext mFakeContext; + private BugleGservices mBugleGservices; + private BuglePrefs mBuglePrefs; + private DataModel mDataModel; + private UIIntents mUIIntents; + private MemoryCacheManager mMemoryCacheManager; + private MediaResourceManager mMediaResourceManager; + private MediaCacheManager mMediaCacheManager; + @Mock protected PhoneUtils mPhoneUtils; + private MediaUtil mMediaUtil; + private BugleCarrierConfigValuesLoader mCarrierConfigValuesLoader; + + private FakeFactory() { + } + + public static FakeFactory registerWithFakeContext(final Context context, + final FakeContext fake) { + // In tests we currently NEVER run the application/factory initialization + Assert.isTrue(!sRegistered); + Assert.isTrue(!sInitialized); + + final FakeFactory factory = new FakeFactory(); + Factory.setInstance(factory); + + // At this point Factory is published. Services can now get initialized and depend on + // Factory.get(). + factory.mContext = context; + factory.mFakeContext = fake; + factory.mMediaResourceManager = Mockito.mock(MediaResourceManager.class); + factory.mBugleGservices = new FakeBugleGservices(); + factory.mBuglePrefs = new FakeBuglePrefs(); + factory.mPhoneUtils = Mockito.mock(PhoneUtils.class); + + ApnDatabase.initializeAppContext(context); + + Mockito.when(factory.mPhoneUtils.getCanonicalBySystemLocale(Matchers.anyString())) + .thenAnswer(new Answer() { + @Override + public String answer(final InvocationOnMock invocation) throws Throwable { + final Object[] args = invocation.getArguments(); + return (String) args[0]; + } + } + ); + Mockito.when(factory.mPhoneUtils.getCanonicalBySimLocale(Matchers.anyString())).thenAnswer( + new Answer() { + @Override + public String answer(final InvocationOnMock invocation) throws Throwable { + final Object[] args = invocation.getArguments(); + return (String) args[0]; + } + } + ); + Mockito.when(factory.mPhoneUtils.formatForDisplay(Matchers.anyString())).thenAnswer( + new Answer() { + @Override + public String answer(final InvocationOnMock invocation) throws Throwable { + return (String) invocation.getArguments()[0]; + } + } + ); + if (OsUtil.isAtLeastL_MR1()) { + Mockito.when(factory.mPhoneUtils.toLMr1()).thenReturn( + new PhoneUtils.LMr1() { + @Override + public SubscriptionInfo getActiveSubscriptionInfo() { + return null; + } + + @Override + public List getActiveSubscriptionInfoList() { + return null; + } + + @Override + public void registerOnSubscriptionsChangedListener( + final SubscriptionManager.OnSubscriptionsChangedListener listener) { + } + } + ); + } + // By default only allow reading of system settings (that we provide) - can delegate + // to real provider if required. + final FakeContentProvider settings = new FakeContentProvider(context, + Settings.System.CONTENT_URI, false); + settings.addOverrideData(Settings.System.CONTENT_URI, "name=?", "time_12_24", + new String[] { "value" }, new Object[][] { { "12" } }); + settings.addOverrideData(Settings.System.CONTENT_URI, "name=?", "sound_effects_enabled", + new String[] { "value" }, new Object[][] { { 1 } }); + + factory.withProvider(Settings.System.CONTENT_URI, settings); + + return factory; + } + + public static FakeFactory register(final Context applicationContext) { + final FakeContext context = new FakeContext(applicationContext); + return registerWithFakeContext(applicationContext, context); + } + + public static FakeFactory registerWithoutFakeContext(final Context applicationContext) { + return registerWithFakeContext(applicationContext, null); + } + + @Override + public void onRequiredPermissionsAcquired() { + } + + @Override + public Context getApplicationContext() { + return ((mFakeContext != null) ? mFakeContext : mContext ); + } + + @Override + public DataModel getDataModel() { + return mDataModel; + } + + @Override + public BugleGservices getBugleGservices() { + return mBugleGservices; + } + + @Override + public BuglePrefs getApplicationPrefs() { + return mBuglePrefs; + } + + @Override + public BuglePrefs getWidgetPrefs() { + return mBuglePrefs; + } + + @Override + public BuglePrefs getSubscriptionPrefs(final int subId) { + return mBuglePrefs; + } + + @Override + public UIIntents getUIIntents() { + return mUIIntents; + } + + @Override + public MemoryCacheManager getMemoryCacheManager() { + return mMemoryCacheManager; + } + + @Override + public MediaResourceManager getMediaResourceManager() { + return mMediaResourceManager; + } + + @Override + public MediaCacheManager getMediaCacheManager() { + return mMediaCacheManager; + } + + @Override + public PhoneUtils getPhoneUtils(final int subId) { + return mPhoneUtils; + } + + @Override + public MediaUtil getMediaUtil() { + return mMediaUtil; + } + + @Override + public BugleCarrierConfigValuesLoader getCarrierConfigValuesLoader() { + return mCarrierConfigValuesLoader; + } + + @Override + public ContactContentObserver getContactContentObserver() { + return null; + } + + @Override + public void reclaimMemory() { + } + + @Override + public void onActivityResume() { + } + + public FakeFactory withDataModel(final DataModel dataModel) { + this.mDataModel = dataModel; + return this; + } + + public FakeFactory withUIIntents(final UIIntents uiIntents) { + this.mUIIntents = uiIntents; + return this; + } + + public FakeFactory withMemoryCacheManager(final MemoryCacheManager memoryCacheManager) { + this.mMemoryCacheManager = memoryCacheManager; + return this; + } + + public FakeFactory withBugleGservices(final BugleGservices bugleGservices) { + this.mBugleGservices = bugleGservices; + return this; + } + + public FakeFactory withMediaCacheManager(final MediaCacheManager mediaCacheManager) { + this.mMediaCacheManager = mediaCacheManager; + return this; + } + + public FakeFactory withProvider(final Uri uri, final ContentProvider provider) { + if (mFakeContext != null) { + mFakeContext.addContentProvider(uri.getAuthority(), provider); + } + return this; + } + + public FakeFactory withDefaultProvider(final Uri uri) { + if (mFakeContext != null) { + mFakeContext.addDefaultProvider(this.mContext, uri); + } + return this; + } + + public FakeFactory withMediaUtil(final MediaUtil mediaUtil) { + this.mMediaUtil = mediaUtil; + return this; + } + + public FakeFactory withCarrierConfigValuesLoader( + final BugleCarrierConfigValuesLoader carrierConfigValuesLoader) { + this.mCarrierConfigValuesLoader = carrierConfigValuesLoader; + return this; + } +} diff --git a/tests/src/com/android/messaging/TestUtil.java b/tests/src/com/android/messaging/TestUtil.java new file mode 100644 index 0000000..77a8450 --- /dev/null +++ b/tests/src/com/android/messaging/TestUtil.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging; + +import android.appwidget.AppWidgetManager; +import android.content.ComponentName; +import android.content.Context; +import android.os.PowerManager; +import android.test.InstrumentationTestCase; + +import com.android.messaging.util.LogUtil; +import com.android.messaging.widget.BugleWidgetProvider; +import com.android.messaging.widget.WidgetConversationProvider; + +import junit.framework.TestCase; + +import org.mockito.MockitoAnnotations; + +/** + * Helpers that can be called from all test base classes to 'reset' state and prevent as much as + * possible having side effects leak from one test to another. + */ +public class TestUtil { + public static void testSetup(final Context context, final TestCase testCase) { + haltIfTestsAreNotAbleToRun(context); + + // Workaround to get mockito to work. + // See https://code.google.com/p/dexmaker/issues/detail?id=2. TODO: Apparently + // solvable by using a different runner. + System.setProperty("dexmaker.dexcache", + context.getCacheDir().getPath()); + + // Initialize @Mock objects. + MockitoAnnotations.initMocks(testCase); + + // Tests have to explicitly override this + Factory.setInstance(null); + } + + public static void testTeardown(final TestCase testCase) { + if (testCase instanceof InstrumentationTestCase) { + // Make sure the test case is finished running or we'll get NPEs when accessing + // Fragment.get() + ((InstrumentationTestCase) testCase).getInstrumentation().waitForIdleSync(); + } + Factory.setInstance(null); + } + + private static void haltIfTestsAreNotAbleToRun(final Context context) { + final PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + if (!pm.isScreenOn()) { + // Ideally we could turn it on for you using the WindowManager, but we currently run + // the tests independently of the activity life cycle. + LogUtil.wtf(LogUtil.BUGLE_TAG, "You need to turn on your screen to run tests!"); + } + + final AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context); + int [] conversationWidgetIds = appWidgetManager.getAppWidgetIds(new ComponentName(context, + WidgetConversationProvider.class)); + int [] conversationListWidgetIds = appWidgetManager.getAppWidgetIds(new + ComponentName(context, BugleWidgetProvider.class)); + + if ((conversationWidgetIds.length > 0) || (conversationListWidgetIds.length > 0)) { + // Currently widgets asynchronously access our content providers and singletons which + // interacts badly with our test setup and tear down. + LogUtil.wtf(LogUtil.BUGLE_TAG, "You currently can't reliably run unit tests" + + " with a Messaging widget on your desktop!"); + } + } +} diff --git a/tests/src/com/android/messaging/datamodel/BindingTest.java b/tests/src/com/android/messaging/datamodel/BindingTest.java new file mode 100644 index 0000000..9205657 --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/BindingTest.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.test.suitebuilder.annotation.SmallTest; + +import com.android.messaging.BugleTestCase; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.binding.ImmutableBindingRef; + +/** + * Test binding + */ +@SmallTest +public class BindingTest extends BugleTestCase { + private static final Object TEST_DATA_ID = "myDataId"; + private static final Object YOUR_DATA_ID = "yourDataId"; + + public void testBindingStartsUnbound() { + final Binding binding = BindingBase.createBinding(this); + assertNull(binding.getBindingId()); + } + + public void testDataStartsUnbound() { + final TestBindableData data = new TestBindableData(TEST_DATA_ID); + assertFalse(data.isBound()); + } + + public void testBindingUpdatesDataAndBindee() { + final Binding binding = BindingBase.createBinding(this); + final TestBindableData data = new TestBindableData(TEST_DATA_ID); + binding.bind(data); + assertTrue(binding.isBound()); + assertEquals(binding.getData(), data); + assertTrue(data.isBound(binding.getBindingId())); + assertFalse(data.isBound("SomeRandomString")); + assertNotNull(binding.getBindingId()); + assertFalse(data.mListenersUnregistered); + } + + public void testRebindingFails() { + final Binding binding = BindingBase.createBinding(this); + final TestBindableData yours = new TestBindableData(YOUR_DATA_ID); + binding.bind(yours); + assertEquals(binding.getData(), yours); + assertTrue(yours.isBound(binding.getBindingId())); + final TestBindableData data = new TestBindableData(TEST_DATA_ID); + try { + binding.bind(data); + fail(); + } catch (final IllegalStateException e) { + } + assertTrue(binding.isBound()); + assertEquals(binding.getData(), yours); + assertTrue(yours.isBound(binding.getBindingId())); + } + + public void testUnbindingClearsDataAndBindee() { + final Binding binding = BindingBase.createBinding(this); + final TestBindableData data = new TestBindableData(TEST_DATA_ID); + binding.bind(data); + assertTrue(data.isBound(binding.getBindingId())); + assertTrue(binding.isBound()); + binding.unbind(); + try { + final TestBindableData other = binding.getData(); + fail(); + } catch (final IllegalStateException e) { + } + assertFalse(data.isBound()); + assertNull(binding.getBindingId()); + assertTrue(data.mListenersUnregistered); + } + + public void testUnbindingAndRebinding() { + final Binding binding = BindingBase.createBinding(this); + final TestBindableData yours = new TestBindableData(YOUR_DATA_ID); + binding.bind(yours); + assertEquals(binding.getData(), yours); + assertTrue(yours.isBound(binding.getBindingId())); + binding.unbind(); + assertFalse(yours.isBound()); + assertNull(binding.getBindingId()); + + final TestBindableData data = new TestBindableData(TEST_DATA_ID); + binding.bind(data); + assertEquals(binding.getData(), data); + assertTrue(data.isBound(binding.getBindingId())); + assertFalse(data.isBound("SomeRandomString")); + assertTrue(binding.isBound()); + assertNotNull(binding.getBindingId()); + } + + public void testBindingReference() { + final Binding binding = BindingBase.createBinding(this); + final TestBindableData data = new TestBindableData(TEST_DATA_ID); + binding.bind(data); + assertEquals(binding.getData(), data); + assertTrue(data.isBound(binding.getBindingId())); + + final ImmutableBindingRef bindingRef = + BindingBase.createBindingReference(binding); + assertEquals(bindingRef.getData(), data); + assertTrue(data.isBound(bindingRef.getBindingId())); + + binding.unbind(); + assertFalse(binding.isBound()); + assertNull(binding.getBindingId()); + assertFalse(bindingRef.isBound()); + assertNull(bindingRef.getBindingId()); + } + + static class TestBindableData extends BindableData { + private final Object mDataId; + public boolean mListenersUnregistered; + + public TestBindableData(final Object dataId) { + mDataId = dataId; + mListenersUnregistered = false; + } + + @Override + public void unregisterListeners() { + mListenersUnregistered = true; + } + + @Override + public boolean isBound() { + return super.isBound(); + } + } +} diff --git a/tests/src/com/android/messaging/datamodel/BitmapPoolTest.java b/tests/src/com/android/messaging/datamodel/BitmapPoolTest.java new file mode 100644 index 0000000..6d0aaa3 --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/BitmapPoolTest.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.test.suitebuilder.annotation.SmallTest; + +import com.android.messaging.BugleTestCase; +import com.android.messaging.FakeFactory; +import com.android.messaging.R; + +import org.mockito.Mock; + +import java.util.HashSet; +import java.util.Set; + +@SmallTest +public class BitmapPoolTest extends BugleTestCase { + private static final int POOL_SIZE = 5; + private static final int IMAGE_DIM = 1; + private static final String NAME = "BitmapPoolTest"; + + @Mock private MemoryCacheManager mockMemoryCacheManager; + + @Override + protected void setUp() throws Exception { + super.setUp(); + FakeFactory.register(getTestContext()) + .withMemoryCacheManager(mockMemoryCacheManager); + } + + private Set fillPoolAndGetPoolContents(final BitmapPool pool, final int width, + final int height) { + final Set returnedBitmaps = new HashSet(); + for (int i = 0; i < POOL_SIZE; i++) { + final Bitmap temp = pool.createOrReuseBitmap(width, height); + assertFalse(returnedBitmaps.contains(temp)); + returnedBitmaps.add(temp); + } + for (final Bitmap b : returnedBitmaps) { + pool.reclaimBitmap(b); + } + assertTrue(pool.isFull(width, height)); + return returnedBitmaps; + } + + public void testCreateAndPutBackInPoolTest() { + final BitmapPool pool = new BitmapPool(POOL_SIZE, NAME); + final Bitmap bitmap = pool.createOrReuseBitmap(IMAGE_DIM, IMAGE_DIM); + assertFalse(bitmap.isRecycled()); + assertFalse(pool.isFull(IMAGE_DIM, IMAGE_DIM)); + pool.reclaimBitmap(bitmap); + + // Don't recycle because the pool isn't full yet. + assertFalse(bitmap.isRecycled()); + } + + public void testCreateBeyondFullAndCheckReuseTest() { + final BitmapPool pool = new BitmapPool(POOL_SIZE, NAME); + final Set returnedBitmaps = + fillPoolAndGetPoolContents(pool, IMAGE_DIM, IMAGE_DIM); + final Bitmap overflowBitmap = pool.createOrReuseBitmap(IMAGE_DIM, IMAGE_DIM); + assertFalse(overflowBitmap.isRecycled()); + assertTrue(returnedBitmaps.contains(overflowBitmap)); + } + + /** + * Make sure that we have the correct options to create mutable for bitmap pool reuse. + */ + public void testAssertBitmapOptionsAreMutable() { + final BitmapFactory.Options options = + BitmapPool.getBitmapOptionsForPool(false, IMAGE_DIM, IMAGE_DIM); + assertTrue(options.inMutable); + } + + public void testDecodeFromResourceBitmap() { + final BitmapPool pool = new BitmapPool(POOL_SIZE, NAME); + final BitmapFactory.Options options = + BitmapPool.getBitmapOptionsForPool(true, IMAGE_DIM, IMAGE_DIM); + final Resources resources = getContext().getResources(); + final Bitmap resourceBitmap = pool.decodeSampledBitmapFromResource( + R.drawable.msg_bubble_incoming, resources, options, IMAGE_DIM, IMAGE_DIM); + assertNotNull(resourceBitmap); + assertTrue(resourceBitmap.getByteCount() > 0); + } +} diff --git a/tests/src/com/android/messaging/datamodel/BugleServiceTestCase.java b/tests/src/com/android/messaging/datamodel/BugleServiceTestCase.java new file mode 100644 index 0000000..42eb647 --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/BugleServiceTestCase.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.app.Service; +import android.test.ServiceTestCase; + +import com.android.messaging.BugleTestCase; +import com.android.messaging.TestUtil; + + +/* + * Base class for service tests that takes care of housekeeping that is commong amongst our service + * test case. + */ +public abstract class BugleServiceTestCase extends ServiceTestCase { + + static { + // Set flag during loading of test cases to prevent application initialization starting + BugleTestCase.setTestsRunning(); + } + + public BugleServiceTestCase(final Class serviceClass) { + super(serviceClass); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + TestUtil.testSetup(getContext(), this); + } + + @Override + protected void tearDown() throws Exception { + super.tearDown(); + TestUtil.testTeardown(this); + } +} \ No newline at end of file diff --git a/tests/src/com/android/messaging/datamodel/ConversationListTest.java b/tests/src/com/android/messaging/datamodel/ConversationListTest.java new file mode 100644 index 0000000..f45696b --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/ConversationListTest.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.test.suitebuilder.annotation.SmallTest; + +import com.android.messaging.BugleTestCase; + +@SmallTest +public class ConversationListTest extends BugleTestCase { + public void testTesting() { + assertTrue(true); + } +} diff --git a/tests/src/com/android/messaging/datamodel/DataModelTest.java b/tests/src/com/android/messaging/datamodel/DataModelTest.java new file mode 100644 index 0000000..71723a4 --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/DataModelTest.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.test.suitebuilder.annotation.SmallTest; + +import com.android.messaging.BugleTestCase; +import com.android.messaging.FakeFactory; +import com.android.messaging.datamodel.data.ConversationData; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; +import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener; + +import org.mockito.Mock; + +public class DataModelTest extends BugleTestCase { + + DataModel dataModel; + @Mock protected ConversationDataListener mockConversationDataListener; + @Mock protected ConversationListDataListener mockConversationListDataListener; + + @Override + protected void setUp() throws Exception { + super.setUp(); + dataModel = new DataModelImpl(getTestContext()); + FakeFactory.register(mContext) + .withDataModel(dataModel); + } + + @SmallTest + public void testCreateConversationList() { + final ConversationListData list = dataModel.createConversationListData(getContext(), + mockConversationListDataListener, true); + assertTrue(list instanceof ConversationListData); + final ConversationData conv = dataModel.createConversationData(getContext(), + mockConversationDataListener, "testConversation"); + assertTrue(conv instanceof ConversationData); + } + + private static final String FOCUSED_CONV_ID = "focused_conv_id"; + + @SmallTest + public void testFocusedConversationIsObservable() { + dataModel.setFocusedConversation(FOCUSED_CONV_ID); + assertTrue(dataModel.isNewMessageObservable(FOCUSED_CONV_ID)); + dataModel.setFocusedConversation(null); + assertFalse(dataModel.isNewMessageObservable(FOCUSED_CONV_ID)); + } + + @SmallTest + public void testConversationIsObservableInList() { + dataModel.setConversationListScrolledToNewestConversation(true); + assertTrue(dataModel.isNewMessageObservable(FOCUSED_CONV_ID)); + dataModel.setConversationListScrolledToNewestConversation(false); + assertFalse(dataModel.isNewMessageObservable(FOCUSED_CONV_ID)); + } +} diff --git a/tests/src/com/android/messaging/datamodel/FakeCursor.java b/tests/src/com/android/messaging/datamodel/FakeCursor.java new file mode 100644 index 0000000..59d1b89 --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/FakeCursor.java @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.os.Bundle; +import android.test.mock.MockCursor; + +import java.util.ArrayList; + +/** + * A simple in memory fake cursor that can be used for UI tests. + */ +public class FakeCursor extends MockCursor { + private final ArrayList mProjection; + private final String[] mColumnNamesOfData; + private final Object[][] mData; + private int mIndex; + + public FakeCursor(final String[] projection, final String[] columnNames, + final Object[][] data) { + mColumnNamesOfData = columnNames; + mData = data; + mIndex = -1; + mProjection = new ArrayList(projection.length); + for (final String column : projection) { + mProjection.add(getColumnIndex(column)); + } + } + + public Object getAt(final String columnName, final int row) { + final int dataIdx = getColumnIndex(columnName); + return (dataIdx < 0 || row < 0 || row >= mData.length) ? 0 : mData[row][dataIdx]; + } + + @Override + public int getCount() { + return mData.length; + } + + @Override + public boolean isFirst() { + return mIndex == 0; + } + + @Override + public boolean isLast() { + return mIndex == mData.length - 1; + } + + @Override + public boolean moveToFirst() { + if (mData.length == 0) { + return false; + } + mIndex = 0; + return true; + } + + @Override + public boolean moveToPosition(final int position) { + if (position < 0 || position >= mData.length) { + return false; + } + mIndex = position; + return true; + } + + @Override + public int getPosition() { + return mIndex; + } + + @Override + public boolean moveToPrevious() { + if (mIndex <= 0) { + return false; + } + mIndex--; + return true; + } + + @Override + public boolean moveToNext() { + if (mIndex == mData.length - 1) { + return false; + } + + mIndex++; + return true; + } + + @Override + public int getColumnCount() { + return mColumnNamesOfData.length; + } + + @Override + public int getColumnIndex(final String columnName) { + for (int i = 0 ; i < mColumnNamesOfData.length ; i++) { + if (mColumnNamesOfData[i].equals(columnName)) { + return i; + } + } + return -1; + } + + @Override + public int getColumnIndexOrThrow(final String columnName) { + final int result = getColumnIndex(columnName); + if (result == -1) { + throw new IllegalArgumentException(); + } + + return result; + } + + @Override + public String getString(final int columnIndex) { + final int dataIdx = mProjection.get(columnIndex); + final Object obj = (dataIdx < 0 ? null : mData[mIndex][dataIdx]); + return (obj == null ? null : obj.toString()); + } + + @Override + public int getInt(final int columnIndex) { + final int dataIdx = mProjection.get(columnIndex); + return (dataIdx < 0 ? 0 : (Integer) mData[mIndex][dataIdx]); + } + + @Override + public long getLong(final int columnIndex) { + final int dataIdx = mProjection.get(columnIndex); + return (dataIdx < 0 ? 0 : (Long) mData[mIndex][dataIdx]); + } + + @Override + public void close() { + } + + @Override + public boolean isClosed() { + return false; + } + + @Override + public Bundle getExtras() { return null; } +} diff --git a/tests/src/com/android/messaging/datamodel/FakeDataModel.java b/tests/src/com/android/messaging/datamodel/FakeDataModel.java new file mode 100644 index 0000000..5e80eab --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/FakeDataModel.java @@ -0,0 +1,257 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.test.RenamingDelegatingContext; + +import com.android.messaging.datamodel.action.ActionService; +import com.android.messaging.datamodel.action.BackgroundWorker; +import com.android.messaging.datamodel.data.BlockedParticipantsData; +import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener; +import com.android.messaging.datamodel.data.ContactListItemData; +import com.android.messaging.datamodel.data.ContactPickerData; +import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataListener; +import com.android.messaging.datamodel.data.ConversationData; +import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.GalleryGridItemData; +import com.android.messaging.datamodel.data.LaunchConversationData; +import com.android.messaging.datamodel.data.LaunchConversationData.LaunchConversationDataListener; +import com.android.messaging.datamodel.data.MediaPickerData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.ParticipantListItemData; +import com.android.messaging.datamodel.data.PeopleAndOptionsData; +import com.android.messaging.datamodel.data.PeopleAndOptionsData.PeopleAndOptionsDataListener; +import com.android.messaging.datamodel.data.PeopleOptionsItemData; +import com.android.messaging.datamodel.data.SettingsData; +import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener; +import com.android.messaging.datamodel.data.SubscriptionListData; +import com.android.messaging.datamodel.data.TestDataFactory; +import com.android.messaging.datamodel.data.VCardContactItemData; +import com.android.messaging.util.ConnectivityUtil; + +public class FakeDataModel extends DataModel { + private BackgroundWorker mWorker; + private ActionService mActionService; + private final DatabaseHelper mDatabaseHelper; + private ConversationListData mConversationListData; + private ContactPickerData mContactPickerData; + private MediaPickerData mMediaPickerData; + private PeopleAndOptionsData mPeopleAndOptionsData; + private ConnectivityUtil mConnectivityUtil; + private SyncManager mSyncManager; + private SettingsData mSettingsData; + private DraftMessageData mDraftMessageData; + + public FakeDataModel(final Context context) { + super(); + if (context instanceof RenamingDelegatingContext) { + mDatabaseHelper = DatabaseHelper.getNewInstanceForTest(context); + } else { + mDatabaseHelper = null; + } + } + + @Override + public BackgroundWorker getBackgroundWorkerForActionService() { + return mWorker; + } + + public FakeDataModel withBackgroundWorkerForActionService(final BackgroundWorker worker) { + mWorker = worker; + return this; + } + + public FakeDataModel withActionService(final ActionService ActionService) { + mActionService = ActionService; + return this; + } + + public FakeDataModel withConversationListData(final ConversationListData conversationListData) { + mConversationListData = conversationListData; + return this; + } + + public FakeDataModel withContactPickerData(final ContactPickerData contactPickerData) { + mContactPickerData = contactPickerData; + return this; + } + + public FakeDataModel withMediaPickerData(final MediaPickerData mediaPickerData) { + mMediaPickerData = mediaPickerData; + return this; + } + + public FakeDataModel withConnectivityUtil(final ConnectivityUtil connectivityUtil) { + mConnectivityUtil = connectivityUtil; + return this; + } + + public FakeDataModel withSyncManager(final SyncManager syncManager) { + mSyncManager = syncManager; + return this; + } + + public FakeDataModel withPeopleAndOptionsData(final PeopleAndOptionsData peopleAndOptionsData) { + mPeopleAndOptionsData = peopleAndOptionsData; + return this; + } + + public FakeDataModel withSettingsData(final SettingsData settingsData) { + mSettingsData = settingsData; + return this; + } + + public FakeDataModel withDraftMessageData(final DraftMessageData draftMessageData) { + mDraftMessageData = draftMessageData; + return this; + } + + @Override + public ConversationListData createConversationListData(final Context context, + final ConversationListDataListener listener, final boolean archivedMode) { + return mConversationListData; + } + + @Override + public ConversationData createConversationData(final Context context, + final ConversationDataListener listener, final String conversationId) { + throw new IllegalStateException("Add withXXX or mock this method"); + } + + @Override + public ContactListItemData createContactListItemData() { + // This is a lightweight data holder object for each individual list item for which + // we don't perform any data request, so we can directly return a new instance. + return new ContactListItemData(); + } + + @Override + public ContactPickerData createContactPickerData(final Context context, + final ContactPickerDataListener listener) { + return mContactPickerData; + } + + @Override + public MediaPickerData createMediaPickerData(final Context context) { + return mMediaPickerData; + } + + @Override + public GalleryGridItemData createGalleryGridItemData() { + // This is a lightweight data holder object for each individual grid item for which + // we don't perform any data request, so we can directly return a new instance. + return new GalleryGridItemData(); + } + + @Override + public LaunchConversationData createLaunchConversationData( + final LaunchConversationDataListener listener) { + return new LaunchConversationData(listener); + } + + @Override + public PeopleOptionsItemData createPeopleOptionsItemData(final Context context) { + return new PeopleOptionsItemData(context); + } + + @Override + public PeopleAndOptionsData createPeopleAndOptionsData(final String conversationId, + final Context context, final PeopleAndOptionsDataListener listener) { + return mPeopleAndOptionsData; + } + + @Override + public VCardContactItemData createVCardContactItemData(final Context context, + final MessagePartData data) { + return new VCardContactItemData(context, data); + } + + @Override + public VCardContactItemData createVCardContactItemData(final Context context, + final Uri vCardUri) { + return new VCardContactItemData(context, vCardUri); + } + + @Override + public ParticipantListItemData createParticipantListItemData( + final ParticipantData participant) { + return new ParticipantListItemData(participant); + } + + @Override + public SubscriptionListData createSubscriptonListData(Context context) { + return new SubscriptionListData(context); + } + + @Override + public SettingsData createSettingsData(Context context, SettingsDataListener listener) { + return mSettingsData; + } + + @Override + public DraftMessageData createDraftMessageData(String conversationId) { + return mDraftMessageData; + } + + @Override + public ActionService getActionService() { + return mActionService; + } + + @Override + public ConnectivityUtil getConnectivityUtil() { + return mConnectivityUtil; + } + + @Override + public SyncManager getSyncManager() { + return mSyncManager; + } + + @Override + public DatabaseWrapper getDatabase() { + // Note this will crash unless the application context is redirected... + // This is by design so that tests do not inadvertently use the real database + return mDatabaseHelper.getDatabase(); + } + + @Override + void onCreateTables(final SQLiteDatabase db) { + TestDataFactory.createTestData(db); + } + + @Override + public void onActivityResume() { + } + + @Override + public void onApplicationCreated() { + } + + @Override + public BlockedParticipantsData createBlockedParticipantsData(Context context, + BlockedParticipantsDataListener listener) { + return new BlockedParticipantsData(context, listener); + } +} diff --git a/tests/src/com/android/messaging/datamodel/FrequentContactsCursorBuilderTest.java b/tests/src/com/android/messaging/datamodel/FrequentContactsCursorBuilderTest.java new file mode 100644 index 0000000..6b78a07 --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/FrequentContactsCursorBuilderTest.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel; + +import android.database.Cursor; +import android.test.suitebuilder.annotation.SmallTest; + +import com.android.messaging.BugleTestCase; +import com.android.messaging.datamodel.data.TestDataFactory; +import com.android.messaging.util.ContactUtil; + +@SmallTest +public class FrequentContactsCursorBuilderTest extends BugleTestCase { + + private void verifyBuiltCursor(final Cursor expected, final Cursor actual) { + final int rowCount = expected.getCount(); + final int columnCount = expected.getColumnCount(); + assertEquals(rowCount, actual.getCount()); + assertEquals(columnCount, actual.getColumnCount()); + for (int i = 0; i < rowCount; i++) { + expected.moveToPosition(i); + actual.moveToPosition(i); + assertEquals(expected.getLong(ContactUtil.INDEX_DATA_ID), + actual.getLong(ContactUtil.INDEX_DATA_ID)); + assertEquals(expected.getLong(ContactUtil.INDEX_CONTACT_ID), + actual.getLong(ContactUtil.INDEX_CONTACT_ID)); + assertEquals(expected.getString(ContactUtil.INDEX_LOOKUP_KEY), + actual.getString(ContactUtil.INDEX_LOOKUP_KEY)); + assertEquals(expected.getString(ContactUtil.INDEX_DISPLAY_NAME), + actual.getString(ContactUtil.INDEX_DISPLAY_NAME)); + assertEquals(expected.getString(ContactUtil.INDEX_PHOTO_URI), + actual.getString(ContactUtil.INDEX_PHOTO_URI)); + assertEquals(expected.getString(ContactUtil.INDEX_PHONE_EMAIL), + actual.getString(ContactUtil.INDEX_PHONE_EMAIL)); + assertEquals(expected.getInt(ContactUtil.INDEX_PHONE_EMAIL_TYPE), + actual.getInt(ContactUtil.INDEX_PHONE_EMAIL_TYPE)); + assertEquals(expected.getString(ContactUtil.INDEX_PHONE_EMAIL_LABEL), + actual.getString(ContactUtil.INDEX_PHONE_EMAIL_LABEL)); + } + } + + public void testIncompleteBuild() { + final FrequentContactsCursorBuilder builder = new FrequentContactsCursorBuilder(); + assertNull(builder.build()); + assertNull(builder.setFrequents(TestDataFactory.getStrequentContactsCursor()).build()); + builder.resetBuilder(); + assertNull(builder.build()); + assertNull(builder.setAllContacts(TestDataFactory.getAllContactListCursor()).build()); + } + + public void testBuildOnce() { + final Cursor cursor = new FrequentContactsCursorBuilder() + .setAllContacts(TestDataFactory.getAllContactListCursor()) + .setFrequents(TestDataFactory.getStrequentContactsCursor()) + .build(); + assertNotNull(cursor); + verifyBuiltCursor(TestDataFactory.getFrequentContactListCursor(), cursor); + } + + public void testBuildTwice() { + final FrequentContactsCursorBuilder builder = new FrequentContactsCursorBuilder(); + final Cursor firstCursor = builder + .setAllContacts(TestDataFactory.getAllContactListCursor()) + .setFrequents(TestDataFactory.getStrequentContactsCursor()) + .build(); + assertNotNull(firstCursor); + builder.resetBuilder(); + assertNull(builder.build()); + + final Cursor secondCursor = builder + .setAllContacts(TestDataFactory.getAllContactListCursor()) + .setFrequents(TestDataFactory.getStrequentContactsCursor()) + .build(); + assertNotNull(firstCursor); + verifyBuiltCursor(TestDataFactory.getFrequentContactListCursor(), secondCursor); + } +} diff --git a/tests/src/com/android/messaging/datamodel/MemoryCacheManagerTest.java b/tests/src/com/android/messaging/datamodel/MemoryCacheManagerTest.java new file mode 100644 index 0000000..5da9e27 --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/MemoryCacheManagerTest.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.test.AndroidTestCase; +import android.test.suitebuilder.annotation.SmallTest; + +import com.android.messaging.datamodel.MemoryCacheManager.MemoryCache; + +import org.mockito.Mockito; + +@SmallTest +public class MemoryCacheManagerTest extends AndroidTestCase { + + public void testRegisterCachesGetReclaimed() { + final MemoryCache mockMemoryCache = Mockito.mock(MemoryCache.class); + final MemoryCache otherMockMemoryCache = Mockito.mock(MemoryCache.class); + final MemoryCacheManager memoryCacheManager = new MemoryCacheManager(); + + memoryCacheManager.registerMemoryCache(mockMemoryCache); + memoryCacheManager.registerMemoryCache(otherMockMemoryCache); + memoryCacheManager.reclaimMemory(); + memoryCacheManager.unregisterMemoryCache(otherMockMemoryCache); + memoryCacheManager.reclaimMemory(); + + Mockito.verify(mockMemoryCache, Mockito.times(2)).reclaim(); + Mockito.verify(otherMockMemoryCache, Mockito.times(1)).reclaim(); + } +} diff --git a/tests/src/com/android/messaging/datamodel/ParticipantRefreshTest.java b/tests/src/com/android/messaging/datamodel/ParticipantRefreshTest.java new file mode 100644 index 0000000..cd1d6c7 --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/ParticipantRefreshTest.java @@ -0,0 +1,280 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import android.test.suitebuilder.annotation.SmallTest; +import android.text.TextUtils; + +import com.android.messaging.BugleTestCase; +import com.android.messaging.FakeContentProvider; +import com.android.messaging.FakeContext; +import com.android.messaging.FakeFactory; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.ParticipantData.ParticipantsQuery; +import com.android.messaging.util.ContactUtil; + +import org.junit.Assert; + +/** + * Utility class for testing ParticipantRefresh class for different scenarios. + */ +@SmallTest +public class ParticipantRefreshTest extends BugleTestCase { + private FakeContext mContext; + FakeFactory mFakeFactory; + + @Override + public void setUp() throws Exception { + super.setUp(); + + mContext = new FakeContext(getTestContext()); + + final ContentProvider provider = new MessagingContentProvider(); + provider.attachInfo(mContext, null); + mContext.addContentProvider(MessagingContentProvider.AUTHORITY, provider); + + final FakeDataModel fakeDataModel = new FakeDataModel(mContext); + mFakeFactory = FakeFactory.registerWithFakeContext(getTestContext(), mContext) + .withDataModel(fakeDataModel); + } + + /** + * Add some phonelookup result into take PhoneLookup content provider. This will be + * used for doing phone lookup during participant refresh. + */ + private void addPhoneLookup(final String phone, final Object[][] lookupResult) { + final Uri uri = ContactUtil.lookupPhone(mContext, phone).getUri(); + final FakeContentProvider phoneLookup = new FakeContentProvider(mContext, + uri, false); + phoneLookup.addOverrideData(uri, null, null, ContactUtil.PhoneLookupQuery.PROJECTION, + lookupResult); + mFakeFactory.withProvider(uri, phoneLookup); + } + + /** + * Add some participant to test database. + */ + private void addParticipant(final String normalizedDestination, final long contactId, + final String name, final String photoUrl) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + final ContentValues values = new ContentValues(); + + values.put(ParticipantColumns.NORMALIZED_DESTINATION, normalizedDestination); + values.put(ParticipantColumns.CONTACT_ID, contactId); + values.put(ParticipantColumns.FULL_NAME, name); + values.put(ParticipantColumns.PROFILE_PHOTO_URI, photoUrl); + + db.beginTransaction(); + try { + db.insert(DatabaseHelper.PARTICIPANTS_TABLE, null, values); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + /** + * Verify that participant in the database has expected contacdtId, name and photoUrl fields. + */ + private void verifyParticipant(final String normalizedDestination, final long contactId, + final String name, final String photoUrl) { + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + try { + final String selection = ParticipantColumns.NORMALIZED_DESTINATION + "=?"; + final String[] selectionArgs = new String[] { normalizedDestination }; + + final Cursor cursor = db.query(DatabaseHelper.PARTICIPANTS_TABLE, + ParticipantsQuery.PROJECTION, selection, selectionArgs, null, null, null); + + if (cursor == null || cursor.getCount() != 1) { + Assert.fail("Should have participants for:" + normalizedDestination); + return; + } + + cursor.moveToFirst(); + final int currentContactId = cursor.getInt(ParticipantsQuery.INDEX_CONTACT_ID); + final String currentName = cursor.getString(ParticipantsQuery.INDEX_FULL_NAME); + final String currentPhotoUrl = + cursor.getString(ParticipantsQuery.INDEX_PROFILE_PHOTO_URI); + if (currentContactId != contactId) { + Assert.fail("Contact Id doesn't match. normalizedNumber=" + normalizedDestination + + " expected=" + contactId + " actual=" + currentContactId); + return; + } + + if (!TextUtils.equals(currentName, name)) { + Assert.fail("Name doesn't match. normalizedNumber=" + normalizedDestination + + " expected=" + name + " actual=" + currentName); + return; + } + + if (!TextUtils.equals(currentPhotoUrl, photoUrl)) { + Assert.fail("Contact Id doesn't match. normalizedNumber=" + normalizedDestination + + " expected=" + photoUrl + " actual=" + currentPhotoUrl); + return; + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + /** + * Verify that incremental refresh will resolve previously not resolved participants. + */ + public void testIncrementalRefreshNotResolvedSingleMatch() { + addParticipant("650-123-1233", ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED, + null, null); + addPhoneLookup("650-123-1233", new Object[][] { + { 1L, "John", "content://photo/john", "650-123-1233", null, null, null } + }); + + ParticipantRefresh.refreshParticipants(ParticipantRefresh.REFRESH_MODE_INCREMENTAL); + verifyParticipant("650-123-1233", 1, "John", "content://photo/john"); + } + + /** + * Verify that incremental refresh will resolve previously not resolved participants. + */ + public void testIncrementalRefreshNotResolvedMultiMatch() { + addParticipant("650-123-1233", ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED, + null, null); + addPhoneLookup("650-123-1233", new Object[][] { + { 1L, "John", "content://photo/john", "650-123-1233", null, null, null }, + { 2L, "Joe", "content://photo/joe", "650-123-1233", null, null, null } + }); + + ParticipantRefresh.refreshParticipants(ParticipantRefresh.REFRESH_MODE_INCREMENTAL); + verifyParticipant("650-123-1233", 1, "John", "content://photo/john"); + } + + /** + * Verify that incremental refresh will not touch already-resolved participants. + */ + public void testIncrementalRefreshResolvedSingleMatch() { + addParticipant("650-123-1233", 1, "Joh", "content://photo/joh"); + addPhoneLookup("650-123-1233", new Object[][] { + { 1L, "John", "content://photo/john", "650-123-1233", null, null, null } + }); + + ParticipantRefresh.refreshParticipants(ParticipantRefresh.REFRESH_MODE_INCREMENTAL); + verifyParticipant("650-123-1233", 1, "Joh", "content://photo/joh"); + } + + /** + * Verify that full refresh will correct already-resolved participants if needed + */ + public void testFullRefreshResolvedSingleMatch() { + addParticipant("650-123-1233", 1, "Joh", "content://photo/joh"); + addPhoneLookup("650-123-1233", new Object[][] { + { 1L, "John", "content://photo/john", "650-123-1233", null, null, null } + }); + + ParticipantRefresh.refreshParticipants(ParticipantRefresh.REFRESH_MODE_FULL); + verifyParticipant("650-123-1233", 1, "John", "content://photo/john"); + } + + /** + * Verify that incremental refresh will not touch participant that is marked as not found. + */ + public void testIncrementalRefreshNotFound() { + addParticipant("650-123-1233", ParticipantData.PARTICIPANT_CONTACT_ID_NOT_FOUND, + null, null); + addPhoneLookup("650-123-1233", new Object[][] { + { 1L, "John", "content://photo/john", "650-123-1233", null, null, null } + }); + + ParticipantRefresh.refreshParticipants(ParticipantRefresh.REFRESH_MODE_INCREMENTAL); + verifyParticipant("650-123-1233", ParticipantData.PARTICIPANT_CONTACT_ID_NOT_FOUND, + null, null); + } + + /** + * Verify that full refresh will resolve participant that is marked as not found. + */ + public void testFullRefreshNotFound() { + addParticipant("650-123-1233", ParticipantData.PARTICIPANT_CONTACT_ID_NOT_FOUND, + null, null); + addPhoneLookup("650-123-1233", new Object[][] { + { 1L, "John", "content://photo/john", "650-123-1233", null, null, null } + }); + + ParticipantRefresh.refreshParticipants(ParticipantRefresh.REFRESH_MODE_FULL); + verifyParticipant("650-123-1233", 1, "John", "content://photo/john"); + } + + /** + * Verify that refresh take consideration of current contact_id when having multiple matches. + */ + public void testFullRefreshResolvedMultiMatch1() { + addParticipant("650-123-1233", 1, "Joh", "content://photo/joh"); + addPhoneLookup("650-123-1233", new Object[][] { + { 1L, "John", "content://photo/john", "650-123-1233", null, null, null }, + { 2L, "Joe", "content://photo/joe", "650-123-1233", null, null, null } + }); + + ParticipantRefresh.refreshParticipants(ParticipantRefresh.REFRESH_MODE_FULL); + verifyParticipant("650-123-1233", 1, "John", "content://photo/john"); + } + + /** + * Verify that refresh take consideration of current contact_id when having multiple matches. + */ + public void testFullRefreshResolvedMultiMatch2() { + addParticipant("650-123-1233", 2, "Joh", "content://photo/joh"); + addPhoneLookup("650-123-1233", new Object[][] { + { 1L, "John", "content://photo/john", "650-123-1233", null, null, null }, + { 2L, "Joe", "content://photo/joe", "650-123-1233", null, null, null } + }); + + ParticipantRefresh.refreshParticipants(ParticipantRefresh.REFRESH_MODE_FULL); + verifyParticipant("650-123-1233", 2, "Joe", "content://photo/joe"); + } + + /** + * Verify that refresh take first contact in case current contact_id no longer matches. + */ + public void testFullRefreshResolvedMultiMatch3() { + addParticipant("650-123-1233", 3, "Joh", "content://photo/joh"); + addPhoneLookup("650-123-1233", new Object[][] { + { 1L, "John", "content://photo/john", "650-123-1233", null, null, null }, + { 2L, "Joe", "content://photo/joe", "650-123-1233", null, null, null } + }); + + ParticipantRefresh.refreshParticipants(ParticipantRefresh.REFRESH_MODE_FULL); + verifyParticipant("650-123-1233", 1, "John", "content://photo/john"); + } + + /** + * Verify that refresh take first contact in case current contact_id no longer matches. + */ + public void testFullRefreshResolvedBeforeButNotFoundNow() { + addParticipant("650-123-1233", 3, "Joh", "content://photo/joh"); + addPhoneLookup("650-123-1233", new Object[][] {}); + + ParticipantRefresh.refreshParticipants(ParticipantRefresh.REFRESH_MODE_FULL); + verifyParticipant("650-123-1233", ParticipantData.PARTICIPANT_CONTACT_ID_NOT_FOUND, + null, null); + } +} diff --git a/tests/src/com/android/messaging/datamodel/action/ActionServiceSystemTest.java b/tests/src/com/android/messaging/datamodel/action/ActionServiceSystemTest.java new file mode 100644 index 0000000..039bec9 --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/action/ActionServiceSystemTest.java @@ -0,0 +1,436 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.Intent; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.test.suitebuilder.annotation.MediumTest; +import android.util.Log; + +import com.android.messaging.BugleTestCase; +import com.android.messaging.Factory; +import com.android.messaging.FakeContext; +import com.android.messaging.FakeContext.FakeContextHost; +import com.android.messaging.FakeFactory; +import com.android.messaging.datamodel.BugleServiceTestCase; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.FakeDataModel; +import com.android.messaging.datamodel.action.ActionMonitor.ActionCompletedListener; +import com.android.messaging.datamodel.action.ActionMonitor.ActionExecutedListener; +import com.android.messaging.datamodel.action.ActionTestHelpers.ResultTracker; +import com.android.messaging.datamodel.action.ActionTestHelpers.StubBackgroundWorker; +import com.android.messaging.datamodel.action.ActionTestHelpers.StubConnectivityUtil; +import com.android.messaging.datamodel.action.ActionTestHelpers.StubLoader; + +import java.util.ArrayList; + +@MediumTest +public class ActionServiceSystemTest extends BugleServiceTestCase + implements ActionCompletedListener, ActionExecutedListener, FakeContextHost { + private static final String TAG = "ActionServiceSystemTest"; + + static { + // Set flag during loading of test cases to prevent application initialization starting + BugleTestCase.setTestsRunning(); + } + + @Override + public void onActionSucceeded(final ActionMonitor monitor, + final Action action, final Object data, final Object result) { + final TestChatAction test = (TestChatAction) action; + assertEquals("Expect correct action parameter", parameter, test.parameter); + final ResultTracker tracker = (ResultTracker) data; + tracker.completionResult = result; + synchronized(tracker) { + tracker.notifyAll(); + } + } + + @Override + public void onActionFailed(final ActionMonitor monitor, final Action action, + final Object data, final Object result) { + final TestChatAction test = (TestChatAction) action; + assertEquals("Expect correct action parameter", parameter, test.parameter); + final ResultTracker tracker = (ResultTracker) data; + tracker.completionResult = result; + synchronized(tracker) { + tracker.notifyAll(); + } + } + + @Override + public void onActionExecuted(final ActionMonitor monitor, final Action action, + final Object data, final Object result) { + final TestChatAction test = (TestChatAction) action; + assertEquals("Expect correct action parameter", parameter, test.parameter); + final ResultTracker tracker = (ResultTracker) data; + tracker.executionResult = result; + } + + public ActionServiceSystemTest() { + super(ActionServiceImpl.class); + } + + public void testChatActionSucceeds() { + final ResultTracker tracker = new ResultTracker(); + + final ActionService service = DataModel.get().getActionService(); + final TestChatActionMonitor monitor = new TestChatActionMonitor(null, tracker, this, this); + final TestChatAction initial = new TestChatAction(monitor.getActionKey(), parameter); + + assertNull("Expect completion result to start null", tracker.completionResult); + assertNull("Expect execution result to start null", tracker.executionResult); + + final Parcel parcel = Parcel.obtain(); + parcel.writeParcelable(initial, 0); + parcel.setDataPosition(0); + final TestChatAction action = parcel.readParcelable(mContext.getClassLoader()); + + synchronized(mWorker) { + try { + action.start(monitor); + // Wait for callback across threads + mWorker.wait(2000); + } catch (final InterruptedException e) { + assertTrue("Interrupted waiting for execution", false); + } + } + + assertEquals("Expect to see 1 server request queued", 1, + mWorker.getRequestsMade().size()); + final Action request = mWorker.getRequestsMade().get(0); + assertTrue("Expect Test type", request instanceof TestChatAction); + + final Bundle response = new Bundle(); + response.putString(TestChatAction.RESPONSE_TEST, processResponseResult); + synchronized(tracker) { + try { + request.markBackgroundWorkStarting(); + request.markBackgroundWorkQueued(); + + request.markBackgroundWorkStarting(); + request.markBackgroundCompletionQueued(); + service.handleResponseFromBackgroundWorker(request, response); + // Wait for callback across threads + tracker.wait(2000); + } catch (final InterruptedException e) { + assertTrue("Interrupted waiting for response processing", false); + } + } + + // TODO + //assertEquals("Expect execution result set", executeActionResult, tracker.executionResult); + assertEquals("Expect completion result set", processResponseResult, + tracker.completionResult); + } + + public void testChatActionFails() { + final ResultTracker tracker = new ResultTracker(); + + final ActionService service = DataModel.get().getActionService(); + final TestChatActionMonitor monitor = new TestChatActionMonitor(null, tracker, this, this); + final TestChatAction action = new TestChatAction(monitor.getActionKey(), parameter); + + assertNull("Expect completion result to start null", tracker.completionResult); + assertNull("Expect execution result to start null", tracker.executionResult); + + synchronized(mWorker) { + try { + action.start(monitor); + // Wait for callback across threads + mWorker.wait(2000); + } catch (final InterruptedException e) { + assertTrue("Interrupted waiting for requests", false); + } + } + + final ArrayList intents = mContext.extractIntents(); + assertNotNull(intents); + assertEquals("Expect to see one intent", intents.size(), 1); + + assertEquals("Expect to see 1 server request queued", 1, + mWorker.getRequestsMade().size()); + final Action request = mWorker.getRequestsMade().get(0); + assertTrue("Expect Test type", request instanceof TestChatAction); + + synchronized(tracker) { + try { + request.markBackgroundWorkStarting(); + request.markBackgroundWorkQueued(); + + request.markBackgroundWorkStarting(); + request.markBackgroundCompletionQueued(); + service.handleFailureFromBackgroundWorker(request, new Exception("It went wrong")); + // Wait for callback across threads + tracker.wait(2000); + } catch (final InterruptedException e) { + assertTrue("Interrupted waiting for response processing", false); + } + } + + // TODO + //assertEquals("Expect execution result set", executeActionResult, tracker.executionResult); + assertEquals("Expect completion result set", processFailureResult, + tracker.completionResult); + } + + public void testChatActionNoMonitor() { + final ActionService service = DataModel.get().getActionService(); + final TestChatAction action = + new TestChatAction(Action.generateUniqueActionKey(null), parameter); + + synchronized(mWorker) { + try { + action.start(); + // Wait for callback across threads + mWorker.wait(2000); + } catch (final InterruptedException e) { + assertTrue("Interrupted waiting for execution", false); + } + } + + assertEquals("Expect to see 1 server request queued", 1, + mWorker.getRequestsMade().size()); + Action request = mWorker.getRequestsMade().get(0); + assertTrue("Expect Test type", request instanceof TestChatAction); + + final Bundle response = new Bundle(); + response.putString(TestChatAction.RESPONSE_TEST, processResponseResult); + synchronized(mWorker) { + try { + service.handleResponseFromBackgroundWorker(request, response); + // Wait for callback across threads + mWorker.wait(2000); + } catch (final InterruptedException e) { + assertTrue("Interrupted waiting for response processing", false); + } + } + + assertEquals("Expect to see second server request queued", + 2, mWorker.getRequestsMade().size()); + request = mWorker.getRequestsMade().get(1); + assertTrue("Expect other type", + request instanceof TestChatActionOther); + } + + public void testChatActionUnregisterListener() { + final ResultTracker tracker = new ResultTracker(); + + final ActionService service = DataModel.get().getActionService(); + final TestChatActionMonitor monitor = new TestChatActionMonitor(null, tracker, this, this); + final TestChatAction action = new TestChatAction(monitor.getActionKey(), parameter); + + assertNull("Expect completion result to start null", tracker.completionResult); + assertNull("Expect execution result to start null", tracker.executionResult); + + synchronized(mWorker) { + try { + action.start(monitor); + // Wait for callback across threads + mWorker.wait(2000); + } catch (final InterruptedException e) { + assertTrue("Interrupted waiting for execution", false); + } + } + + assertEquals("Expect to see 1 server request queued", 1, + mWorker.getRequestsMade().size()); + final Action request = mWorker.getRequestsMade().get(0); + assertTrue("Expect Test type", request instanceof TestChatAction); + + monitor.unregister(); + + final Bundle response = new Bundle(); + synchronized(mWorker) { + try { + request.markBackgroundWorkStarting(); + request.markBackgroundWorkQueued(); + + request.markBackgroundWorkStarting(); + request.markBackgroundCompletionQueued(); + service.handleResponseFromBackgroundWorker(request, response); + // Wait for callback across threads + mWorker.wait(2000); + } catch (final InterruptedException e) { + assertTrue("Interrupted waiting for response processing", false); + } + } + + //assertEquals("Expect execution result set", executeActionResult, tracker.executionResult); + assertEquals("Expect completion never called", null, tracker.completionResult); + } + + StubBackgroundWorker mWorker; + FakeContext mContext; + StubLoader mLoader; + + private static final String parameter = "parameter"; + private static final Object executeActionResult = "executeActionResult"; + private static final String processResponseResult = "processResponseResult"; + private static final Object processFailureResult = "processFailureResult"; + + @Override + public void setUp() throws Exception { + super.setUp(); + Log.d(TAG, "ChatActionTest setUp"); + + mContext = new FakeContext(getContext(), this); + mWorker = new StubBackgroundWorker(); + FakeFactory.registerWithFakeContext(getContext(), mContext) + .withDataModel(new FakeDataModel(mContext) + .withBackgroundWorkerForActionService(mWorker) + .withActionService(new ActionService()) + .withConnectivityUtil(new StubConnectivityUtil(mContext))); + + mLoader = new StubLoader(); + setContext(Factory.get().getApplicationContext()); + } + + @Override + public String getServiceClassName() { + return ActionServiceImpl.class.getName(); + } + + @Override + public void startServiceForStub(final Intent intent) { + this.startService(intent); + } + + @Override + public void onStartCommandForStub(final Intent intent, final int flags, final int startId) { + this.getService().onStartCommand(intent, flags, startId); + } + + public static class TestChatAction extends Action implements Parcelable { + public static String RESPONSE_TEST = "response_test"; + public static String KEY_PARAMETER = "parameter"; + + protected TestChatAction(final String key, final String parameter) { + super(key); + this.actionParameters.putString(KEY_PARAMETER, parameter); + // Cache parameter as a member variable + this.parameter = parameter; + } + + // An example parameter + public final String parameter; + + /** + * Process the action locally - runs on datamodel service thread + */ + @Override + protected Object executeAction() { + requestBackgroundWork(); + return executeActionResult; + } + + /** + * Process the response from the server - runs on datamodel service thread + */ + @Override + protected Object processBackgroundResponse(final Bundle response) { + requestBackgroundWork(new TestChatActionOther(null, parameter)); + return response.get(RESPONSE_TEST); + } + + /** + * Called in case of failures when sending requests - runs on datamodel service thread + */ + @Override + protected Object processBackgroundFailure() { + return processFailureResult; + } + + private TestChatAction(final Parcel in) { + super(in); + // Cache parameter as a member variable + parameter = actionParameters.getString(KEY_PARAMETER); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public TestChatAction createFromParcel(final Parcel in) { + return new TestChatAction(in); + } + + @Override + public TestChatAction[] newArray(final int size) { + return new TestChatAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } + } + + public static class TestChatActionOther extends Action implements Parcelable { + protected TestChatActionOther(final String key, final String parameter) { + super(generateUniqueActionKey(key)); + this.parameter = parameter; + } + + public final String parameter; + + private TestChatActionOther(final Parcel in) { + super(in); + parameter = in.readString(); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public TestChatActionOther createFromParcel(final Parcel in) { + return new TestChatActionOther(in); + } + + @Override + public TestChatActionOther[] newArray(final int size) { + return new TestChatActionOther[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + parcel.writeString(parameter); + } + } + + /** + * An operation that notifies a listener upon completion + */ + public static class TestChatActionMonitor extends ActionMonitor { + /** + * Create action state wrapping an BlockUserAction instance + * @param account - account in which to block the user + * @param baseKey - suggested action key from BlockUserAction + * @param data - optional action specific data that is handed back to listener + * @param listener - action completed listener + */ + public TestChatActionMonitor(final String baseKey, final Object data, + final ActionCompletedListener completed, final ActionExecutedListener executed) { + super(STATE_CREATED, Action.generateUniqueActionKey(baseKey), data); + setCompletedListener(completed); + setExecutedListener(executed); + } + } +} diff --git a/tests/src/com/android/messaging/datamodel/action/ActionServiceTest.java b/tests/src/com/android/messaging/datamodel/action/ActionServiceTest.java new file mode 100644 index 0000000..02cddae --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/action/ActionServiceTest.java @@ -0,0 +1,275 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.Intent; +import android.os.Bundle; +import android.os.Looper; +import android.os.Parcel; +import android.os.Parcelable; +import android.os.Process; +import android.test.suitebuilder.annotation.MediumTest; +import android.util.Log; + +import com.android.messaging.Factory; +import com.android.messaging.FakeContext; +import com.android.messaging.FakeContext.FakeContextHost; +import com.android.messaging.FakeFactory; +import com.android.messaging.datamodel.BugleServiceTestCase; +import com.android.messaging.datamodel.FakeDataModel; +import com.android.messaging.datamodel.action.ActionMonitor.ActionCompletedListener; +import com.android.messaging.datamodel.action.ActionMonitor.ActionStateChangedListener; +import com.android.messaging.datamodel.action.ActionTestHelpers.ResultTracker; +import com.android.messaging.datamodel.action.ActionTestHelpers.StubBackgroundWorker; +import com.android.messaging.datamodel.action.ActionTestHelpers.StubConnectivityUtil; +import com.android.messaging.datamodel.action.ActionTestHelpers.StubLoader; +import com.android.messaging.util.WakeLockHelper; + +import java.util.ArrayList; + +@MediumTest +public class ActionServiceTest extends BugleServiceTestCase + implements FakeContextHost, ActionStateChangedListener, ActionCompletedListener { + private static final String TAG = "ActionServiceTest"; + + @Override + public void onActionStateChanged(final Action action, final int state) { + mStates.add(state); + } + + @Override + public void onActionSucceeded(final ActionMonitor monitor, + final Action action, final Object data, final Object result) { + final TestChatAction test = (TestChatAction) action; + assertNotSame(test.dontRelyOnMe, dontRelyOnMe); + // This will be true - but only briefly + assertEquals(test.dontRelyOnMe, becauseIChange); + + final ResultTracker tracker = (ResultTracker) data; + tracker.completionResult = result; + synchronized(tracker) { + tracker.notifyAll(); + } + } + + @Override + public void onActionFailed(final ActionMonitor monitor, final Action action, + final Object data, final Object result) { + final TestChatAction test = (TestChatAction) action; + assertNotSame(test.dontRelyOnMe, dontRelyOnMe); + // This will be true - but only briefly + assertEquals(test.dontRelyOnMe, becauseIChange); + + final ResultTracker tracker = (ResultTracker) data; + tracker.completionResult = result; + synchronized(tracker) { + tracker.notifyAll(); + } + } + + /** + * For a dummy action verify that the service intent is constructed and queued correctly and + * that when that intent is processed it actually executes the action. + */ + public void testChatServiceCreatesIntentAndExecutesAction() { + final ResultTracker tracker = new ResultTracker(); + + final TestChatActionMonitor monitor = new TestChatActionMonitor(null, tracker, this, this); + final TestChatAction action = new TestChatAction(monitor.getActionKey(), parameter); + + action.dontRelyOnMe = dontRelyOnMe; + assertFalse("Expect service initially stopped", mServiceStarted); + + action.start(monitor); + + assertTrue("Expect service started", mServiceStarted); + + final ArrayList intents = mContext.extractIntents(); + assertNotNull(intents); + assertEquals("Expect to see 1 server request queued", 1, intents.size()); + final Intent intent = intents.get(0); + assertEquals("Check pid", intent.getIntExtra(WakeLockHelper.EXTRA_CALLING_PID, 0), + Process.myPid()); + assertEquals("Check opcode", intent.getIntExtra(ActionServiceImpl.EXTRA_OP_CODE, 0), + ActionServiceImpl.OP_START_ACTION); + assertTrue("Check wakelock held", ActionServiceImpl.sWakeLock.isHeld(intent)); + + synchronized(tracker) { + try { + this.startService(intent); + // Wait for callback across threads + tracker.wait(2000); + } catch (final InterruptedException e) { + assertTrue("Interrupted waiting for response processing", false); + } + } + + assertEquals("Expect three states ", mStates.size(), 3); + assertEquals("State-0 should be STATE_QUEUED", (int)mStates.get(0), + ActionMonitor.STATE_QUEUED); + assertEquals("State-1 should be STATE_EXECUTING", (int)mStates.get(1), + ActionMonitor.STATE_EXECUTING); + assertEquals("State-2 should be STATE_COMPLETE", (int)mStates.get(2), + ActionMonitor.STATE_COMPLETE); + // TODO: Should find a way to reliably wait, this is a bit of a hack + if (ActionServiceImpl.sWakeLock.isHeld(intent)) { + Log.d(TAG, "ActionServiceTest: waiting for wakelock release"); + try { + Thread.sleep(100); + } catch (final InterruptedException e) { + } + } + assertFalse("Check wakelock released", ActionServiceImpl.sWakeLock.isHeld(intent)); + } + + StubBackgroundWorker mWorker; + FakeContext mContext; + StubLoader mLoader; + ActionService mService; + + ArrayList mStates; + + private static final String parameter = "parameter"; + private static final Object dontRelyOnMe = "dontRelyOnMe"; + private static final Object becauseIChange = "becauseIChange"; + private static final Object executeActionResult = "executeActionResult"; + private static final Object processResponseResult = "processResponseResult"; + private static final Object processFailureResult = "processFailureResult"; + + public ActionServiceTest() { + super(ActionServiceImpl.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + Log.d(TAG, "ChatActionTest setUp"); + + sLooper = Looper.myLooper(); + + mWorker = new StubBackgroundWorker(); + mContext = new FakeContext(getContext(), this); + FakeFactory.registerWithFakeContext(getContext(),mContext) + .withDataModel(new FakeDataModel(mContext) + .withBackgroundWorkerForActionService(mWorker) + .withActionService(new ActionService()) + .withConnectivityUtil(new StubConnectivityUtil(mContext))); + + mStates = new ArrayList(); + setContext(Factory.get().getApplicationContext()); + } + + @Override + public String getServiceClassName() { + return ActionServiceImpl.class.getName(); + } + + boolean mServiceStarted = false; + + @Override + public void startServiceForStub(final Intent intent) { + // Do nothing until later + assertFalse(mServiceStarted); + mServiceStarted = true; + } + + @Override + public void onStartCommandForStub(final Intent intent, final int flags, final int startId) { + assertTrue(mServiceStarted); + } + + private static Looper sLooper; + public static void assertRunsOnOtherThread() { + assertTrue (Looper.myLooper() != Looper.getMainLooper()); + assertTrue (Looper.myLooper() != sLooper); + } + + public static class TestChatAction extends Action implements Parcelable { + public static String RESPONSE_TEST = "response_test"; + public static String KEY_PARAMETER = "parameter"; + + protected TestChatAction(final String key, final String parameter) { + super(key); + this.actionParameters.putString(KEY_PARAMETER, parameter); + } + + transient Object dontRelyOnMe; + + /** + * Process the action locally - runs on service thread + */ + @Override + protected Object executeAction() { + this.dontRelyOnMe = becauseIChange; + assertRunsOnOtherThread(); + return executeActionResult; + } + + /** + * Process the response from the server - runs on service thread + */ + @Override + protected Object processBackgroundResponse(final Bundle response) { + assertRunsOnOtherThread(); + return processResponseResult; + } + + /** + * Called in case of failures when sending requests - runs on service thread + */ + @Override + protected Object processBackgroundFailure() { + assertRunsOnOtherThread(); + return processFailureResult; + } + + private TestChatAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public TestChatAction createFromParcel(final Parcel in) { + return new TestChatAction(in); + } + + @Override + public TestChatAction[] newArray(final int size) { + return new TestChatAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } + } + + /** + * An operation that notifies a listener upon state changes, execution and completion + */ + public static class TestChatActionMonitor extends ActionMonitor { + public TestChatActionMonitor(final String baseKey, final Object data, + final ActionStateChangedListener listener, final ActionCompletedListener executed) { + super(STATE_CREATED, Action.generateUniqueActionKey(baseKey), data); + setStateChangedListener(listener); + setCompletedListener(executed); + assertEquals("Initial state should be STATE_CREATED", mState, STATE_CREATED); + } + } +} diff --git a/tests/src/com/android/messaging/datamodel/action/ActionTest.java b/tests/src/com/android/messaging/datamodel/action/ActionTest.java new file mode 100644 index 0000000..aefa25e --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/action/ActionTest.java @@ -0,0 +1,324 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.test.suitebuilder.annotation.MediumTest; +import android.test.suitebuilder.annotation.SmallTest; + +import com.android.messaging.BugleTestCase; +import com.android.messaging.FakeFactory; +import com.android.messaging.datamodel.DataModelImpl; +import com.android.messaging.datamodel.action.ActionTestHelpers.StubChatActionMonitor; + +import java.util.ArrayList; + +@MediumTest +public class ActionTest extends BugleTestCase { + private static final String parameter = "parameter"; + private static final Object executeActionResult = "executeActionResult"; + private static final Object processResponseResult = "processResponseResult"; + private static final Object processFailureResult = "processFailureResult"; + + private static final String mActionKey = "TheActionKey"; + private static final Object mData = "PrivateData"; + private StubChatActionMonitor mMonitor; + private TestChatAction mAction; + + private ArrayList mTransitions; + + @Override + public void setUp() throws Exception { + super.setUp(); + FakeFactory.register(getTestContext()) + .withDataModel(new DataModelImpl(getContext())); + + mMonitor = new StubChatActionMonitor(ActionMonitor.STATE_CREATED, mActionKey, + mData); + mAction = new TestChatAction(mActionKey, parameter); + mTransitions = mMonitor.getTransitions(); + } + + private void verifyState(final int count, final int from, final int to) { + assertEquals(to, mMonitor.getState()); + assertEquals(mTransitions.size(), count); + verifyTransition(count-1, from , to); + } + + private void verifyTransition(final int index, final int from, final int to) { + assertTrue(mTransitions.size() > index); + assertEquals(mAction, mTransitions.get(index).action); + assertEquals(from, mTransitions.get(index).from); + assertEquals(to, mTransitions.get(index).to); + } + + @SmallTest + public void testActionStartTransitionsCorrectly() { + mMonitor.setState(ActionMonitor.STATE_CREATED); + + ActionMonitor.registerActionMonitor(mAction.actionKey, mMonitor); + assertTrue(ActionMonitor.sActionMonitors.containsKey(mAction.actionKey)); + assertTrue(ActionMonitor.sActionMonitors.containsValue(mMonitor)); + assertEquals(ActionMonitor.sActionMonitors.get(mAction.actionKey), mMonitor); + + mAction.markStart(); + assertEquals("After start state: STATE_QUEUED", ActionMonitor.STATE_QUEUED, + mMonitor.getState()); + verifyState(1, ActionMonitor.STATE_CREATED, ActionMonitor.STATE_QUEUED); + + ActionMonitor.unregisterActionMonitor(mAction.actionKey, mMonitor); + + assertFalse(ActionMonitor.sActionMonitors.containsKey(mAction.actionKey)); + assertFalse(ActionMonitor.sActionMonitors.containsValue(mMonitor)); + } + + @SmallTest + public void testActionStartAssertsFromIncorrectState() { + mMonitor.setState(ActionMonitor.STATE_UNDEFINED); + + ActionMonitor.registerActionMonitor(mAction.actionKey, mMonitor); + assertTrue(ActionMonitor.sActionMonitors.containsKey(mAction.actionKey)); + assertTrue(ActionMonitor.sActionMonitors.containsValue(mMonitor)); + assertEquals(ActionMonitor.sActionMonitors.get(mAction.actionKey), mMonitor); + + try { + mAction.markStart(); + fail("Expect assertion when starting from STATE_UNDEFINED"); + } catch (final IllegalStateException ex){ + } + ActionMonitor.unregisterActionMonitor(mAction.actionKey, mMonitor); + + assertFalse(ActionMonitor.sActionMonitors.containsKey(mAction.actionKey)); + assertFalse(ActionMonitor.sActionMonitors.containsValue(mMonitor)); + } + + public void testActionTransitionsEndToEndWithRequests() { + assertEquals("Start state: STATE_CREATED", ActionMonitor.STATE_CREATED, + mMonitor.getState()); + + ActionMonitor.registerActionMonitor(mAction.actionKey, mMonitor); + assertTrue(ActionMonitor.sActionMonitors.containsKey(mAction.actionKey)); + assertTrue(ActionMonitor.sActionMonitors.containsValue(mMonitor)); + assertEquals(ActionMonitor.sActionMonitors.get(mAction.actionKey), mMonitor); + + mAction.markStart(); + + verifyState(1, ActionMonitor.STATE_CREATED, ActionMonitor.STATE_QUEUED); + + mAction.markBeginExecute(); + + verifyState(2, ActionMonitor.STATE_QUEUED, ActionMonitor.STATE_EXECUTING); + + final Object result = mAction.executeAction(); + mAction.requestBackgroundWork(); + + assertEquals("Check executeAction result", result, executeActionResult); + + mAction.markEndExecute(result); + + verifyState(3, ActionMonitor.STATE_EXECUTING, + ActionMonitor.STATE_BACKGROUND_ACTIONS_QUEUED); + + mAction.markBackgroundWorkStarting(); + + verifyState(4, ActionMonitor.STATE_BACKGROUND_ACTIONS_QUEUED, + ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION); + + mAction.markBackgroundWorkQueued(); + + verifyState(5, ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION, + ActionMonitor.STATE_BACKGROUND_ACTIONS_QUEUED); + + mAction.markBackgroundWorkStarting(); + + verifyState(6, ActionMonitor.STATE_BACKGROUND_ACTIONS_QUEUED, + ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION); + + final Bundle response = null; + + mAction.markBackgroundCompletionQueued(); + + verifyState(7, ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION, + ActionMonitor.STATE_BACKGROUND_COMPLETION_QUEUED); + + assertTrue(ActionMonitor.sActionMonitors.containsKey(mAction.actionKey)); + assertTrue(ActionMonitor.sActionMonitors.containsValue(mMonitor)); + assertEquals(ActionMonitor.sActionMonitors.get(mAction.actionKey), mMonitor); + + mAction.processBackgroundWorkResponse(response); + + verifyTransition(7, ActionMonitor.STATE_BACKGROUND_COMPLETION_QUEUED, + ActionMonitor.STATE_PROCESSING_BACKGROUND_RESPONSE); + + verifyState(9, ActionMonitor.STATE_PROCESSING_BACKGROUND_RESPONSE, + ActionMonitor.STATE_COMPLETE); + + assertFalse(ActionMonitor.sActionMonitors.containsKey(mAction.actionKey)); + assertFalse(ActionMonitor.sActionMonitors.containsValue(mMonitor)); + } + + public void testActionTransitionsEndToEndFailsRequests() { + assertEquals("Start state: STATE_CREATED", ActionMonitor.STATE_CREATED, + mMonitor.getState()); + + ActionMonitor.registerActionMonitor(mAction.actionKey, mMonitor); + assertTrue(ActionMonitor.sActionMonitors.containsKey(mAction.actionKey)); + assertTrue(ActionMonitor.sActionMonitors.containsValue(mMonitor)); + assertEquals(ActionMonitor.sActionMonitors.get(mAction.actionKey), mMonitor); + + mAction.markStart(); + + verifyState(1, ActionMonitor.STATE_CREATED, ActionMonitor.STATE_QUEUED); + + mAction.markBeginExecute(); + + verifyState(2, ActionMonitor.STATE_QUEUED, ActionMonitor.STATE_EXECUTING); + + final Object result = mAction.executeAction(); + mAction.requestBackgroundWork(); + + assertEquals("Check executeAction result", result, executeActionResult); + + mAction.markEndExecute(result); + + verifyState(3, ActionMonitor.STATE_EXECUTING, + ActionMonitor.STATE_BACKGROUND_ACTIONS_QUEUED); + + mAction.markBackgroundWorkStarting(); + + verifyState(4, ActionMonitor.STATE_BACKGROUND_ACTIONS_QUEUED, + ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION); + + mAction.markBackgroundWorkQueued(); + + verifyState(5, ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION, + ActionMonitor.STATE_BACKGROUND_ACTIONS_QUEUED); + + mAction.markBackgroundWorkStarting(); + + verifyState(6, ActionMonitor.STATE_BACKGROUND_ACTIONS_QUEUED, + ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION); + + assertTrue(ActionMonitor.sActionMonitors.containsKey(mAction.actionKey)); + assertTrue(ActionMonitor.sActionMonitors.containsValue(mMonitor)); + assertEquals(ActionMonitor.sActionMonitors.get(mAction.actionKey), mMonitor); + + mAction.processBackgroundWorkFailure(); + + verifyState(7, ActionMonitor.STATE_EXECUTING_BACKGROUND_ACTION, + ActionMonitor.STATE_COMPLETE); + + assertFalse(ActionMonitor.sActionMonitors.containsKey(mAction.actionKey)); + assertFalse(ActionMonitor.sActionMonitors.containsValue(mMonitor)); + } + + public void testActionTransitionsEndToEndNoRequests() { + assertEquals("Start state: STATE_CREATED", ActionMonitor.STATE_CREATED, + mMonitor.getState()); + + ActionMonitor.registerActionMonitor(mAction.actionKey, mMonitor); + assertTrue(ActionMonitor.sActionMonitors.containsKey(mAction.actionKey)); + assertTrue(ActionMonitor.sActionMonitors.containsValue(mMonitor)); + assertEquals(ActionMonitor.sActionMonitors.get(mAction.actionKey), mMonitor); + + mAction.markStart(); + + verifyState(1, ActionMonitor.STATE_CREATED, ActionMonitor.STATE_QUEUED); + + mAction.markBeginExecute(); + + verifyState(2, ActionMonitor.STATE_QUEUED, ActionMonitor.STATE_EXECUTING); + + final Object result = mAction.executeAction(); + + assertEquals("Check executeAction result", result, executeActionResult); + + assertTrue(ActionMonitor.sActionMonitors.containsKey(mAction.actionKey)); + assertTrue(ActionMonitor.sActionMonitors.containsValue(mMonitor)); + assertEquals(ActionMonitor.sActionMonitors.get(mAction.actionKey), mMonitor); + + mAction.markEndExecute(result); + + verifyState(3, ActionMonitor.STATE_EXECUTING, + ActionMonitor.STATE_COMPLETE); + + assertFalse(ActionMonitor.sActionMonitors.containsKey(mAction.actionKey)); + assertFalse(ActionMonitor.sActionMonitors.containsValue(mMonitor)); + } + + public static class TestChatAction extends Action implements Parcelable { + protected TestChatAction(final String key, final String parameter) { + super(key); + this.parameter = parameter; + } + + public final String parameter; + + /** + * Process the action locally - runs on service thread + */ + @Override + protected Object executeAction() { + assertEquals("Check parameter", parameter, ActionTest.parameter); + return executeActionResult; + } + + /** + * Process the response from the server - runs on service thread + */ + @Override + protected Object processBackgroundResponse(final Bundle response) { + assertEquals("Check parameter", parameter, ActionTest.parameter); + return processResponseResult; + } + + /** + * Called in case of failures when sending requests - runs on service thread + */ + @Override + protected Object processBackgroundFailure() { + assertEquals("Check parameter", parameter, ActionTest.parameter); + return processFailureResult; + } + + private TestChatAction(final Parcel in) { + super(in); + parameter = in.readString(); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public TestChatAction createFromParcel(final Parcel in) { + return new TestChatAction(in); + } + + @Override + public TestChatAction[] newArray(final int size) { + return new TestChatAction[size]; + } + }; + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + parcel.writeString(parameter); + } + } +} diff --git a/tests/src/com/android/messaging/datamodel/action/ActionTestHelpers.java b/tests/src/com/android/messaging/datamodel/action/ActionTestHelpers.java new file mode 100644 index 0000000..d72a0f9 --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/action/ActionTestHelpers.java @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.Context; +import android.database.ContentObserver; +import android.net.Uri; +import android.os.Bundle; + +import com.android.messaging.util.ConnectivityUtil; + +import java.util.ArrayList; +import java.util.List; + +public class ActionTestHelpers { + private static final String TAG = "DataModelTestHelpers"; + + static class StubLoader extends ContentObserver { + ArrayList mUriList = new ArrayList(); + + StubLoader() { + super(null); + } + + public void clear() { + mUriList.clear(); + } + + @Override + public void onChange(final boolean selfChange) { + // Handle change. + mUriList.add(null); + } + + // Implement the onChange(boolean, Uri) method to take advantage of the new Uri argument. + // Only supported on platform 16 and above... + @Override + public void onChange(final boolean selfChange, final Uri uri) { + // Handle change. + mUriList.add(uri); + } + } + + static class StubBackgroundWorker extends BackgroundWorker { + public StubBackgroundWorker() { + super(); + mActions = new ArrayList(); + } + + ArrayList mActions; + public ArrayList getRequestsMade() { + return mActions; + } + + @Override + public void queueBackgroundWork(final List actions) { + mActions.addAll(actions); + + synchronized(this) { + this.notifyAll(); + } + } + } + + static class ResultTracker { + public Object executionResult; + public Object completionResult; + } + + static class StubChatActionMonitor extends ActionMonitor { + static public class StateTransition { + Action action; + int from; + int to; + public StateTransition(final Action action, final int from, final int to) { + this.action = action; + this.from = from; + this.to = to; + } + } + + private final ArrayList mTransitions; + public ArrayList getTransitions() { + return mTransitions; + } + + protected StubChatActionMonitor(final int initialState, final String actionKey, + final Object data) { + super(initialState, actionKey, data); + mTransitions = new ArrayList(); + } + + @Override + protected void updateState(final Action action, final int expectedState, + final int state) { + mTransitions.add(new StateTransition(action, mState, state)); + super.updateState(action, expectedState, state); + } + + public void setState(final int state) { + mState = state; + } + + public int getState() { + return mState; + } + } + + public static class StubActionService extends ActionService { + public static class StubActionServiceCallLog { + public final Action action; + public final Action request; + public final Bundle response; + public final Exception exception; + public final Action update; + + public StubActionServiceCallLog(final Action action, + final Action request, + final Bundle response, + final Exception exception, + final Action update) { + this.action = action; + this.request = request; + this.response = response; + this.exception = exception; + this.update = update; + } + } + + private final ArrayList mServiceCalls = + new ArrayList(); + + public ArrayList getCalls() { + return mServiceCalls; + } + + @Override + public void startAction(final Action action) { + mServiceCalls.add(new StubActionServiceCallLog(action, null, null, null, null)); + synchronized(this) { + this.notifyAll(); + } + } + + @Override + public void handleResponseFromBackgroundWorker(final Action request, + final Bundle response) { + mServiceCalls.add(new StubActionServiceCallLog(null, request, response, null, null)); + synchronized(this) { + this.notifyAll(); + } + } + + @Override + protected void handleFailureFromBackgroundWorker(final Action request, + final Exception exception) { + mServiceCalls.add(new StubActionServiceCallLog(null, request, null, exception, null)); + synchronized(this) { + this.notifyAll(); + } + } + } + + public static class StubConnectivityUtil extends ConnectivityUtil { + public StubConnectivityUtil(final Context context) { + super(context); + } + + @Override + public void registerForSignalStrength() { + } + + @Override + public void unregisterForSignalStrength() { + } + } +} diff --git a/tests/src/com/android/messaging/datamodel/action/GetOrCreateConversationActionTest.java b/tests/src/com/android/messaging/datamodel/action/GetOrCreateConversationActionTest.java new file mode 100644 index 0000000..b05b022 --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/action/GetOrCreateConversationActionTest.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.pm.ProviderInfo; +import android.database.Cursor; +import android.net.Uri; +import android.test.mock.MockContentProvider; +import android.test.suitebuilder.annotation.SmallTest; + +import com.android.messaging.BugleTestCase; +import com.android.messaging.FakeContext; +import com.android.messaging.FakeFactory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.FakeDataModel; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.action.ActionTestHelpers.StubActionService; +import com.android.messaging.datamodel.action.ActionTestHelpers.StubActionService.StubActionServiceCallLog; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionListener; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionMonitor; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.TestDataFactory; +import com.android.messaging.sms.MmsUtils; + +import org.mockito.Mock; + +import java.util.ArrayList; + +@SmallTest +public class GetOrCreateConversationActionTest extends BugleTestCase { + + @Mock GetOrCreateConversationActionListener mockListener; + + public void testGetOrCreateConversation() { + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final ArrayList recipients = new ArrayList(); + recipients.add("5551234567"); + recipients.add("5551234568"); + + // Generate a list of partially formed participants + final ArrayList participants = new + ArrayList(); + + + for (final String recipient : recipients) { + participants.add(ParticipantData.getFromRawPhoneBySystemLocale(recipient)); + } + + // Test that we properly stubbed the SMS provider to return a thread id + final long threadId = MmsUtils.getOrCreateThreadId(mContext, recipients); + assertEquals(TestDataFactory.SMS_MMS_THREAD_ID_CURSOR_VALUE, threadId); + + final String blankId = BugleDatabaseOperations.getExistingConversation(db, threadId, false); + assertNull("Conversation already exists", blankId); + + ArrayList calls = mService.getCalls(); + + GetOrCreateConversationActionMonitor monitor = + GetOrCreateConversationAction.getOrCreateConversation(participants, null, + mockListener); + + assertEquals("Failed to start service once for action", calls.size(), 1); + assertTrue("Action not GetOrCreateConversationAction", calls.get(0).action instanceof + GetOrCreateConversationAction); + + GetOrCreateConversationAction action = (GetOrCreateConversationAction) + calls.get(0).action; + + Object result = action.executeAction(); + + assertTrue(result instanceof String); + + // Make sure that we created a new conversation + assertEquals(TestDataFactory.NUM_TEST_CONVERSATIONS+1, Integer.parseInt((String)result)); + + // Now get the conversation that we just created again + monitor = GetOrCreateConversationAction.getOrCreateConversation(participants, null, + mockListener); + + calls = mService.getCalls(); + assertEquals("Failed to start service for second action", calls.size(), 2); + assertTrue("Action not GetOrCreateConversationAction", calls.get(1).action instanceof + GetOrCreateConversationAction); + action = (GetOrCreateConversationAction)calls.get(1).action; + result = action.executeAction(); + + assertTrue(result instanceof String); + + final String conversationId = (String) result; + + // Make sure that we found the same conversation id + assertEquals(TestDataFactory.NUM_TEST_CONVERSATIONS+1, Integer.parseInt((String)result)); + + final ArrayList conversationParticipants = + BugleDatabaseOperations.getParticipantsForConversation(db, conversationId); + + assertEquals("Participant count mismatch", recipients.size(), + conversationParticipants.size()); + for(final ParticipantData participant : conversationParticipants) { + assertTrue(recipients.contains(participant.getSendDestination())); + } + + final Uri conversationParticipantsUri = + MessagingContentProvider.buildConversationParticipantsUri(conversationId); + final Cursor cursor = mContext.getContentResolver().query(conversationParticipantsUri, + ParticipantData.ParticipantsQuery.PROJECTION, null, null, null); + + int countSelf = 0; + while(cursor.moveToNext()) { + final ParticipantData participant = ParticipantData.getFromCursor(cursor); + if (participant.isSelf()) { + countSelf++; + } else { + assertTrue(recipients.contains(participant.getSendDestination())); + } + } + cursor.close(); + assertEquals("Expect one self participant in conversations", 1, countSelf); + assertEquals("Cursor count mismatch", recipients.size(), cursor.getCount() - countSelf); + + final String realId = BugleDatabaseOperations.getExistingConversation(db, threadId, false); + assertEquals("Conversation already exists", realId, conversationId); + } + + private FakeContext mContext; + private StubActionService mService; + + @Override + public void setUp() throws Exception { + super.setUp(); + + mContext = new FakeContext(getTestContext()); + + final MockContentProvider mockProvider = new MockContentProvider() { + @Override + public Cursor query(final Uri uri, final String[] projection, final String selection, + final String[] selectionArgs, final String sortOrder) { + return TestDataFactory.getSmsMmsThreadIdCursor(); + } + }; + + mContext.addContentProvider("mms-sms", mockProvider); + final MessagingContentProvider provider = new MessagingContentProvider(); + final ProviderInfo providerInfo = new ProviderInfo(); + providerInfo.authority = MessagingContentProvider.AUTHORITY; + provider.attachInfo(mContext, providerInfo); + mContext.addContentProvider(MessagingContentProvider.AUTHORITY, provider); + + mService = new StubActionService(); + final FakeDataModel fakeDataModel = new FakeDataModel(mContext) + .withActionService(mService); + FakeFactory.registerWithFakeContext(getTestContext(), mContext) + .withDataModel(fakeDataModel); + provider.setDatabaseForTest(fakeDataModel.getDatabase()); + } +} diff --git a/tests/src/com/android/messaging/datamodel/action/ReadWriteDraftMessageActionTest.java b/tests/src/com/android/messaging/datamodel/action/ReadWriteDraftMessageActionTest.java new file mode 100644 index 0000000..0405c90 --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/action/ReadWriteDraftMessageActionTest.java @@ -0,0 +1,482 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.action; + +import android.content.ContentProvider; +import android.content.pm.ProviderInfo; +import android.database.Cursor; +import android.net.Uri; +import android.test.suitebuilder.annotation.SmallTest; +import android.text.TextUtils; + +import com.android.messaging.BugleTestCase; +import com.android.messaging.FakeContext; +import com.android.messaging.FakeFactory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.FakeDataModel; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.action.ActionTestHelpers.StubActionService; +import com.android.messaging.datamodel.action.ActionTestHelpers.StubActionService.StubActionServiceCallLog; +import com.android.messaging.datamodel.action.ActionTestHelpers.StubConnectivityUtil; +import com.android.messaging.datamodel.action.ReadDraftDataAction.ReadDraftDataActionListener; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.util.ContentType; + +import org.mockito.Mock; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; + +@SmallTest +public class ReadWriteDraftMessageActionTest extends BugleTestCase { + + @Mock ReadDraftDataActionListener mockListener; + + // TODO: Add test cases + // 1. Make sure drafts can include attachments and multiple parts + // 2. Make sure attachments get cleaned up appropriately + // 3. Make sure messageId and partIds not reused (currently each draft is a new message). + public void testWriteDraft() { + final String draftMessage = "draftMessage"; + final long threadId = 1234567; + final boolean senderBlocked = false; + final String participantNumber = "5551234567"; + + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final String conversationId = getOrCreateConversation(db, participantNumber, threadId, + senderBlocked); + final String selfId = getOrCreateSelfId(db); + + // Should clear/stub DB + final ArrayList calls = mService.getCalls(); + + final MessageData message = MessageData.createDraftSmsMessage(conversationId, selfId, + draftMessage); + + WriteDraftMessageAction.writeDraftMessage(conversationId, message); + + assertEquals("Failed to start service once for action", calls.size(), 1); + assertTrue("Action not SaveDraftMessageAction", + calls.get(0).action instanceof WriteDraftMessageAction); + + final Action save = calls.get(0).action; + + final Object result = save.executeAction(); + + assertTrue("Expect row number string as result", result instanceof String); + final String messageId = (String) result; + + // Should check DB + final MessageData actual = BugleDatabaseOperations.readMessage(db, messageId); + assertNotNull("Database missing draft", actual); + assertEquals("Draft text changed", draftMessage, actual.getMessageText()); + } + + private static String getOrCreateSelfId(final DatabaseWrapper db) { + db.beginTransaction(); + final String selfId = BugleDatabaseOperations.getOrCreateParticipantInTransaction(db, + ParticipantData.getSelfParticipant(ParticipantData.DEFAULT_SELF_SUB_ID)); + db.setTransactionSuccessful(); + db.endTransaction(); + return selfId; + } + + private static String getOrCreateConversation(final DatabaseWrapper db, + final String participantNumber, final long threadId, final boolean senderBlocked) { + final ArrayList participants = + new ArrayList(); + participants.add(ParticipantData.getFromRawPhoneBySystemLocale(participantNumber)); + + final String conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId, + senderBlocked, participants, false, false, null); + assertNotNull("No conversation", conversationId); + return conversationId; + } + + public void testReadDraft() { + final Object data = "data"; + final String draftMessage = "draftMessage"; + final long threadId = 1234567; + final boolean senderBlocked = false; + final String participantNumber = "5552345678"; + + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final String conversationId = getOrCreateConversation(db, participantNumber, threadId, + senderBlocked); + final String selfId = getOrCreateSelfId(db); + + // Should clear/stub DB + final ArrayList calls = mService.getCalls(); + + final MessageData message = MessageData.createDraftSmsMessage(conversationId, selfId, + draftMessage); + + BugleDatabaseOperations.updateDraftMessageData(db, conversationId, message, + BugleDatabaseOperations.UPDATE_MODE_ADD_DRAFT); + + final ActionMonitor monitor = + ReadDraftDataAction.readDraftData(conversationId, null, data, mockListener); + + assertEquals("Unexpected number of calls to service", 1, calls.size()); + assertTrue("Action not of type ReadDraftMessageAction", + calls.get(0).action instanceof ReadDraftDataAction); + + final Action read = calls.get(0).action; + + final Object result = read.executeAction(); + + assertTrue(result instanceof ReadDraftDataAction.DraftData); + final ReadDraftDataAction.DraftData draft = (ReadDraftDataAction.DraftData) result; + + assertEquals("Draft message text differs", draftMessage, draft.message.getMessageText()); + assertEquals("Draft self differs", selfId, draft.message.getSelfId()); + assertEquals("Draft conversation differs", conversationId, + draft.conversation.getConversationId()); + } + + public void testReadDraftForNewConversation() { + final Object data = "data"; + long threadId = 1234567; + final boolean senderBlocked = false; + long phoneNumber = 5557654567L; + final String notConversationId = "ThisIsNotValidConversationId"; + + final DatabaseWrapper db = DataModel.get().getDatabase(); + final String selfId = getOrCreateSelfId(db); + + // Unless set a new conversation should have a null draft message + final MessageData blank = BugleDatabaseOperations.readDraftMessageData(db, + notConversationId, selfId); + assertNull(blank); + + String conversationId = null; + do { + conversationId = BugleDatabaseOperations.getExistingConversation(db, + threadId, senderBlocked); + threadId++; + phoneNumber++; + } + while(!TextUtils.isEmpty(conversationId)); + + final ArrayList participants = + new ArrayList(); + participants.add(ParticipantData.getFromRawPhoneBySystemLocale(Long.toString(phoneNumber))); + + conversationId = BugleDatabaseOperations.getOrCreateConversation(db, threadId, + senderBlocked, participants, false, false, null); + assertNotNull("No conversation", conversationId); + + final MessageData actual = BugleDatabaseOperations.readDraftMessageData(db, conversationId, + selfId); + assertNull(actual); + + // Should clear/stub DB + final ArrayList calls = mService.getCalls(); + + final ActionMonitor monitor = + ReadDraftDataAction.readDraftData(conversationId, null, data, mockListener); + + assertEquals("Unexpected number of calls to service", 1, calls.size()); + assertTrue("Action not of type ReadDraftMessageAction", + calls.get(0).action instanceof ReadDraftDataAction); + + final Action read = calls.get(0).action; + + final Object result = read.executeAction(); + + assertTrue(result instanceof ReadDraftDataAction.DraftData); + final ReadDraftDataAction.DraftData draft = (ReadDraftDataAction.DraftData) result; + + assertEquals("Draft message text differs", "", draft.message.getMessageText()); + assertEquals("Draft self differs", selfId, draft.message.getSelfId()); + assertEquals("Draft conversation differs", conversationId, + draft.conversation.getConversationId()); + } + + public void testWriteAndReadDraft() { + final Object data = "data"; + final String draftMessage = "draftMessage"; + + final DatabaseWrapper db = DataModel.get().getDatabase(); + final Cursor conversations = db.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { ConversationColumns._ID, ConversationColumns.CURRENT_SELF_ID }, null, + null, null /* groupBy */, null /* having */, null /* orderBy */); + + if (conversations.moveToFirst()) { + final String conversationId = conversations.getString(0); + final String selfId = getOrCreateSelfId(db); + + // Should clear/stub DB + final ArrayList calls = mService.getCalls(); + + final MessageData message = MessageData.createDraftSmsMessage(conversationId, selfId, + draftMessage); + + WriteDraftMessageAction.writeDraftMessage(conversationId, message); + + assertEquals("Failed to start service once for action", calls.size(), 1); + assertTrue("Action not SaveDraftMessageAction", + calls.get(0).action instanceof WriteDraftMessageAction); + + final Action save = calls.get(0).action; + + Object result = save.executeAction(); + + assertTrue("Expect row number string as result", result instanceof String); + + // Should check DB + + final ActionMonitor monitor = + ReadDraftDataAction.readDraftData(conversationId, null, data, + mockListener); + + assertEquals("Expect two calls queued", 2, calls.size()); + assertTrue("Expect action", calls.get(1).action instanceof ReadDraftDataAction); + + final Action read = calls.get(1).action; + + result = read.executeAction(); + + assertTrue(result instanceof ReadDraftDataAction.DraftData); + final ReadDraftDataAction.DraftData draft = (ReadDraftDataAction.DraftData) result; + + assertEquals("Draft message text differs", draftMessage, draft.message.getMessageText()); + // The conversation's self id is used as the draft's self id. + assertEquals("Draft self differs", conversations.getString(1), + draft.message.getSelfId()); + assertEquals("Draft conversation differs", conversationId, + draft.conversation.getConversationId()); + } else { + fail("No conversations in database"); + } + } + + public void testUpdateDraft() { + final String initialMessage = "initialMessage"; + final String draftMessage = "draftMessage"; + final long threadId = 1234567; + final boolean senderBlocked = false; + final String participantNumber = "5553456789"; + + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final String conversationId = getOrCreateConversation(db, participantNumber, threadId, + senderBlocked); + final String selfId = getOrCreateSelfId(db); + + final ArrayList calls = mService.getCalls(); + + // Insert initial message + MessageData initial = MessageData.createDraftSmsMessage(conversationId, selfId, + initialMessage); + + BugleDatabaseOperations.updateDraftMessageData(db, conversationId, initial, + BugleDatabaseOperations.UPDATE_MODE_ADD_DRAFT); + + initial = BugleDatabaseOperations.readDraftMessageData(db, + conversationId, selfId); + assertEquals("Initial text mismatch", initialMessage, initial.getMessageText()); + + // Now update the draft + final MessageData message = MessageData.createDraftSmsMessage(conversationId, selfId, + draftMessage); + WriteDraftMessageAction.writeDraftMessage(conversationId, message); + + assertEquals("Failed to start service once for action", calls.size(), 1); + assertTrue("Action not SaveDraftMessageAction", + calls.get(0).action instanceof WriteDraftMessageAction); + + final Action save = calls.get(0).action; + + final Object result = save.executeAction(); + + assertTrue("Expect row number string as result", result instanceof String); + + // Check DB + final MessageData actual = BugleDatabaseOperations.readDraftMessageData(db, + conversationId, selfId); + assertNotNull("Database missing draft", actual); + assertEquals("Draft text mismatch", draftMessage, actual.getMessageText()); + assertNull("Draft messageId should be null", actual.getMessageId()); + } + + public void testBugleDatabaseDraftOperations() { + final String initialMessage = "initialMessage"; + final String draftMessage = "draftMessage"; + final long threadId = 1234599; + final boolean senderBlocked = false; + final String participantNumber = "5553456798"; + final String subject = "subject here"; + + final DatabaseWrapper db = DataModel.get().getDatabase(); + + final String conversationId = getOrCreateConversation(db, participantNumber, threadId, + senderBlocked); + final String selfId = getOrCreateSelfId(db); + + final String text = "This is some text"; + final Uri mOutputUri = MediaScratchFileProvider.buildMediaScratchSpaceUri("txt"); + OutputStream outputStream = null; + try { + outputStream = mContext.getContentResolver().openOutputStream(mOutputUri); + outputStream.write(text.getBytes()); + } catch (final FileNotFoundException e) { + fail("Cannot open output file"); + } catch (final IOException e) { + fail("Cannot write output file"); + } + + final MessageData initial = + MessageData.createDraftMmsMessage(conversationId, selfId, initialMessage, subject); + initial.addPart(MessagePartData.createMediaMessagePart(ContentType.MULTIPART_MIXED, + mOutputUri, 0, 0)); + + final String initialMessageId = BugleDatabaseOperations.updateDraftMessageData(db, + conversationId, initial, BugleDatabaseOperations.UPDATE_MODE_ADD_DRAFT); + assertNotNull(initialMessageId); + + final MessageData initialDraft = BugleDatabaseOperations.readMessage(db, initialMessageId); + assertNotNull(initialDraft); + int cnt = 0; + for(final MessagePartData part : initialDraft.getParts()) { + if (part.isAttachment()) { + assertEquals(part.getContentUri(), mOutputUri); + } else { + assertEquals(part.getText(), initialMessage); + } + cnt++; + } + assertEquals("Wrong number of parts", 2, cnt); + + InputStream inputStream = null; + try { + inputStream = mContext.getContentResolver().openInputStream(mOutputUri); + final byte[] buffer = new byte[256]; + final int read = inputStream.read(buffer); + assertEquals(read, text.getBytes().length); + } catch (final FileNotFoundException e) { + fail("Cannot open input file"); + } catch (final IOException e) { + fail("Cannot read input file"); + } + + final String moreText = "This is some more text"; + final Uri mAnotherUri = MediaScratchFileProvider.buildMediaScratchSpaceUri("txt"); + outputStream = null; + try { + outputStream = mContext.getContentResolver().openOutputStream(mAnotherUri); + outputStream.write(moreText.getBytes()); + } catch (final FileNotFoundException e) { + fail("Cannot open output file"); + } catch (final IOException e) { + fail("Cannot write output file"); + } + + final MessageData another = + MessageData.createDraftMmsMessage(conversationId, selfId, draftMessage, subject); + another.addPart(MessagePartData.createMediaMessagePart(ContentType.MMS_MULTIPART_MIXED, + mAnotherUri, 0, 0)); + + final String anotherMessageId = BugleDatabaseOperations.updateDraftMessageData(db, + conversationId, another, BugleDatabaseOperations.UPDATE_MODE_ADD_DRAFT); + assertNotNull(anotherMessageId); + + final MessageData anotherDraft = BugleDatabaseOperations.readMessage(db, anotherMessageId); + assertNotNull(anotherDraft); + cnt = 0; + for(final MessagePartData part : anotherDraft.getParts()) { + if (part.isAttachment()) { + assertEquals(part.getContentUri(), mAnotherUri); + } else { + assertEquals(part.getText(), draftMessage); + } + cnt++; + } + assertEquals("Wrong number of parts", 2, cnt); + + inputStream = null; + try { + inputStream = mContext.getContentResolver().openInputStream(mOutputUri); + assertNull("Original draft content should have been deleted", inputStream); + } catch (final FileNotFoundException e) { + } + inputStream = null; + try { + inputStream = mContext.getContentResolver().openInputStream(mAnotherUri); + final byte[] buffer = new byte[256]; + final int read = inputStream.read(buffer); + assertEquals(read, moreText.getBytes().length); + } catch (final FileNotFoundException e) { + fail("Cannot open input file"); + } catch (final IOException e) { + fail("Cannot read input file"); + } + + final MessageData last = null; + final String lastPartId = BugleDatabaseOperations.updateDraftMessageData(db, + conversationId, last, BugleDatabaseOperations.UPDATE_MODE_ADD_DRAFT); + assertNull(lastPartId); + + inputStream = null; + try { + inputStream = mContext.getContentResolver().openInputStream(mAnotherUri); + assertNull("Original draft content should have been deleted", inputStream); + } catch (final FileNotFoundException e) { + } + + } + + private StubActionService mService; + + @Override + public void setUp() throws Exception { + super.setUp(); + + final FakeContext context = new FakeContext(getTestContext()); + + final ContentProvider bugleProvider = new MessagingContentProvider(); + final ProviderInfo bugleProviderInfo = new ProviderInfo(); + bugleProviderInfo.authority = MessagingContentProvider.AUTHORITY; + bugleProvider.attachInfo(mContext, bugleProviderInfo); + context.addContentProvider(MessagingContentProvider.AUTHORITY, bugleProvider); + final ContentProvider mediaProvider = new MediaScratchFileProvider(); + final ProviderInfo mediaProviderInfo = new ProviderInfo(); + mediaProviderInfo.authority = MediaScratchFileProvider.AUTHORITY; + mediaProvider.attachInfo(mContext, mediaProviderInfo); + context.addContentProvider(MediaScratchFileProvider.AUTHORITY, mediaProvider); + + mService = new StubActionService(); + final FakeDataModel fakeDataModel = new FakeDataModel(context) + .withActionService(mService) + .withConnectivityUtil(new StubConnectivityUtil(context)); + FakeFactory.registerWithFakeContext(getTestContext(), context) + .withDataModel(fakeDataModel); + + } +} diff --git a/tests/src/com/android/messaging/datamodel/data/ConversationMessageDataTest.java b/tests/src/com/android/messaging/datamodel/data/ConversationMessageDataTest.java new file mode 100644 index 0000000..c6b9b20 --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/data/ConversationMessageDataTest.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.data; + +import android.test.suitebuilder.annotation.SmallTest; + +import com.android.messaging.BugleTestCase; +import com.android.messaging.FakeFactory; +import com.android.messaging.datamodel.FakeCursor; +import com.android.messaging.datamodel.data.ConversationMessageData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.ConversationMessageData.ConversationMessageViewColumns; + +@SmallTest +public class ConversationMessageDataTest extends BugleTestCase { + @Override + protected void setUp() throws Exception { + super.setUp(); + FakeFactory.register(getTestContext()); + } + + public void testBindFirstMessage() { + final FakeCursor testCursor = TestDataFactory.getConversationMessageCursor(); + final ConversationMessageData data = new ConversationMessageData(); + testCursor.moveToFirst(); + data.bind(testCursor); + // TODO: Add before checking in all bound fields... + assertEquals(testCursor.getAt(ConversationMessageViewColumns.STATUS, 0).equals( + MessageData.BUGLE_STATUS_INCOMING_COMPLETE), data.getIsIncoming()); + assertEquals(testCursor.getAt(ConversationMessageViewColumns.SENDER_PROFILE_PHOTO_URI, + 0), data.getSenderProfilePhotoUri()); + assertEquals(testCursor.getAt(ConversationMessageViewColumns.SENDER_FULL_NAME, 0), + data.getSenderFullName()); + } + + public void testBindTwice() { + final FakeCursor testCursor = TestDataFactory.getConversationMessageCursor(); + final ConversationMessageData data = new ConversationMessageData(); + testCursor.moveToPosition(1); + data.bind(testCursor); + assertEquals(TestDataFactory.getMessageText(testCursor, 1), data.getText()); + assertEquals(testCursor.getAt(ConversationMessageViewColumns.RECEIVED_TIMESTAMP, 1), + data.getReceivedTimeStamp()); + assertEquals(testCursor.getAt(ConversationMessageViewColumns.STATUS, 1).equals( + MessageData.BUGLE_STATUS_INCOMING_COMPLETE), data.getIsIncoming()); + assertEquals(testCursor.getAt(ConversationMessageViewColumns.SENDER_PROFILE_PHOTO_URI, + 1), data.getSenderProfilePhotoUri()); + assertEquals(testCursor.getAt(ConversationMessageViewColumns.SENDER_FULL_NAME, 1), + data.getSenderFullName()); + testCursor.moveToPosition(2); + data.bind(testCursor); + assertEquals(TestDataFactory.getMessageText(testCursor, 2), data.getText()); + assertEquals(testCursor.getAt(ConversationMessageViewColumns.RECEIVED_TIMESTAMP, 2), + data.getReceivedTimeStamp()); + assertEquals(testCursor.getAt(ConversationMessageViewColumns.STATUS, 2).equals( + MessageData.BUGLE_STATUS_INCOMING_COMPLETE), data.getIsIncoming()); + assertEquals(testCursor.getAt(ConversationMessageViewColumns.SENDER_PROFILE_PHOTO_URI, + 2), data.getSenderProfilePhotoUri()); + assertEquals(testCursor.getAt(ConversationMessageViewColumns.SENDER_FULL_NAME, 2), + data.getSenderFullName()); + } + + public void testMessageClustering() { + final FakeCursor testCursor = TestDataFactory.getConversationMessageCursor(); + final ConversationMessageData data = new ConversationMessageData(); + testCursor.moveToPosition(0); + data.bind(testCursor); + assertFalse(data.getCanClusterWithPreviousMessage()); + assertFalse(data.getCanClusterWithNextMessage()); + + testCursor.moveToPosition(1); + data.bind(testCursor); + assertFalse(data.getCanClusterWithPreviousMessage()); + assertFalse(data.getCanClusterWithNextMessage()); + + testCursor.moveToPosition(2); + data.bind(testCursor); + assertFalse(data.getCanClusterWithPreviousMessage()); + assertTrue(data.getCanClusterWithNextMessage()); // 2 and 3 can be clustered + testCursor.moveToPosition(3); + + data.bind(testCursor); + assertTrue(data.getCanClusterWithPreviousMessage()); // 2 and 3 can be clustered + assertFalse(data.getCanClusterWithNextMessage()); + } +} diff --git a/tests/src/com/android/messaging/datamodel/data/ConversationParticipantsDataTest.java b/tests/src/com/android/messaging/datamodel/data/ConversationParticipantsDataTest.java new file mode 100644 index 0000000..527a600 --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/data/ConversationParticipantsDataTest.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.data; + +import android.test.suitebuilder.annotation.SmallTest; + +import com.android.messaging.BugleTestCase; +import com.android.messaging.datamodel.FakeCursor; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.data.ConversationParticipantsData; +import com.android.messaging.datamodel.data.ParticipantData; + +@SmallTest +public class ConversationParticipantsDataTest extends BugleTestCase { + public void testBindParticipants() { + final FakeCursor testCursor = TestDataFactory.getConversationParticipantsCursor(); + final ConversationParticipantsData data = new ConversationParticipantsData(); + data.bind(testCursor); + + assertEquals(data.getParticipantListExcludingSelf().size(), testCursor.getCount()); + final ParticipantData participant2 = data.getParticipantById("2"); + assertNotNull(participant2); + assertEquals(participant2.getFirstName(), testCursor.getAt( + ParticipantColumns.FIRST_NAME, 1) ); + assertEquals(participant2.getSendDestination(), testCursor.getAt( + ParticipantColumns.SEND_DESTINATION, 1)); + } +} diff --git a/tests/src/com/android/messaging/datamodel/data/TestDataFactory.java b/tests/src/com/android/messaging/datamodel/data/TestDataFactory.java new file mode 100644 index 0000000..8527e2b --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/data/TestDataFactory.java @@ -0,0 +1,347 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.data; + +import android.content.ContentValues; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.provider.BaseColumns; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.Contacts; +import android.provider.MediaStore.Images.Media; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns; +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; +import com.android.messaging.datamodel.DatabaseHelper.PartColumns; +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; +import com.android.messaging.datamodel.FakeCursor; +import com.android.messaging.datamodel.data.ConversationListItemData.ConversationListViewColumns; +import com.android.messaging.datamodel.data.ConversationMessageData.ConversationMessageViewColumns; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.ContentType; + +import java.util.Arrays; +import java.util.List; + +/** + * A factory for fake objects that can be useful for multiple tests. + */ +public class TestDataFactory { + private final static String[] sConversationListCursorColumns = new String[] { + ConversationListViewColumns._ID, + ConversationListViewColumns.NAME, + ConversationListViewColumns.ICON, + ConversationListViewColumns.SNIPPET_TEXT, + ConversationListViewColumns.PREVIEW_URI, + ConversationListViewColumns.SORT_TIMESTAMP, + ConversationListViewColumns.READ, + ConversationListViewColumns.PREVIEW_CONTENT_TYPE, + ConversationListViewColumns.MESSAGE_STATUS, + }; + + private final static String[] sContactCursorColumns = new String[] { + Phone.CONTACT_ID, + Phone.DISPLAY_NAME_PRIMARY, + Phone.PHOTO_THUMBNAIL_URI, + Phone.NUMBER, + Phone.TYPE, + Phone.LABEL, + Phone.LOOKUP_KEY, + Phone._ID, + Phone.SORT_KEY_PRIMARY, + }; + + private final static String[] sFrequentContactCursorColumns = new String[] { + Contacts._ID, + Contacts.DISPLAY_NAME, + Contacts.PHOTO_URI, + Phone.LOOKUP_KEY, + }; + + private final static String[] sConversationMessageCursorColumns = new String[] { + ConversationMessageViewColumns._ID, + ConversationMessageViewColumns.CONVERSATION_ID, + ConversationMessageViewColumns.PARTICIPANT_ID, + ConversationMessageViewColumns.SENT_TIMESTAMP, + ConversationMessageViewColumns.RECEIVED_TIMESTAMP, + ConversationMessageViewColumns.STATUS, + ConversationMessageViewColumns.SENDER_FULL_NAME, + ConversationMessageViewColumns.SENDER_PROFILE_PHOTO_URI, + ConversationMessageViewColumns.PARTS_IDS, + ConversationMessageViewColumns.PARTS_CONTENT_TYPES, + ConversationMessageViewColumns.PARTS_CONTENT_URIS, + ConversationMessageViewColumns.PARTS_WIDTHS, + ConversationMessageViewColumns.PARTS_HEIGHTS, + ConversationMessageViewColumns.PARTS_TEXTS, + ConversationMessageViewColumns.PARTS_COUNT + }; + + private final static String[] sGalleryCursorColumns = new String[] { + Media._ID, + Media.DATA, + Media.WIDTH, + Media.HEIGHT, + Media.MIME_TYPE + }; + + public static FakeCursor getConversationListCursor() { + final Object[][] cursorData = new Object[][] { + new Object[] { Long.valueOf(1), "name1", "content://icon1", + "snippetText1", "content://snippetUri1", Long.valueOf(10), 1, + ContentType.IMAGE_JPEG, MessageData.BUGLE_STATUS_INCOMING_COMPLETE}, + new Object[] { Long.valueOf(2), "name2", "content://icon2", + "snippetText2", "content://snippetUri2", Long.valueOf(20) + 24*60*60*1000, + 0, ContentType.IMAGE_JPEG, MessageData.BUGLE_STATUS_INCOMING_COMPLETE}, + new Object[] { Long.valueOf(3), "name3", "content://icon3", + "snippetText3", "content://snippetUri3", Long.valueOf(30) + 2*24*60*60*1000, + 0, ContentType.IMAGE_JPEG, MessageData.BUGLE_STATUS_OUTGOING_COMPLETE} + }; + return new FakeCursor(ConversationListItemData.PROJECTION, sConversationListCursorColumns, + cursorData); + } + public static final int CONVERSATION_LIST_CURSOR_READ_MESSAGE_INDEX = 0; + public static final int CONVERSATION_LIST_CURSOR_UNREAD_MESSAGE_INDEX = 1; + + public static FakeCursor getEmptyConversationListCursor() { + return new FakeCursor(ConversationListItemData.PROJECTION, sConversationListCursorColumns, + new Object[][] {}); + } + + public static FakeCursor getConversationMessageCursor() { + final Object[][] cursorData = new Object[][] { + new Object[] { Long.valueOf(0), Long.valueOf(1), Long.valueOf(1), + Long.valueOf(10), Long.valueOf(10), + MessageData.BUGLE_STATUS_INCOMING_COMPLETE, "Alice", null, + "0", "text/plain", "''", -1, -1, "msg0", 1}, + new Object[] { Long.valueOf(1), Long.valueOf(1), Long.valueOf(2), + Long.valueOf(20), Long.valueOf(20), + MessageData.BUGLE_STATUS_OUTGOING_COMPLETE, "Bob", null, + "1", "text/plain", "''", -1, -1, "msg1", 1}, + new Object[] { Long.valueOf(2), Long.valueOf(1), Long.valueOf(1), + Long.valueOf(30), Long.valueOf(30), + MessageData.BUGLE_STATUS_OUTGOING_COMPLETE, "Alice", null, + "2", "contentType3", "'content://fakeUri3'", "0", "0", "msg1", 1}, + new Object[] { Long.valueOf(3), Long.valueOf(1), Long.valueOf(1), + Long.valueOf(40), Long.valueOf(40), + MessageData.BUGLE_STATUS_OUTGOING_COMPLETE, "Alice", null, + "3|4", "'contentType4'|'text/plain'", "'content://fakeUri4'|''", "0|-1", "0|-1", "''|'msg3'", 2}, + }; + return new FakeCursor( + ConversationMessageData.getProjection(), + sConversationMessageCursorColumns, + cursorData); + } + + public static String getMessageText(final FakeCursor messageCursor, final int row) { + final String allPartsText = messageCursor.getAt(ConversationMessageViewColumns.PARTS_TEXTS, row) + .toString(); + final int partsCount = (Integer) messageCursor.getAt( + ConversationMessageViewColumns.PARTS_COUNT, row); + final String messageId = messageCursor.getAt( + ConversationMessageViewColumns._ID, row).toString(); + final List parts = ConversationMessageData.makeParts( + messageCursor.getAt(ConversationMessageViewColumns.PARTS_IDS, row).toString(), + messageCursor.getAt(ConversationMessageViewColumns.PARTS_CONTENT_TYPES, row).toString(), + messageCursor.getAt(ConversationMessageViewColumns.PARTS_CONTENT_URIS, row).toString(), + messageCursor.getAt(ConversationMessageViewColumns.PARTS_WIDTHS, row).toString(), + messageCursor.getAt(ConversationMessageViewColumns.PARTS_HEIGHTS, row).toString(), + messageCursor.getAt(ConversationMessageViewColumns.PARTS_TEXTS, row).toString(), + partsCount, + messageId); + + for (final MessagePartData part : parts) { + if (part.isText()) { + return part.getText(); + } + } + return null; + } + + // Indexes where to find consecutive and non consecutive messages from same participant + // (respect to index - 1). + public static final int MESSAGE_WITH_SAME_PARTICIPANT_AS_PREVIOUS = 3; + public static final int MESSAGE_WITH_DIFFERENT_PARTICIPANT_AS_PREVIOUS = 2; + + public static FakeCursor getConversationParticipantsCursor() { + final String[] sConversationParticipantsCursorColumns = new String[] { + ParticipantColumns._ID, + ParticipantColumns.SUB_ID, + ParticipantColumns.NORMALIZED_DESTINATION, + ParticipantColumns.SEND_DESTINATION, + ParticipantColumns.FULL_NAME, + ParticipantColumns.FIRST_NAME, + ParticipantColumns.PROFILE_PHOTO_URI, + }; + + final Object[][] cursorData = new Object[][] { + new Object[] { 1, ParticipantData.OTHER_THAN_SELF_SUB_ID, "+15554567890", + "(555)456-7890", "alice in wonderland", "alice", "alice.png" }, + new Object[] { 2, ParticipantData.OTHER_THAN_SELF_SUB_ID, "+15551011121", + "(555)101-1121", "bob the baker", "bob", "bob.png"}, + new Object[] { 3, ParticipantData.OTHER_THAN_SELF_SUB_ID, "+15551314152", + "(555)131-4152", "charles in charge", "charles", "charles.png" }, + }; + + return new FakeCursor(ParticipantData.ParticipantsQuery.PROJECTION, + sConversationParticipantsCursorColumns, cursorData); + } + + public static final int CONTACT_LIST_CURSOR_FIRST_LEVEL_CONTACT_INDEX = 0; + public static final int CONTACT_LIST_CURSOR_SECOND_LEVEL_CONTACT_INDEX = 2; + + /** + * Returns a cursor for the all contacts list consumable by ContactPickerFragment. + */ + public static FakeCursor getAllContactListCursor() { + final Object[][] cursorData = new Object[][] { + new Object[] { Long.valueOf(0), "John Smith", "content://uri1", + "425-555-1234", Phone.TYPE_HOME, "", "0", Long.valueOf(0), 0 }, + new Object[] { Long.valueOf(1), "Sun Woo Kong", "content://uri2", + "425-555-1235", Phone.TYPE_MOBILE, "", "1", Long.valueOf(1), 1 }, + new Object[] { Long.valueOf(1), "Sun Woo Kong", "content://uri2", + "425-555-1238", Phone.TYPE_HOME, "", "1", Long.valueOf(2), 2 }, + new Object[] { Long.valueOf(2), "Anna Kinney", "content://uri3", + "425-555-1236", Phone.TYPE_MAIN, "", "3", Long.valueOf(3), 3 }, + new Object[] { Long.valueOf(3), "Mike Jones", "content://uri3", + "425-555-1236", Phone.TYPE_MAIN, "", "5", Long.valueOf(4), 4 }, + }; + return new FakeCursor(ContactUtil.PhoneQuery.PROJECTION, sContactCursorColumns, + cursorData); + } + + /** + * Returns a cursor for the frequent contacts list consumable by ContactPickerFragment. + * Note: make it so that this cursor is the generated result of getStrequentContactsCursor() + * and getAllContactListCursor(), i.e., expand the entries in getStrequentContactsCursor() + * with the details from getAllContactListCursor() + */ + public static FakeCursor getFrequentContactListCursor() { + final Object[][] cursorData = new Object[][] { + new Object[] { Long.valueOf(2), "Anna Kinney", "content://uri3", + "425-555-1236", Phone.TYPE_MAIN, "", "3", Long.valueOf(3), 0 }, + new Object[] { Long.valueOf(1), "Sun Woo Kong", "content://uri2", + "425-555-1235", Phone.TYPE_MOBILE, "", "1", Long.valueOf(1), 1}, + new Object[] { Long.valueOf(1), "Sun Woo Kong", "content://uri2", + "425-555-1238", Phone.TYPE_HOME, "", "1", Long.valueOf(2), 2 }, + new Object[] { Long.valueOf(0), "John Smith", "content://uri1", + "425-555-1234", Phone.TYPE_HOME, "", "0", Long.valueOf(0), 3 }, + }; + return new FakeCursor(ContactUtil.PhoneQuery.PROJECTION, sContactCursorColumns, + cursorData); + } + + /** + * Returns a strequent (starred + frequent) cursor (like the one produced by android contact + * provider's CONTENT_STREQUENT_URI query) that's consumable by FrequentContactsCursorBuilder. + */ + public static FakeCursor getStrequentContactsCursor() { + final Object[][] cursorData = new Object[][] { + new Object[] { Long.valueOf(0), "Anna Kinney", "content://uri1", "3" }, + new Object[] { Long.valueOf(1), "Sun Woo Kong", "content://uri2", "1" }, + new Object[] { Long.valueOf(2), "John Smith", "content://uri3", "0" }, + // Email-only entry that shouldn't be included in the result. + new Object[] { Long.valueOf(3), "Email Contact", "content://uri4", "100" }, + }; + return new FakeCursor(ContactUtil.FrequentContactQuery.PROJECTION, + sFrequentContactCursorColumns, cursorData); + } + + public static final int SMS_MMS_THREAD_ID_CURSOR_VALUE = 123456789; + + public static FakeCursor getSmsMmsThreadIdCursor() { + final String[] ID_PROJECTION = { BaseColumns._ID }; + final Object[][] cursorData = new Object[][] { + new Object[] { Long.valueOf(SMS_MMS_THREAD_ID_CURSOR_VALUE) }, + }; + return new FakeCursor(ID_PROJECTION, ID_PROJECTION, cursorData); + } + + public static FakeCursor getGalleryGridCursor() { + final Object[][] cursorData = new Object[][] { + new Object[] { Long.valueOf(0), "/sdcard/image1", 100, 100, "image/jpeg" }, + new Object[] { Long.valueOf(1), "/sdcard/image2", 200, 200, "image/png" }, + new Object[] { Long.valueOf(2), "/sdcard/image3", 300, 300, "image/jpeg" }, + }; + return new FakeCursor(GalleryGridItemData.IMAGE_PROJECTION, sGalleryCursorColumns, + cursorData); + } + + public static final int NUM_TEST_CONVERSATIONS = 10; + + /** + * Create test data in our db. + * + * Ideally this will create more realistic data with more variety. + */ + public static void createTestData(final SQLiteDatabase db) { + BugleDatabaseOperations.clearParticipantIdCache(); + + // Timestamp for 1 day ago + final long yesterday = System.currentTimeMillis() - (24 * 60 * 60 * 1000); + + final ContentValues conversationValues = new ContentValues(); + for (int i = 1; i <= NUM_TEST_CONVERSATIONS; i++) { + conversationValues.put(ConversationColumns.NAME, "Conversation " + i); + final long conversationId = db.insert(DatabaseHelper.CONVERSATIONS_TABLE, null, + conversationValues); + + final ContentValues messageValues = new ContentValues(); + for (int m = 1; m <= 25; m++) { + // Move forward ten minutes per conversation, 1 minute per message. + final long messageTime = yesterday + (i * 10 * 60 * 1000) + (m * 60 * 1000); + messageValues.put(MessageColumns.RECEIVED_TIMESTAMP, messageTime); + messageValues.put(MessageColumns.CONVERSATION_ID, conversationId); + messageValues.put(MessageColumns.SENDER_PARTICIPANT_ID, + Math.abs(("" + messageTime).hashCode()) % 2); + final long messageId = db.insert(DatabaseHelper.MESSAGES_TABLE, null, messageValues); + + // Create a text part for this message + final ContentValues partValues = new ContentValues(); + partValues.put(PartColumns.MESSAGE_ID, messageId); + partValues.put(PartColumns.CONVERSATION_ID, conversationId); + partValues.put(PartColumns.TEXT, "Conversation: " + conversationId + + " Message: " + m); + db.insert(DatabaseHelper.PARTS_TABLE, null, partValues); + + // Update the snippet for this conversation to the latest message inserted + conversationValues.clear(); + conversationValues.put(ConversationColumns.LATEST_MESSAGE_ID, messageId); + final int updatedCount = db.update(DatabaseHelper.CONVERSATIONS_TABLE, + conversationValues, + "_id=?", new String[]{String.valueOf(conversationId)}); + Assert.isTrue(updatedCount == 1); + } + } + } + + public static List getTestDraftAttachments() { + final MessagePartData[] retParts = new MessagePartData[] { + new MessagePartData(ContentType.IMAGE_JPEG, Uri.parse("content://image"), + 100, 100), + new MessagePartData(ContentType.VIDEO_3GPP, Uri.parse("content://video"), + 100, 100), + new MessagePartData(ContentType.TEXT_VCARD, Uri.parse("content://vcard"), + 0, 0), + new MessagePartData(ContentType.AUDIO_3GPP, Uri.parse("content://audio"), + 0, 0) + }; + return Arrays.asList(retParts); + } +} diff --git a/tests/src/com/android/messaging/datamodel/media/FakeImageRequest.java b/tests/src/com/android/messaging/datamodel/media/FakeImageRequest.java new file mode 100644 index 0000000..7f6ac3f --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/media/FakeImageRequest.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.text.TextUtils; + +import java.util.List; + +public class FakeImageRequest implements MediaRequest { + public static final String INVALID_KEY = "invalid"; + private final String mKey; + private final int mSize; + + public FakeImageRequest(final String key, final int size) { + mKey = key; + mSize = size; + } + + @Override + public String getKey() { + return mKey; + } + + @Override + public FakeImageResource loadMediaBlocking(List> chainedTask) + throws Exception { + if (TextUtils.equals(mKey, INVALID_KEY)) { + throw new Exception(); + } else { + return new FakeImageResource(mSize, mKey); + } + } + + @Override + public int getCacheId() { + return FakeMediaCacheManager.FAKE_IMAGE_CACHE; + } + + @SuppressWarnings("unchecked") + @Override + public MediaCache getMediaCache() { + return (MediaCache) MediaCacheManager.get().getOrCreateMediaCacheById( + getCacheId()); + } + + @Override + public int getRequestType() { + return MediaRequest.REQUEST_LOAD_MEDIA; + } + + @Override + public MediaRequestDescriptor getDescriptor() { + return null; + } +} diff --git a/tests/src/com/android/messaging/datamodel/media/FakeImageResource.java b/tests/src/com/android/messaging/datamodel/media/FakeImageResource.java new file mode 100644 index 0000000..969854f --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/media/FakeImageResource.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +public class FakeImageResource extends RefCountedMediaResource { + private boolean mClosed = false; + private boolean mCached = false; + private final int mSize; + private final String mImageId; + + public FakeImageResource(final int size, final String imageId) { + super(null); + mSize = size; + mImageId = imageId; + } + + public boolean isClosed() { + return mClosed; + } + + public String getImageId() { + return mImageId; + } + + public void setCached(final boolean cached) { + mCached = cached; + } + + public boolean getCached() { + return mCached; + } + + @Override + public int getMediaSize() { + return mSize; + } + + @Override + protected void close() { + mClosed = true; + } +} diff --git a/tests/src/com/android/messaging/datamodel/media/FakeMediaCacheManager.java b/tests/src/com/android/messaging/datamodel/media/FakeMediaCacheManager.java new file mode 100644 index 0000000..34b3a55 --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/media/FakeMediaCacheManager.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +public class FakeMediaCacheManager extends MediaCacheManager { + // List of available fake cache ids. + public static final int FAKE_IMAGE_CACHE = 1; + public static final int FAKE_BATCH_IMAGE_CACHE = 2; + + @Override + public MediaCache createMediaCacheById(final int id) { + switch (id) { + case FAKE_IMAGE_CACHE: + // Make a cache of only 3 KB of data. + return new MediaCache(3, FAKE_IMAGE_CACHE, "FakeImageCache"); + + case FAKE_BATCH_IMAGE_CACHE: + return new MediaCache(10, FAKE_BATCH_IMAGE_CACHE, + "FakeBatchImageCache"); + } + return null; + } +} diff --git a/tests/src/com/android/messaging/datamodel/media/ImageRequestTest.java b/tests/src/com/android/messaging/datamodel/media/ImageRequestTest.java new file mode 100644 index 0000000..2cfec7d --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/media/ImageRequestTest.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.ContentResolver; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.test.suitebuilder.annotation.SmallTest; + +import com.android.messaging.BugleTestCase; +import com.android.messaging.FakeFactory; +import com.android.messaging.R; +import com.android.messaging.datamodel.MemoryCacheManager; +import com.android.messaging.util.ImageUtils; + +import org.mockito.ArgumentCaptor; +import org.mockito.Matchers; +import org.mockito.Mockito; +import org.mockito.Spy; + +import java.io.IOException; + +@SmallTest +public class ImageRequestTest extends BugleTestCase { + private static final int DOWNSAMPLE_IMAGE_SIZE = 2; + + @Spy protected ImageUtils spyImageUtils; + + @Override + protected void setUp() throws Exception { + super.setUp(); + FakeFactory.register(getTestContext()) + .withMemoryCacheManager(new MemoryCacheManager()) + .withMediaCacheManager(new BugleMediaCacheManager()); + spyImageUtils = Mockito.spy(new ImageUtils()); + ImageUtils.set(spyImageUtils); + } + + public void testLoadImageUnspecifiedSize() { + final String uriString = ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + + getContext().getPackageName() + "/" + R.drawable.ic_audio_light; + final Uri uri = Uri.parse(uriString); + final UriImageRequest imageRequest = new UriImageRequest(getContext(), + new UriImageRequestDescriptor(uri)); + try { + final ImageResource imageResource = imageRequest.loadMediaBlocking(null); + final ArgumentCaptor options = + ArgumentCaptor.forClass(BitmapFactory.Options.class); + Mockito.verify(spyImageUtils).calculateInSampleSize( + options.capture(), + Matchers.eq(ImageRequest.UNSPECIFIED_SIZE), + Matchers.eq(ImageRequest.UNSPECIFIED_SIZE)); + assertEquals(1, options.getValue().inSampleSize); + assertNotNull(imageResource); + assertNotNull(imageResource.getBitmap()); + + // Make sure there's no scaling on the bitmap. + final int bitmapWidth = imageResource.getBitmap().getWidth(); + final int bitmapHeight = imageResource.getBitmap().getHeight(); + assertEquals(options.getValue().outWidth, bitmapWidth); + assertEquals(options.getValue().outHeight, bitmapHeight); + } catch (final IOException e) { + fail("IO exception while trying to load image resource"); + } + } + + public void testLoadImageWithDownsampling() { + final String uriString = ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + + getContext().getPackageName() + "/" + R.drawable.ic_audio_light; + final Uri uri = Uri.parse(uriString); + final UriImageRequest imageRequest = new UriImageRequest(getContext(), + new UriImageRequestDescriptor(uri, DOWNSAMPLE_IMAGE_SIZE, DOWNSAMPLE_IMAGE_SIZE, + false, true /* isStatic */, false /* cropToCircle */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */)); + try { + final ImageResource imageResource = imageRequest.loadMediaBlocking(null); + final ArgumentCaptor options = + ArgumentCaptor.forClass(BitmapFactory.Options.class); + Mockito.verify(spyImageUtils).calculateInSampleSize( + options.capture(), + Matchers.eq(DOWNSAMPLE_IMAGE_SIZE), Matchers.eq(DOWNSAMPLE_IMAGE_SIZE)); + assertNotSame(1, options.getValue().inSampleSize); + assertNotNull(imageResource); + assertNotNull(imageResource.getBitmap()); + + // Make sure there's down sampling on the bitmap. + final int bitmapWidth = imageResource.getBitmap().getWidth(); + final int bitmapHeight = imageResource.getBitmap().getHeight(); + assertTrue(bitmapWidth >= DOWNSAMPLE_IMAGE_SIZE && + bitmapHeight >= DOWNSAMPLE_IMAGE_SIZE && + (bitmapWidth <= DOWNSAMPLE_IMAGE_SIZE * 4 || + bitmapHeight <= DOWNSAMPLE_IMAGE_SIZE * 4)); + } catch (final IOException e) { + fail("IO exception while trying to load image resource"); + } + } +} diff --git a/tests/src/com/android/messaging/datamodel/media/MediaResourceManagerTest.java b/tests/src/com/android/messaging/datamodel/media/MediaResourceManagerTest.java new file mode 100644 index 0000000..d214067 --- /dev/null +++ b/tests/src/com/android/messaging/datamodel/media/MediaResourceManagerTest.java @@ -0,0 +1,174 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.test.suitebuilder.annotation.SmallTest; + +import com.android.messaging.BugleTestCase; +import com.android.messaging.FakeFactory; +import com.android.messaging.datamodel.MemoryCacheManager; +import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener; + +import java.util.concurrent.CountDownLatch; + +@SmallTest +public class MediaResourceManagerTest extends BugleTestCase { + private static final int KB = 1024; + + // Loaded image resource from the MediaResourceManager callback. + private FakeImageResource mImageResource; + private BindableMediaRequest mImageRequest; + + @Override + protected void setUp() throws Exception { + super.setUp(); + FakeFactory.register(getTestContext()) + .withMemoryCacheManager(new MemoryCacheManager()) + .withMediaCacheManager(new FakeMediaCacheManager()); + } + + public void testLoadFromCache() { + final MediaResourceManager mediaResourceManager = + new MediaResourceManager(); + MediaCacheManager.get().reclaim(); + assertNotNull(mediaResourceManager); + + // Load one image of 1KB + loadImage(mediaResourceManager, "image1", 1 * KB, false /* shouldBeCached */, false); + assertEquals("image1", mImageResource.getImageId()); + final FakeImageResource loadedResource = mImageResource; + + // Load the same image. + loadImage(mediaResourceManager, "image1", 1 * KB, true /* shouldBeCached */, false); + assertEquals(loadedResource, mImageResource); + } + + public void testCacheEviction() { + final MediaResourceManager mediaResourceManager = + new MediaResourceManager(); + MediaCacheManager.get().reclaim(); + assertNotNull(mediaResourceManager); + + // Load one image of 1KB + loadImage(mediaResourceManager, "image1", 1 * KB, false /* shouldBeCached */, false); + assertEquals("image1", mImageResource.getImageId()); + + // Load another image + loadImage(mediaResourceManager, "image2", 2 * KB, false /* shouldBeCached */, false); + assertEquals("image2", mImageResource.getImageId()); + + // Load another image. This should fill the cache and cause eviction of image1. + loadImage(mediaResourceManager, "image3", 2 * KB, false /* shouldBeCached */, false); + assertEquals("image3", mImageResource.getImageId()); + + // Load image1. It shouldn't be cached any more. + loadImage(mediaResourceManager, "image1", 1 * KB, false /* shouldBeCached */, false); + assertEquals("image1", mImageResource.getImageId()); + } + + public void testReclaimMemoryFromMediaCache() { + final MediaResourceManager mediaResourceManager = + new MediaResourceManager(); + MediaCacheManager.get().reclaim(); + assertNotNull(mediaResourceManager); + + // Load one image of 1KB + loadImage(mediaResourceManager, "image1", 1 * KB, false /* shouldBeCached */, false); + assertEquals("image1", mImageResource.getImageId()); + + // Purge everything from the cache, now the image should no longer be cached. + MediaCacheManager.get().reclaim(); + + // The image resource should have no ref left. + assertEquals(0, mImageResource.getRefCount()); + assertTrue(mImageResource.isClosed()); + loadImage(mediaResourceManager, "image1", 1 * KB, false /* shouldBeCached */, false); + assertEquals("image1", mImageResource.getImageId()); + } + + public void testLoadInvalidImage() { + final MediaResourceManager mediaResourceManager = + new MediaResourceManager(); + MediaCacheManager.get().reclaim(); + assertNotNull(mediaResourceManager); + + // Test the failure case with invalid resource. + loadImage(mediaResourceManager, FakeImageRequest.INVALID_KEY, 1 * KB, false, + true /* shouldFail */); + } + + public void testLoadImageSynchronously() { + final MediaResourceManager mediaResourceManager = + new MediaResourceManager(); + MediaCacheManager.get().reclaim(); + assertNotNull(mediaResourceManager); + + // Test a normal sync load. + final FakeImageRequest request = new FakeImageRequest("image1", 1 * KB); + final FakeImageResource resource = mediaResourceManager.requestMediaResourceSync(request); + assertNotNull(resource); + assertFalse(resource.isClosed()); + assertNotSame(0, resource.getRefCount()); + resource.release(); + + // Test a failed sync load. + final FakeImageRequest invalidRequest = + new FakeImageRequest(FakeImageRequest.INVALID_KEY, 1 * KB); + assertNull(mediaResourceManager.requestMediaResourceSync(invalidRequest)); + } + + private void loadImage(final MediaResourceManager manager, final String key, + final int size, final boolean shouldBeCached, final boolean shouldFail) { + try { + final CountDownLatch signal = new CountDownLatch(1); + mImageRequest = AsyncMediaRequestWrapper.createWith(new FakeImageRequest(key, size), + createAssertListener(shouldBeCached, shouldFail, signal)); + mImageRequest.bind("1"); + manager.requestMediaResourceAsync(mImageRequest); + + // Wait for the asynchronous callback before proceeding. + signal.await(); + } catch (final InterruptedException e) { + fail("Something interrupted the signal await."); + } + } + + private MediaResourceLoadListener createAssertListener( + final boolean shouldBeCached, final boolean shouldFail, final CountDownLatch signal) { + return new MediaResourceLoadListener() { + @Override + public void onMediaResourceLoaded(final MediaRequest request, + final FakeImageResource resource, final boolean isCached) { + assertEquals(mImageRequest, request); + assertNotNull(resource); + assertFalse(resource.isClosed()); + assertNotSame(0, resource.getRefCount()); + assertFalse(shouldFail); + assertEquals(shouldBeCached, resource.getCached()); + resource.setCached(true); + mImageResource = resource; + signal.countDown(); + } + + @Override + public void onMediaResourceLoadError( + final MediaRequest request, final Exception exception) { + assertTrue(shouldFail); + mImageResource = null; + signal.countDown(); + }}; + } +} diff --git a/tests/src/com/android/messaging/ui/ActivityInstrumentationTestCaseIntent.java b/tests/src/com/android/messaging/ui/ActivityInstrumentationTestCaseIntent.java new file mode 100644 index 0000000..5ea6aa7 --- /dev/null +++ b/tests/src/com/android/messaging/ui/ActivityInstrumentationTestCaseIntent.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; + + +/** + * Purpose of this class is providing a workaround for https://b/14561718 + */ +public class ActivityInstrumentationTestCaseIntent extends Intent { + public ActivityInstrumentationTestCaseIntent(Context packageContext, Class cls) { + super(packageContext, cls); + } + @Override + public Intent setComponent(ComponentName component) { + // Ignore the ComponentName set, as the one ActivityUnitTest does is wrong (and actually + // unnecessary). + return this; + } +} \ No newline at end of file diff --git a/tests/src/com/android/messaging/ui/BugleActivityInstrumentationTestCase.java b/tests/src/com/android/messaging/ui/BugleActivityInstrumentationTestCase.java new file mode 100644 index 0000000..60cddff --- /dev/null +++ b/tests/src/com/android/messaging/ui/BugleActivityInstrumentationTestCase.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.app.Activity; + +import com.android.messaging.BugleTestCase; +import com.android.messaging.TestUtil; + +/** + * Helper class that extends ActivityInstrumentationTestCase2 to provide some extra common + * initialization (eg. Mockito boilerplate). + */ +public class BugleActivityInstrumentationTestCase + extends android.test.ActivityInstrumentationTestCase2 { + + static { + // Set flag during loading of test cases to prevent application initialization starting + BugleTestCase.setTestsRunning(); + } + + public BugleActivityInstrumentationTestCase(final Class activityClass) { + super(activityClass); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + setActivityInitialTouchMode(false); + TestUtil.testSetup(getInstrumentation().getTargetContext(), this); + } + + @Override + protected void tearDown() throws Exception { + super.tearDown(); + TestUtil.testTeardown(this); + } +} \ No newline at end of file diff --git a/tests/src/com/android/messaging/ui/BugleActivityTest.java b/tests/src/com/android/messaging/ui/BugleActivityTest.java new file mode 100644 index 0000000..05e32fa --- /dev/null +++ b/tests/src/com/android/messaging/ui/BugleActivityTest.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import com.android.messaging.FakeFactory; +import com.android.messaging.datamodel.DataModel; + +import org.mockito.Mock; +import org.mockito.Mockito; + +public abstract class BugleActivityTest extends BugleActivityUnitTestCase { + @Mock protected DataModel mDataModel; + + public BugleActivityTest() { + super(BugleActionBarActivity.class); + } + + @SuppressWarnings("unchecked") + @Override + protected void setUp() throws Exception { + super.setUp(); + + // Create activity + final ActivityInstrumentationTestCaseIntent intent = + new ActivityInstrumentationTestCaseIntent(getInstrumentation().getTargetContext(), + TestActivity.class); + startActivity(intent, null, null); + + FakeFactory.register(getInstrumentation().getTargetContext()) + .withDataModel(mDataModel); + } + + public void testOnResumeDataModelCallback() { + getInstrumentation().callActivityOnStart(getActivity()); + getInstrumentation().callActivityOnResume(getActivity()); + Mockito.verify(mDataModel).onActivityResume(); + } +} diff --git a/tests/src/com/android/messaging/ui/BugleActivityUnitTestCase.java b/tests/src/com/android/messaging/ui/BugleActivityUnitTestCase.java new file mode 100644 index 0000000..dcbd785 --- /dev/null +++ b/tests/src/com/android/messaging/ui/BugleActivityUnitTestCase.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.app.Activity; +import android.view.ContextThemeWrapper; + +import com.android.messaging.BugleTestCase; +import com.android.messaging.R; +import com.android.messaging.TestUtil; + +/** + * Base class for activity unit test cases, provides boilerplate setup/teardown. + */ +public abstract class BugleActivityUnitTestCase extends + android.test.ActivityUnitTestCase { + + static { + // Set flag during loading of test cases to prevent application initialization starting + BugleTestCase.setTestsRunning(); + } + + public BugleActivityUnitTestCase(final Class activityClass) { + super(activityClass); + } + + @SuppressWarnings("unchecked") + @Override + protected void setUp() throws Exception { + super.setUp(); + TestUtil.testSetup(getInstrumentation().getTargetContext(), this); + + setActivityContext(new ContextThemeWrapper(getInstrumentation().getTargetContext(), + R.style.Theme_AppCompat_Light_DarkActionBar)); + } + + @Override + protected void tearDown() throws Exception { + super.tearDown(); + TestUtil.testTeardown(this); + } +} diff --git a/tests/src/com/android/messaging/ui/CustomHeaderViewPagerTest.java b/tests/src/com/android/messaging/ui/CustomHeaderViewPagerTest.java new file mode 100644 index 0000000..011cb82 --- /dev/null +++ b/tests/src/com/android/messaging/ui/CustomHeaderViewPagerTest.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.support.v4.view.ViewPager; +import android.view.View; +import android.view.ViewGroup; +import android.widget.SimpleCursorAdapter; + +import com.android.messaging.FakeFactory; +import com.android.messaging.R; + +public class CustomHeaderViewPagerTest extends ViewTest { + + @Override + protected void setUp() throws Exception { + super.setUp(); + FakeFactory.register(getInstrumentation().getTargetContext()); + } + + public void testBindFirstLevel() { + final CustomHeaderViewPager view = new CustomHeaderViewPager(getActivity(), null); + final SimpleCursorAdapter adapter = + new SimpleCursorAdapter(getActivity(), 0, null, null, null, 0); + final CustomHeaderPagerViewHolder[] viewHolders = { + new FakeListViewHolder(getActivity(), adapter), + new FakeListViewHolder(getActivity(), adapter) + }; + + view.setViewHolders(viewHolders); + final ViewPager pager = (ViewPager) view.findViewById(R.id.pager); + final ViewGroup tabStrip = (ViewGroup) view.findViewById(R.id.tab_strip); + final ViewPagerTabStrip realTab = (ViewPagerTabStrip) tabStrip.getChildAt(0); + + assertEquals(2, realTab.getChildCount()); + View headerTitleButton = realTab.getChildAt(1); + // Click on the first page. Now the view pager should switch to that page accordingly. + clickButton(headerTitleButton); + assertEquals(1, pager.getCurrentItem()); + } + + @Override + protected int getLayoutIdForView() { + // All set up should be done by creating a CustomHeaderViewPager which handles inflating + // the layout + return 0; + } +} diff --git a/tests/src/com/android/messaging/ui/FakeListViewHolder.java b/tests/src/com/android/messaging/ui/FakeListViewHolder.java new file mode 100644 index 0000000..d4de885 --- /dev/null +++ b/tests/src/com/android/messaging/ui/FakeListViewHolder.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui; + +import android.content.Context; +import android.widget.CursorAdapter; + +import com.android.messaging.R; + +/** + * A fake {@link CustomHeaderPagerListViewHolder} for CustomHeaderViewPager tests only. + */ +public class FakeListViewHolder extends CustomHeaderPagerListViewHolder { + public FakeListViewHolder(final Context context, final CursorAdapter adapter) { + super(context, adapter); + } + + @Override + protected int getLayoutResId() { + return 0; + } + + @Override + protected int getPageTitleResId() { + return android.R.string.untitled; + } + + @Override + protected int getEmptyViewResId() { + return R.id.empty_view; + } + + @Override + protected int getListViewResId() { + return android.R.id.list; + } + + @Override + protected int getEmptyViewTitleResId() { + return R.string.contact_list_empty_text; + } + + @Override + protected int getEmptyViewImageResId() { + return R.drawable.ic_oobe_freq_list; + } +} \ No newline at end of file diff --git a/tests/src/com/android/messaging/ui/FragmentTestCase.java b/tests/src/com/android/messaging/ui/FragmentTestCase.java new file mode 100644 index 0000000..eb65dc6 --- /dev/null +++ b/tests/src/com/android/messaging/ui/FragmentTestCase.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.app.Fragment; +import android.app.FragmentManager; +import android.content.res.Configuration; +import android.view.View; + +/** + * Helper class that extends Bugle.ui.ActivityInstrumentationTestCase to provide common behavior + * across fragment tests. + */ +public abstract class FragmentTestCase + extends BugleActivityInstrumentationTestCase { + + protected T mFragment; + protected Class mFragmentClass; + + public FragmentTestCase(final Class fragmentClass) { + super(TestActivity.class); + mFragmentClass = fragmentClass; + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + } + + protected T getFragment() { + // Fragment creation deferred (typically until test time) so that factory/appcontext is + // ready. + if (mFragment == null) { + try { + mFragment = mFragmentClass.newInstance(); + } catch (final InstantiationException e) { + throw new IllegalStateException("Failed to instantiate fragment"); + } catch (final IllegalAccessException e) { + throw new IllegalStateException("Failed to instantiate fragment"); + } + } + + return mFragment; + } + + protected void attachFragment() { + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + final FragmentManager fragmentManager = getActivity().getFragmentManager(); + fragmentManager.beginTransaction().add(mFragment, null /* tag */).commit(); + } + }); + + getInstrumentation().waitForIdleSync(); + } + + @Override + protected void tearDown() throws Exception { + // In landscape mode, sleep for a second first. + // The reason is: our UI tests don't wait for the UI thread to finish settling down + // before exiting (because they can't know when the UI thread is done). In portrait mode, + // things generally work fine here -- the UI thread is done by the time the test is done. + // In landscape mode, though, since the launcher is in portrait mode, there is a lot of + // extra work that happens in our UI when the app launches into landscape mode, and the + // UI is often not done by the time the test finishes running. So then our teardown + // nulls out the Factory, and then the UI keeps running and derefs the null factory, + // and things blow up. + // So ... as a cheap hack, sleep for one second before finishing the teardown of UI + // tests, but only do it in landscape mode (so that developers running it in portrait + // mode can still run the tests faster). + if (this.getInstrumentation().getTargetContext().getResources(). + getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) { + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + super.tearDown(); + } + + protected void clickButton(final View view) { + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + view.performClick(); + } + }); + getInstrumentation().waitForIdleSync(); + } + + protected void setFocus(final View view, final boolean focused) { + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + if (focused) { + view.requestFocus(); + } else { + view.clearFocus(); + } + } + }); + getInstrumentation().waitForIdleSync(); + } +} \ No newline at end of file diff --git a/tests/src/com/android/messaging/ui/MultiAttachmentLayoutTest.java b/tests/src/com/android/messaging/ui/MultiAttachmentLayoutTest.java new file mode 100644 index 0000000..cf9a647 --- /dev/null +++ b/tests/src/com/android/messaging/ui/MultiAttachmentLayoutTest.java @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + + +import android.content.Context; +import android.net.Uri; +import android.test.suitebuilder.annotation.MediumTest; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.android.messaging.FakeFactory; +import com.android.messaging.datamodel.data.MessagePartData; + +import java.util.Arrays; +import java.util.Collections; + +@MediumTest +public class MultiAttachmentLayoutTest extends ViewTest { + @Override + protected void setUp() throws Exception { + super.setUp(); + final Context context = getInstrumentation().getTargetContext(); + FakeFactory.register(context); + } + + @Override + protected MultiAttachmentLayout getView() { + if (mView == null) { + // View creation deferred (typically until test time) so that factory/appcontext is + // ready. + mView = new MultiAttachmentLayout(getActivity(), null); + mView.setLayoutParams(new ViewGroup.LayoutParams(100, 100)); + } + return mView; + } + + protected void verifyContent( + final MultiAttachmentLayout view, + final int imageCount, + final int plusCount) { + final int count = view.getChildCount(); + int actualImageCount = 0; + final boolean needPlusText = plusCount > 0; + boolean hasPlusText = false; + for (int i = 0; i < count; i++) { + final View child = view.getChildAt(i); + if (child instanceof AsyncImageView) { + actualImageCount++; + } else if (child instanceof TextView) { + assertTrue(plusCount > 0); + assertTrue(((TextView) child).getText().toString().contains("" + plusCount)); + hasPlusText = true; + } else { + // Nothing other than image and overflow text view should appear in this layout. + fail("unexpected view in layout. view = " + child); + } + } + assertEquals(imageCount, actualImageCount); + assertEquals(needPlusText, hasPlusText); + } + + public void testBindTwoAttachments() { + final MultiAttachmentLayout view = getView(); + final MessagePartData testAttachment1 = MessagePartData.createMediaMessagePart( + "image/jpeg", Uri.parse("content://uri1"), 100, 100); + final MessagePartData testAttachment2 = MessagePartData.createMediaMessagePart( + "image/jpeg", Uri.parse("content://uri2"), 100, 100); + + view.bindAttachments(createAttachmentList(testAttachment1, testAttachment2), + null /* transitionRect */, 2); + verifyContent(view, 2, 0); + } + + public void testBindFiveAttachments() { + final MultiAttachmentLayout view = getView(); + final MessagePartData testAttachment1 = MessagePartData.createMediaMessagePart( + "image/jpeg", Uri.parse("content://uri1"), 100, 100); + final MessagePartData testAttachment2 = MessagePartData.createMediaMessagePart( + "image/jpeg", Uri.parse("content://uri2"), 100, 100); + final MessagePartData testAttachment3 = MessagePartData.createMediaMessagePart( + "image/jpeg", Uri.parse("content://uri3"), 100, 100); + final MessagePartData testAttachment4 = MessagePartData.createMediaMessagePart( + "image/jpeg", Uri.parse("content://uri4"), 100, 100); + final MessagePartData testAttachment5 = MessagePartData.createMediaMessagePart( + "image/jpeg", Uri.parse("content://uri5"), 100, 100); + + view.bindAttachments(createAttachmentList(testAttachment1, testAttachment2, testAttachment3, + testAttachment4, testAttachment5), null /* transitionRect */, 5); + verifyContent(view, 4, 1); + } + + public void testBindTwice() { + // Put the above two tests together so we can simulate binding twice. + testBindTwoAttachments(); + testBindFiveAttachments(); + } + + private Iterable createAttachmentList(final MessagePartData... attachments) { + return Collections.unmodifiableList(Arrays.asList(attachments)); + } + + @Override + protected int getLayoutIdForView() { + return 0; // We construct the view with getView(). + } +} \ No newline at end of file diff --git a/tests/src/com/android/messaging/ui/ViewTest.java b/tests/src/com/android/messaging/ui/ViewTest.java new file mode 100644 index 0000000..c4e8431 --- /dev/null +++ b/tests/src/com/android/messaging/ui/ViewTest.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.view.View; + + +/** + * Base class for view tests. Derived class just has to provide a layout id. Tests can then just + * call getView() to get a created view and test its behavior. + */ +public abstract class ViewTest extends BugleActivityUnitTestCase { + public ViewTest() { + super(TestActivity.class); + } + + protected T mView; + + @SuppressWarnings("unchecked") + @Override + protected void setUp() throws Exception { + super.setUp(); + + // Create activity + final ActivityInstrumentationTestCaseIntent intent = + new ActivityInstrumentationTestCaseIntent(getInstrumentation().getTargetContext(), + TestActivity.class); + startActivity(intent, null, null); + } + + @SuppressWarnings("unchecked") + protected T getView() { + if (mView == null) { + // View creation deferred (typically until test time) so that factory/appcontext is + // ready. + mView = (T) getActivity().getLayoutInflater().inflate(getLayoutIdForView(), null); + } + return mView; + } + + protected void clickButton(final View view) { + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + view.performClick(); + } + }); + getInstrumentation().waitForIdleSync(); + } + + protected abstract int getLayoutIdForView(); +} diff --git a/tests/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserFragmentTest.java b/tests/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserFragmentTest.java new file mode 100644 index 0000000..30c711b --- /dev/null +++ b/tests/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserFragmentTest.java @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.attachmentchooser; + +import android.app.Fragment; +import android.test.suitebuilder.annotation.LargeTest; +import android.widget.CheckBox; + +import com.android.messaging.FakeFactory; +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.TestDataFactory; +import com.android.messaging.ui.FragmentTestCase; +import com.android.messaging.ui.TestActivity; +import com.android.messaging.ui.TestActivity.FragmentEventListener; +import com.android.messaging.ui.attachmentchooser.AttachmentChooserFragment; +import com.android.messaging.ui.attachmentchooser.AttachmentGridItemView; +import com.android.messaging.ui.attachmentchooser.AttachmentGridView; +import com.android.messaging.ui.attachmentchooser.AttachmentChooserFragment.AttachmentChooserFragmentHost; +import com.android.messaging.ui.conversationlist.ConversationListFragment; + +import org.mockito.ArgumentMatcher; +import org.mockito.Matchers; +import org.mockito.Mock; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + + +/** + * Unit tests for {@link ConversationListFragment}. + */ +@LargeTest +public class AttachmentChooserFragmentTest extends FragmentTestCase { + + @Mock protected DataModel mockDataModel; + @Mock protected DraftMessageData mockDraftMessageData; + @Mock protected AttachmentChooserFragmentHost mockHost; + + private static final String CONVERSATION_ID = "cid"; + + /** A custom argument matcher that checks whether the set argument passed in is a set + * with identical attachment data as the given set. + */ + private class IsSetOfGivenAttachments extends ArgumentMatcher> { + private final Set mGivenParts; + public IsSetOfGivenAttachments(final Set givenParts) { + mGivenParts = givenParts; + } + + @Override + public boolean matches(final Object set) { + @SuppressWarnings("unchecked") + final Set actualSet = (Set) set; + if (actualSet.size() != mGivenParts.size()) { + return false; + } + return mGivenParts.containsAll(actualSet) && actualSet.containsAll(mGivenParts); + } + } + + public AttachmentChooserFragmentTest() { + super(AttachmentChooserFragment.class); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + FakeFactory.register(this.getInstrumentation().getTargetContext()) + .withDataModel(mockDataModel); + } + + private void loadWith(final List attachments) { + Mockito.when(mockDraftMessageData.isBound(Matchers.anyString())) + .thenReturn(true); + Mockito.doReturn(mockDraftMessageData) + .when(mockDataModel) + .createDraftMessageData(Mockito.anyString()); + Mockito.doReturn(attachments) + .when(mockDraftMessageData) + .getReadOnlyAttachments(); + Mockito.when(mockDataModel.createDraftMessageData( + Matchers.anyString())) + .thenReturn(mockDraftMessageData); + + // Create fragment synchronously to avoid need for volatile, synchronization etc. + final AttachmentChooserFragment fragment = getFragment(); + // Binding to model happens when attaching fragment to activity, so hook into test + // activity to do so. + getActivity().setFragmentEventListener(new FragmentEventListener() { + @Override + public void onAttachFragment(final Fragment attachedFragment) { + if (fragment == attachedFragment) { + fragment.setConversationId(CONVERSATION_ID); + } + } + }); + + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + fragment.setHost(mockHost); + getActivity().setFragment(fragment); + Mockito.verify(mockDataModel).createDraftMessageData( + Mockito.matches(CONVERSATION_ID)); + Mockito.verify(mockDraftMessageData).loadFromStorage( + Matchers.eq(fragment.mBinding), Matchers.eq((MessageData) null), + Matchers.eq(false)); + } + }); + // Now load the cursor + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + fragment.onDraftChanged(mockDraftMessageData, DraftMessageData.ALL_CHANGED); + } + }); + getInstrumentation().waitForIdleSync(); + } + + public void testUnselect() { + final List attachments = TestDataFactory.getTestDraftAttachments(); + loadWith(attachments); + final AttachmentGridView attachmentGridView = (AttachmentGridView) + getFragment().getView().findViewById(R.id.grid); + assertEquals("bad view count", attachments.size(), + attachmentGridView.getAdapter().getCount()); + + final AttachmentGridItemView itemView = (AttachmentGridItemView) + attachmentGridView.getChildAt(0); + assertEquals(attachmentGridView, itemView.testGetHostInterface()); + final CheckBox checkBox = (CheckBox) itemView.findViewById(R.id.checkbox); + assertEquals(true, checkBox.isChecked()); + assertEquals(true, attachmentGridView.isItemSelected(itemView.mAttachmentData)); + clickButton(checkBox); + assertEquals(false, checkBox.isChecked()); + assertEquals(false, attachmentGridView.isItemSelected(itemView.mAttachmentData)); + + final AttachmentGridItemView itemView2 = (AttachmentGridItemView) + attachmentGridView.getChildAt(1); + final CheckBox checkBox2 = (CheckBox) itemView2.findViewById(R.id.checkbox); + clickButton(checkBox2); + + getFragment().confirmSelection(); + final MessagePartData[] attachmentsToRemove = new MessagePartData[] { + itemView.mAttachmentData, itemView2.mAttachmentData }; + Mockito.verify(mockDraftMessageData).removeExistingAttachments(Matchers.argThat( + new IsSetOfGivenAttachments(new HashSet<>(Arrays.asList(attachmentsToRemove))))); + Mockito.verify(mockDraftMessageData).saveToStorage(Matchers.eq(getFragment().mBinding)); + Mockito.verify(mockHost).onConfirmSelection(); + } +} diff --git a/tests/src/com/android/messaging/ui/contact/ContactListItemViewTest.java b/tests/src/com/android/messaging/ui/contact/ContactListItemViewTest.java new file mode 100644 index 0000000..de4c583 --- /dev/null +++ b/tests/src/com/android/messaging/ui/contact/ContactListItemViewTest.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.contact; + +import android.content.Context; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.Contacts; +import android.view.View; +import android.widget.TextView; + +import com.android.messaging.FakeFactory; +import com.android.messaging.R; +import com.android.messaging.datamodel.FakeCursor; +import com.android.messaging.datamodel.FakeDataModel; +import com.android.messaging.datamodel.data.ContactListItemData; +import com.android.messaging.datamodel.data.TestDataFactory; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.ui.ViewTest; + +import org.mockito.Mock; +import org.mockito.Mockito; + +public class ContactListItemViewTest extends ViewTest { + + @Mock ContactListItemView.HostInterface mockHost; + + @Override + protected void setUp() throws Exception { + super.setUp(); + final Context context = getInstrumentation().getTargetContext(); + FakeFactory.register(context) + .withDataModel(new FakeDataModel(context)); + } + + protected void verifyAddedContactForData(final ContactListItemData data, + final ContactListItemView view) { + Mockito.verify(mockHost).onContactListItemClicked(data, view); + } + + protected void verifyContent( + final ContactListItemView view, + final String contactName, + final String contactDetail, + final String avatarUrl, + final boolean showAvatar) { + final TextView contactNameView = (TextView) view.findViewById(R.id.contact_name); + final TextView contactDetailView = (TextView) view.findViewById(R.id.contact_details); + final ContactIconView avatarView = (ContactIconView) view.findViewById(R.id.contact_icon); + + assertNotNull(contactNameView); + assertEquals(contactName, contactNameView.getText()); + assertNotNull(contactDetail); + assertEquals(contactDetail, contactDetailView.getText()); + assertNotNull(avatarView); + if (showAvatar) { + assertTrue(avatarView.mImageRequestBinding.isBound()); + assertEquals(View.VISIBLE, avatarView.getVisibility()); + } else { + assertFalse(avatarView.mImageRequestBinding.isBound()); + assertEquals(View.INVISIBLE, avatarView.getVisibility()); + } + } + + public void testBindFirstLevel() { + final ContactListItemView view = getView(); + final FakeCursor cursor = TestDataFactory.getAllContactListCursor(); + final int row = TestDataFactory.CONTACT_LIST_CURSOR_FIRST_LEVEL_CONTACT_INDEX; + cursor.moveToPosition(row); + view.bind(cursor, mockHost, false, null); + verifyContent(view, (String) cursor.getAt(Contacts.DISPLAY_NAME, row), + (String) cursor.getAt(Phone.NUMBER, row), + (String) cursor.getAt(Contacts.PHOTO_THUMBNAIL_URI, row), true); + } + + public void testBindSecondLevel() { + final ContactListItemView view = getView(); + final FakeCursor cursor = TestDataFactory.getAllContactListCursor(); + final int row = TestDataFactory.CONTACT_LIST_CURSOR_SECOND_LEVEL_CONTACT_INDEX; + cursor.moveToPosition(row); + view.bind(cursor, mockHost, false, null); + verifyContent(view, (String) cursor.getAt(Contacts.DISPLAY_NAME, row), + (String) cursor.getAt(Phone.NUMBER, row), + (String) cursor.getAt(Contacts.PHOTO_THUMBNAIL_URI, row), false); + } + + public void testClickAddedContact() { + final ContactListItemView view = getView(); + final FakeCursor cursor = TestDataFactory.getAllContactListCursor(); + cursor.moveToFirst(); + + view.bind(cursor, mockHost, false, null); + view.performClick(); + verifyAddedContactForData(view.mData, view); + } + + public void testBindTwice() { + final ContactListItemView view = getView(); + final FakeCursor cursor = TestDataFactory.getAllContactListCursor(); + + cursor.moveToFirst(); + view.bind(cursor, mockHost, false, null); + + cursor.moveToNext(); + view.bind(cursor, mockHost, false, null); + verifyContent(view, (String) cursor.getAt(Contacts.DISPLAY_NAME, 1), + (String) cursor.getAt(Phone.NUMBER, 1), + (String) cursor.getAt(Contacts.PHOTO_THUMBNAIL_URI, 1), true); + } + + @Override + protected int getLayoutIdForView() { + return R.layout.contact_list_item_view; + } +} diff --git a/tests/src/com/android/messaging/ui/contact/ContactPickerFragmentTest.java b/tests/src/com/android/messaging/ui/contact/ContactPickerFragmentTest.java new file mode 100644 index 0000000..5b1503b --- /dev/null +++ b/tests/src/com/android/messaging/ui/contact/ContactPickerFragmentTest.java @@ -0,0 +1,221 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.contact; + +import android.content.Context; +import android.database.Cursor; +import android.support.v4.view.ViewPager; +import android.test.suitebuilder.annotation.LargeTest; +import android.view.View; +import android.widget.ListView; + +import com.android.messaging.FakeFactory; +import com.android.messaging.R; +import com.android.messaging.datamodel.FakeDataModel; +import com.android.messaging.datamodel.action.ActionTestHelpers; +import com.android.messaging.datamodel.action.ActionTestHelpers.StubActionService; +import com.android.messaging.datamodel.action.ActionTestHelpers.StubActionService.StubActionServiceCallLog; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction; +import com.android.messaging.datamodel.data.ContactPickerData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.TestDataFactory; +import com.android.messaging.ui.CustomHeaderViewPagerAdapter; +import com.android.messaging.ui.FragmentTestCase; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.contact.ContactPickerFragment.ContactPickerFragmentHost; + +import org.mockito.Matchers; +import org.mockito.Mock; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.List; + + +/** + * Unit tests for {@link ContactPickerFragment}. + */ +@LargeTest +public class ContactPickerFragmentTest + extends FragmentTestCase { + + @Mock protected ContactPickerData mMockContactPickerData; + @Mock protected UIIntents mMockUIIntents; + @Mock protected ContactPickerFragmentHost mockHost; + protected FakeDataModel mFakeDataModel; + private ActionTestHelpers.StubActionService mService; + + public ContactPickerFragmentTest() { + super(ContactPickerFragment.class); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + + final Context context = getInstrumentation().getTargetContext(); + mService = new StubActionService(); + mFakeDataModel = new FakeDataModel(context) + .withContactPickerData(mMockContactPickerData) + .withActionService(mService); + FakeFactory.register(context) + .withDataModel(mFakeDataModel) + .withUIIntents(mMockUIIntents); + } + + /** + * Helper method to initialize the ContactPickerFragment and its data. + */ + private ContactPickerFragmentTest initFragment(final int initialMode) { + Mockito.when(mMockContactPickerData.isBound(Matchers.anyString())) + .thenReturn(true); + + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + final ContactPickerFragment fragment = getFragment(); + fragment.setHost(mockHost); + fragment.setContactPickingMode(initialMode, false); + + getActivity().setFragment(fragment); + Mockito.verify(mMockContactPickerData).init(fragment.getLoaderManager(), + fragment.mBinding); + } + }); + getInstrumentation().waitForIdleSync(); + return this; + } + + /** + * Bind the datamodel with all contacts cursor to populate the all contacts list in the + * fragment. + */ + private ContactPickerFragmentTest loadWithAllContactsCursor(final Cursor cursor) { + Mockito.when(mMockContactPickerData.isBound(Matchers.anyString())) + .thenReturn(true); + + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + getFragment().onAllContactsCursorUpdated(cursor); + } + }); + getInstrumentation().waitForIdleSync(); + return this; + } + + /** + * Bind the datamodel with frequent contacts cursor to populate the contacts list in the + * fragment. + */ + private ContactPickerFragmentTest loadWithFrequentContactsCursor(final Cursor cursor) { + Mockito.when(mMockContactPickerData.isBound(Matchers.anyString())) + .thenReturn(true); + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + getFragment().onFrequentContactsCursorUpdated(cursor); + } + }); + getInstrumentation().waitForIdleSync(); + return this; + } + + /** + * Test the initial state of the fragment before loading data. + */ + public void testInitialState() { + initFragment(ContactPickerFragment.MODE_PICK_INITIAL_CONTACT); + + // Make sure that the frequent contacts view is shown by default. + final ViewPager pager = (ViewPager) getFragment().getView().findViewById(R.id.pager); + final View currentPagedView = pager.getChildAt(pager.getCurrentItem()); + final View frequentContactsView = ((CustomHeaderViewPagerAdapter) pager.getAdapter()) + .getViewHolder(0).getView(null); + assertEquals(frequentContactsView, currentPagedView); + } + + /** + * Verifies that list view gets correctly populated given a cursor. + */ + public void testLoadAllContactsList() { + final Cursor cursor = TestDataFactory.getAllContactListCursor(); + initFragment(ContactPickerFragment.MODE_PICK_INITIAL_CONTACT) + .loadWithAllContactsCursor(cursor); + final ListView listView = (ListView) getFragment().getView() + .findViewById(R.id.all_contacts_list); + assertEquals(cursor.getCount(), listView.getCount()); + } + + /** + * Verifies that list view gets correctly populated given a cursor. + */ + public void testLoadFrequentContactsList() { + final Cursor cursor = TestDataFactory.getFrequentContactListCursor(); + initFragment(ContactPickerFragment.MODE_PICK_INITIAL_CONTACT) + .loadWithFrequentContactsCursor(cursor); + final ListView listView = (ListView) getFragment().getView() + .findViewById(R.id.frequent_contacts_list); + assertEquals(cursor.getCount(), listView.getCount()); + } + + public void testPickInitialContact() { + final Cursor cursor = TestDataFactory.getFrequentContactListCursor(); + initFragment(ContactPickerFragment.MODE_PICK_INITIAL_CONTACT) + .loadWithFrequentContactsCursor(cursor); + final ListView listView = (ListView) getFragment().getView() + .findViewById(R.id.frequent_contacts_list); + // Click on the first contact to add it. + final ContactListItemView cliv = (ContactListItemView) listView.getChildAt(0); + clickButton(cliv); + final ContactRecipientAutoCompleteView chipsView = (ContactRecipientAutoCompleteView) + getFragment().getView() + .findViewById(R.id.recipient_text_view); + // Verify the contact is added to the chips view. + final List participants = + chipsView.getRecipientParticipantDataForConversationCreation(); + assertEquals(1, participants.size()); + assertEquals(cliv.mData.getDestination(), participants.get(0).getSendDestination()); + assertTrue(mService.getCalls().get(0).action instanceof GetOrCreateConversationAction); + } + + public void testLeaveChipsMode() { + final Cursor cursor = TestDataFactory.getFrequentContactListCursor(); + initFragment(ContactPickerFragment.MODE_CHIPS_ONLY) + .loadWithFrequentContactsCursor(cursor); + // Click on the add more participants button + // TODO: Figure out a way to click on the add more participants button now that + // it's part of the menu. + // final ImageButton AddMoreParticipantsButton = (ImageButton) getFragment().getView() + // .findViewById(R.id.add_more_participants_button); + // clickButton(AddMoreParticipantsButton); + // Mockito.verify(mockHost).onInitiateAddMoreParticipants(); + } + + public void testPickMoreContacts() { + final Cursor cursor = TestDataFactory.getFrequentContactListCursor(); + initFragment(ContactPickerFragment.MODE_PICK_MORE_CONTACTS) + .loadWithFrequentContactsCursor(cursor); + final ListView listView = (ListView) getFragment().getView() + .findViewById(R.id.frequent_contacts_list); + // Click on the first contact to add it. + final ContactListItemView cliv = (ContactListItemView) listView.getChildAt(0); + clickButton(cliv); + // Verify that we don't attempt to create a conversation right away. + assertEquals(0, mService.getCalls().size()); + } +} diff --git a/tests/src/com/android/messaging/ui/conversation/ComposeMessageViewTest.java b/tests/src/com/android/messaging/ui/conversation/ComposeMessageViewTest.java new file mode 100644 index 0000000..2dd2a89 --- /dev/null +++ b/tests/src/com/android/messaging/ui/conversation/ComposeMessageViewTest.java @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversation; + +import android.content.Context; +import android.media.MediaPlayer; +import android.test.suitebuilder.annotation.MediumTest; +import android.view.View; +import android.widget.EditText; + +import com.android.messaging.FakeFactory; +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.ConversationData; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.DraftMessageData.CheckDraftForSendTask; +import com.android.messaging.datamodel.data.DraftMessageData.CheckDraftTaskCallback; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.ui.ViewTest; +import com.android.messaging.ui.conversation.ComposeMessageView.IComposeMessageViewHost; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.FakeMediaUtil; +import com.android.messaging.util.ImeUtil; + +import org.mockito.ArgumentMatcher; +import org.mockito.Matchers; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import java.util.ArrayList; +import java.util.Collections; + +@MediumTest +public class ComposeMessageViewTest extends ViewTest { + private Context mContext; + + @Mock protected DataModel mockDataModel; + @Mock protected DraftMessageData mockDraftMessageData; + @Mock protected BugleGservices mockBugleGservices; + @Mock protected ImeUtil mockImeUtil; + @Mock protected IComposeMessageViewHost mockIComposeMessageViewHost; + @Mock protected MediaPlayer mockMediaPlayer; + @Mock protected ConversationInputManager mockInputManager; + @Mock protected ConversationData mockConversationData; + + Binding mConversationBinding; + + @Override + protected void setUp() throws Exception { + super.setUp(); + mContext = getInstrumentation().getTargetContext(); + FakeFactory.register(mContext) + .withDataModel(mockDataModel) + .withBugleGservices(mockBugleGservices) + .withMediaUtil(new FakeMediaUtil(mockMediaPlayer)); + + Mockito.doReturn(true).when(mockConversationData).isBound(Mockito.anyString()); + mConversationBinding = BindingBase.createBinding(this); + mConversationBinding.bind(mockConversationData); + } + + @Override + protected ComposeMessageView getView() { + final ComposeMessageView view = super.getView(); + view.setInputManager(mockInputManager); + view.setConversationDataModel(BindingBase.createBindingReference(mConversationBinding)); + return view; + } + + @Override + protected int getLayoutIdForView() { + return R.layout.compose_message_view; + } + + public void testSend() { + Mockito.when(mockDraftMessageData.getReadOnlyAttachments()) + .thenReturn(Collections.unmodifiableList(new ArrayList())); + Mockito.when(mockDraftMessageData.getIsDefaultSmsApp()).thenReturn(true); + Mockito.when(mockIComposeMessageViewHost.isReadyForAction()).thenReturn(true); + final ComposeMessageView view = getView(); + + final MessageData message = MessageData.createDraftSmsMessage("fake_id", "just_a_self_id", + "Sample Message"); + + Mockito.when(mockDraftMessageData.isBound(Matchers.anyString())) + .thenReturn(true); + Mockito.when(mockDraftMessageData.getMessageText()).thenReturn(message.getMessageText()); + Mockito.when(mockDraftMessageData.prepareMessageForSending( + Matchers.>any())) + .thenReturn(message); + Mockito.when(mockDraftMessageData.hasPendingAttachments()).thenReturn(false); + Mockito.doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + // Synchronously pass the draft check and callback. + ((CheckDraftTaskCallback)invocation.getArguments()[2]).onDraftChecked( + mockDraftMessageData, CheckDraftForSendTask.RESULT_PASSED); + return null; + } + }).when(mockDraftMessageData).checkDraftForAction(Mockito.anyBoolean(), Mockito.anyInt(), + Mockito.any(), + Mockito.>any()); + + view.bind(mockDraftMessageData, mockIComposeMessageViewHost); + + final EditText composeEditText = (EditText) view.findViewById(R.id.compose_message_text); + final View sendButton = view.findViewById(R.id.send_message_button); + + view.requestDraftMessage(false); + + Mockito.verify(mockDraftMessageData).loadFromStorage(Matchers.any(BindingBase.class), + Matchers.any(MessageData.class), Mockito.eq(false)); + + view.onDraftChanged(mockDraftMessageData, DraftMessageData.ALL_CHANGED); + + assertEquals(message.getMessageText(), composeEditText.getText().toString()); + + sendButton.performClick(); + Mockito.verify(mockIComposeMessageViewHost).sendMessage( + Mockito.argThat(new ArgumentMatcher() { + @Override + public boolean matches(final Object o) { + assertEquals(message.getMessageText(), ((MessageData) o).getMessageText()); + return true; + } + })); + } + + public void testNotDefaultSms() { + Mockito.when(mockDraftMessageData.getReadOnlyAttachments()) + .thenReturn(Collections.unmodifiableList(new ArrayList())); + Mockito.when(mockDraftMessageData.getIsDefaultSmsApp()).thenReturn(false); + Mockito.when(mockIComposeMessageViewHost.isReadyForAction()).thenReturn(false); + final ComposeMessageView view = getView(); + + final MessageData message = MessageData.createDraftSmsMessage("fake_id", "just_a_self_id", + "Sample Message"); + + Mockito.when(mockDraftMessageData.isBound(Matchers.anyString())) + .thenReturn(true); + Mockito.when(mockDraftMessageData.getMessageText()).thenReturn(message.getMessageText()); + Mockito.when(mockDraftMessageData.prepareMessageForSending( + Matchers.>any())) + .thenReturn(message); + Mockito.when(mockDraftMessageData.hasPendingAttachments()).thenReturn(false); + + view.bind(mockDraftMessageData, mockIComposeMessageViewHost); + + final EditText composeEditText = (EditText) view.findViewById(R.id.compose_message_text); + final View sendButton = view.findViewById(R.id.send_message_button); + + view.requestDraftMessage(false); + + Mockito.verify(mockDraftMessageData).loadFromStorage(Matchers.any(BindingBase.class), + Matchers.any(MessageData.class), Mockito.eq(false)); + + view.onDraftChanged(mockDraftMessageData, DraftMessageData.ALL_CHANGED); + + assertEquals(message.getMessageText(), composeEditText.getText().toString()); + + sendButton.performClick(); + Mockito.verify(mockIComposeMessageViewHost).warnOfMissingActionConditions( + Matchers.any(Boolean.class), Matchers.any(Runnable.class)); + } +} diff --git a/tests/src/com/android/messaging/ui/conversation/ConversationActivityUiStateTest.java b/tests/src/com/android/messaging/ui/conversation/ConversationActivityUiStateTest.java new file mode 100644 index 0000000..7c6903d --- /dev/null +++ b/tests/src/com/android/messaging/ui/conversation/ConversationActivityUiStateTest.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversation; + +import android.test.suitebuilder.annotation.SmallTest; +import android.text.TextUtils; + +import com.android.messaging.BugleTestCase; +import com.android.messaging.ui.contact.ContactPickerFragment; +import com.android.messaging.ui.conversation.ConversationActivityUiState; +import com.android.messaging.ui.conversation.ConversationActivityUiState.ConversationActivityUiStateHost; + +import org.mockito.Mock; +import org.mockito.Mockito; + +@SmallTest +public class ConversationActivityUiStateTest extends BugleTestCase { + @Mock protected ConversationActivityUiStateHost mockListener; + + /** + * Test the Ui state where we start off with the contact picker to pick the first contact. + */ + public void testPickInitialContact() { + final ConversationActivityUiState uiState = new ConversationActivityUiState(null); + uiState.setHost(mockListener); + assertTrue(uiState.shouldShowContactPickerFragment()); + assertFalse(uiState.shouldShowConversationFragment()); + assertEquals(ContactPickerFragment.MODE_PICK_INITIAL_CONTACT, + uiState.getDesiredContactPickingMode()); + uiState.onGetOrCreateConversation("conversation1"); + Mockito.verify(mockListener, Mockito.times(1)).onConversationContactPickerUiStateChanged( + Mockito.eq(ConversationActivityUiState.STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT), + Mockito.eq( + ConversationActivityUiState.STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW), + Mockito.anyBoolean()); + assertTrue(uiState.shouldShowContactPickerFragment()); + assertTrue(uiState.shouldShowConversationFragment()); + assertTrue(TextUtils.equals("conversation1", uiState.getConversationId())); + assertEquals(ContactPickerFragment.MODE_CHIPS_ONLY, + uiState.getDesiredContactPickingMode()); + } + + /** + * Test the Ui state where we have both the chips view and the conversation view and we + * start message compose. + */ + public void testHybridUiStateStartCompose() { + final ConversationActivityUiState uiState = new ConversationActivityUiState("conv1"); + uiState.testSetUiState( + ConversationActivityUiState.STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW); + uiState.setHost(mockListener); + + // Start message compose. + uiState.onStartMessageCompose(); + Mockito.verify(mockListener, Mockito.times(1)).onConversationContactPickerUiStateChanged( + Mockito.eq( + ConversationActivityUiState.STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW), + Mockito.eq(ConversationActivityUiState.STATE_CONVERSATION_ONLY), + Mockito.anyBoolean()); + assertFalse(uiState.shouldShowContactPickerFragment()); + assertTrue(uiState.shouldShowConversationFragment()); + } + + /** + * Test the Ui state where we have both the chips view and the conversation view and we + * try to add a participant. + */ + public void testHybridUiStateAddParticipant() { + final ConversationActivityUiState uiState = new ConversationActivityUiState("conv1"); + uiState.testSetUiState( + ConversationActivityUiState.STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW); + uiState.setHost(mockListener); + + uiState.onAddMoreParticipants(); + Mockito.verify(mockListener, Mockito.times(1)).onConversationContactPickerUiStateChanged( + Mockito.eq( + ConversationActivityUiState.STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW), + Mockito.eq( + ConversationActivityUiState.STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS), + Mockito.anyBoolean()); + assertTrue(uiState.shouldShowContactPickerFragment()); + assertFalse(uiState.shouldShowConversationFragment()); + assertEquals(ContactPickerFragment.MODE_PICK_MORE_CONTACTS, + uiState.getDesiredContactPickingMode()); + } + + /** + * Test the Ui state where we are trying to add more participants and commit. + */ + public void testCommitAddParticipant() { + final ConversationActivityUiState uiState = new ConversationActivityUiState(null); + uiState.testSetUiState( + ConversationActivityUiState.STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS); + uiState.setHost(mockListener); + + uiState.onGetOrCreateConversation("conversation1"); + + // After adding more contacts, the terminal state is always conversation only (i.e. we + // don't go back to hybrid mode). + Mockito.verify(mockListener, Mockito.times(1)).onConversationContactPickerUiStateChanged( + Mockito.eq(ConversationActivityUiState.STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS), + Mockito.eq(ConversationActivityUiState.STATE_CONVERSATION_ONLY), + Mockito.anyBoolean()); + assertFalse(uiState.shouldShowContactPickerFragment()); + assertTrue(uiState.shouldShowConversationFragment()); + } +} diff --git a/tests/src/com/android/messaging/ui/conversation/ConversationFragmentTest.java b/tests/src/com/android/messaging/ui/conversation/ConversationFragmentTest.java new file mode 100644 index 0000000..c92fbf6 --- /dev/null +++ b/tests/src/com/android/messaging/ui/conversation/ConversationFragmentTest.java @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversation; + +import android.app.Activity; +import android.app.Fragment; +import android.database.Cursor; +import android.support.v7.widget.RecyclerView; +import android.test.suitebuilder.annotation.LargeTest; + +import com.android.messaging.FakeFactory; +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.MemoryCacheManager; +import com.android.messaging.datamodel.data.ConversationData; +import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.TestDataFactory; +import com.android.messaging.datamodel.media.MediaResourceManager; +import com.android.messaging.ui.FragmentTestCase; +import com.android.messaging.ui.PlainTextEditText; +import com.android.messaging.ui.TestActivity.FragmentEventListener; +import com.android.messaging.ui.conversation.ConversationFragment.ConversationFragmentHost; +import com.android.messaging.ui.conversationlist.ConversationListFragment; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.ImeUtil; + +import org.mockito.Matchers; +import org.mockito.Mock; +import org.mockito.Mockito; + + +/** + * Unit tests for {@link ConversationListFragment}. + */ +@LargeTest +public class ConversationFragmentTest extends FragmentTestCase { + + @Mock protected DataModel mockDataModel; + @Mock protected ConversationData mockConversationData; + @Mock protected DraftMessageData mockDraftMessageData; + @Mock protected MediaResourceManager mockMediaResourceManager; + @Mock protected BugleGservices mockBugleGservices; + @Mock protected ConversationFragmentHost mockHost; + @Mock protected MemoryCacheManager mockMemoryCacheManager; + + private ImeUtil mSpiedImeUtil; + + private static final String CONVERSATION_ID = "cid"; + + + public ConversationFragmentTest() { + super(ConversationFragment.class); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + ImeUtil.clearInstance(); + mSpiedImeUtil = Mockito.spy(new ImeUtil()); + FakeFactory.register(this.getInstrumentation().getTargetContext()) + .withDataModel(mockDataModel) + .withBugleGservices(mockBugleGservices) + .withMemoryCacheManager(mockMemoryCacheManager); + } + + /** + * Helper that will do the 'binding' of ConversationFragmentTest with ConversationData and + * leave fragment in 'ready' state. + * @param cursor + */ + private void loadWith(final Cursor cursor) { + Mockito.when(mockDraftMessageData.isBound(Matchers.anyString())) + .thenReturn(true); + Mockito.when(mockConversationData.isBound(Matchers.anyString())) + .thenReturn(true); + Mockito.doReturn(mockDraftMessageData) + .when(mockDataModel) + .createDraftMessageData(Mockito.anyString()); + Mockito.when(mockDataModel.createConversationData( + Matchers.any(Activity.class), + Matchers.any(ConversationDataListener.class), + Matchers.anyString())) + .thenReturn(mockConversationData); + + // Create fragment synchronously to avoid need for volatile, synchronization etc. + final ConversationFragment fragment = getFragment(); + // Binding to model happens when attaching fragment to activity, so hook into test + // activity to do so. + getActivity().setFragmentEventListener(new FragmentEventListener() { + @Override + public void onAttachFragment(final Fragment attachedFragment) { + if (fragment == attachedFragment) { + fragment.setConversationInfo(getActivity(), CONVERSATION_ID, null); + } + } + }); + + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + fragment.setHost(mockHost); + getActivity().setFragment(fragment); + Mockito.verify(mockDataModel).createConversationData( + getActivity(), fragment, CONVERSATION_ID); + Mockito.verify(mockConversationData).init(fragment.getLoaderManager(), + fragment.mBinding); + } + }); + // Wait for initial layout pass to work around crash in recycler view + getInstrumentation().waitForIdleSync(); + // Now load the cursor + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + fragment.onConversationMessagesCursorUpdated(mockConversationData, cursor, null, + false); + } + }); + getInstrumentation().waitForIdleSync(); + } + + /** + * Verifies that list view gets correctly populated given a cursor. + */ + public void testLoadListView() { + final Cursor cursor = TestDataFactory.getConversationMessageCursor(); + loadWith(cursor); + final RecyclerView listView = + (RecyclerView) getFragment().getView().findViewById(android.R.id.list); + assertEquals("bad cursor", cursor.getCount(), listView.getAdapter().getItemCount()); + assertEquals("bad cursor count", cursor.getCount(), listView.getChildCount()); + } + + public void testClickComposeMessageView() { + final Cursor cursor = TestDataFactory.getConversationMessageCursor(); + loadWith(cursor); + + final PlainTextEditText composeEditText = (PlainTextEditText) getFragment().getView() + .findViewById(R.id.compose_message_text); + setFocus(composeEditText, false); + Mockito.verify(mockHost, Mockito.never()).onStartComposeMessage(); + setFocus(composeEditText, true); + Mockito.verify(mockHost).onStartComposeMessage(); + } +} diff --git a/tests/src/com/android/messaging/ui/conversation/ConversationInputManagerTest.java b/tests/src/com/android/messaging/ui/conversation/ConversationInputManagerTest.java new file mode 100644 index 0000000..f335785 --- /dev/null +++ b/tests/src/com/android/messaging/ui/conversation/ConversationInputManagerTest.java @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversation; + +import android.app.FragmentManager; +import android.app.FragmentTransaction; +import android.os.Bundle; +import android.test.suitebuilder.annotation.SmallTest; +import android.widget.EditText; + +import com.android.messaging.BugleTestCase; +import com.android.messaging.FakeFactory; +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.ConversationData; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.SubscriptionListData; +import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputHost; +import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputSink; +import com.android.messaging.ui.mediapicker.MediaPicker; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.ImeUtil; +import com.android.messaging.util.ImeUtil.ImeStateHost; + +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.Spy; + +@SmallTest +public class ConversationInputManagerTest extends BugleTestCase { + @Spy protected ImeUtil spyImeUtil; + @Mock protected BugleGservices mockBugleGservices; + @Mock protected FragmentManager mockFragmentManager; + @Mock protected ConversationInputHost mockConversationInputHost; + @Mock protected ConversationInputSink mockConversationInputSink; + @Mock protected ImeStateHost mockImeStateHost; + @Mock protected ConversationData mockConversationData; + @Mock protected DraftMessageData mockDraftMessageData; + @Mock protected MediaPicker mockMediaPicker; + @Mock protected SubscriptionListData mockSubscriptionListData; + @Mock protected FragmentTransaction mockFragmentTransaction; + + @Override + protected void setUp() throws Exception { + super.setUp(); + FakeFactory.register(getTestContext()) + .withBugleGservices(mockBugleGservices); + spyImeUtil = Mockito.spy(new ImeUtil()); + ImeUtil.set(spyImeUtil); + } + + private ConversationInputManager initNewInputManager(final Bundle savedState) { + // Set up the mocks. + Mockito.when(mockConversationInputHost.getSimSelectorView()) + .thenReturn(new SimSelectorView(getTestContext(), null)); + Mockito.when(mockConversationInputHost.createMediaPicker()).thenReturn(mockMediaPicker); + Mockito.when(mockConversationInputSink.getComposeEditText()) + .thenReturn(new EditText(getTestContext())); + Mockito.doReturn(mockFragmentTransaction).when(mockFragmentTransaction).replace( + Mockito.eq(R.id.mediapicker_container), Mockito.any(MediaPicker.class), + Mockito.anyString()); + Mockito.when(mockFragmentManager.findFragmentByTag(MediaPicker.FRAGMENT_TAG)) + .thenReturn(null); + Mockito.when(mockFragmentManager.beginTransaction()).thenReturn(mockFragmentTransaction); + Mockito.when(mockSubscriptionListData.hasData()).thenReturn(true); + Mockito.when(mockConversationData.getSubscriptionListData()) + .thenReturn(mockSubscriptionListData); + Mockito.doReturn(true).when(mockConversationData).isBound(Mockito.anyString()); + Mockito.doReturn(true).when(mockDraftMessageData).isBound(Mockito.anyString()); + + final Binding dataBinding = BindingBase.createBinding(this); + dataBinding.bind(mockConversationData); + final Binding draftBinding = BindingBase.createBinding(this); + draftBinding.bind(mockDraftMessageData); + final ConversationInputManager inputManager = new ConversationInputManager(getTestContext(), + mockConversationInputHost, mockConversationInputSink, mockImeStateHost, + mockFragmentManager, dataBinding, draftBinding, savedState); + return inputManager; + } + + public void testShowHideInputs() { + final ConversationInputManager inputManager = initNewInputManager(new Bundle()); + Mockito.when(mockMediaPicker.isOpen()).thenReturn(true); + inputManager.showHideMediaPicker(true /* show */, true /* animate */); + Mockito.verify(mockFragmentTransaction).replace( + Mockito.eq(R.id.mediapicker_container), Mockito.any(MediaPicker.class), + Mockito.anyString()); + Mockito.verify(mockMediaPicker).open(Mockito.anyInt(), Mockito.eq(true /* animate */)); + + assertEquals(true, inputManager.isMediaPickerVisible()); + assertEquals(false, inputManager.isSimSelectorVisible()); + assertEquals(false, inputManager.isImeKeyboardVisible()); + + Mockito.when(mockMediaPicker.isOpen()).thenReturn(false); + inputManager.showHideMediaPicker(false /* show */, true /* animate */); + Mockito.verify(mockMediaPicker).dismiss(Mockito.eq(true /* animate */)); + + assertEquals(false, inputManager.isMediaPickerVisible()); + assertEquals(false, inputManager.isSimSelectorVisible()); + assertEquals(false, inputManager.isImeKeyboardVisible()); + } + + public void testShowTwoInputsSequentially() { + // First show the media picker, then show the IME keyboard. + final ConversationInputManager inputManager = initNewInputManager(new Bundle()); + Mockito.when(mockMediaPicker.isOpen()).thenReturn(true); + inputManager.showHideMediaPicker(true /* show */, true /* animate */); + + assertEquals(true, inputManager.isMediaPickerVisible()); + assertEquals(false, inputManager.isSimSelectorVisible()); + assertEquals(false, inputManager.isImeKeyboardVisible()); + + Mockito.when(mockMediaPicker.isOpen()).thenReturn(false); + inputManager.showHideImeKeyboard(true /* show */, true /* animate */); + + assertEquals(false, inputManager.isMediaPickerVisible()); + assertEquals(false, inputManager.isSimSelectorVisible()); + assertEquals(true, inputManager.isImeKeyboardVisible()); + } + + public void testOnKeyboardShow() { + final ConversationInputManager inputManager = initNewInputManager(new Bundle()); + Mockito.when(mockMediaPicker.isOpen()).thenReturn(true); + inputManager.showHideMediaPicker(true /* show */, true /* animate */); + + assertEquals(true, inputManager.isMediaPickerVisible()); + assertEquals(false, inputManager.isSimSelectorVisible()); + assertEquals(false, inputManager.isImeKeyboardVisible()); + + Mockito.when(mockMediaPicker.isOpen()).thenReturn(false); + inputManager.testNotifyImeStateChanged(true /* imeOpen */); + + assertEquals(false, inputManager.isMediaPickerVisible()); + assertEquals(false, inputManager.isSimSelectorVisible()); + assertEquals(true, inputManager.isImeKeyboardVisible()); + } + + public void testRestoreState() { + final ConversationInputManager inputManager = initNewInputManager(new Bundle()); + Mockito.when(mockMediaPicker.isOpen()).thenReturn(true); + inputManager.showHideMediaPicker(true /* show */, true /* animate */); + + assertEquals(true, inputManager.isMediaPickerVisible()); + assertEquals(false, inputManager.isSimSelectorVisible()); + assertEquals(false, inputManager.isImeKeyboardVisible()); + + Bundle savedInstanceState = new Bundle(); + inputManager.onSaveInputState(savedInstanceState); + + // Now try to restore the state + final ConversationInputManager restoredInputManager = + initNewInputManager(savedInstanceState); + + // Make sure the state is preserved. + assertEquals(true, restoredInputManager.isMediaPickerVisible()); + assertEquals(false, restoredInputManager.isSimSelectorVisible()); + assertEquals(false, restoredInputManager.isImeKeyboardVisible()); + } + + public void testBackPress() { + final ConversationInputManager inputManager = initNewInputManager(new Bundle()); + Mockito.when(mockMediaPicker.isOpen()).thenReturn(true); + inputManager.showHideMediaPicker(true /* show */, true /* animate */); + + assertEquals(true, inputManager.isMediaPickerVisible()); + assertEquals(false, inputManager.isSimSelectorVisible()); + assertEquals(false, inputManager.isImeKeyboardVisible()); + + Mockito.when(mockMediaPicker.isOpen()).thenReturn(false); + assertEquals(true, inputManager.onBackPressed()); + + assertEquals(false, inputManager.isMediaPickerVisible()); + assertEquals(false, inputManager.isSimSelectorVisible()); + assertEquals(false, inputManager.isImeKeyboardVisible()); + } +} diff --git a/tests/src/com/android/messaging/ui/conversation/ConversationMessageViewTest.java b/tests/src/com/android/messaging/ui/conversation/ConversationMessageViewTest.java new file mode 100644 index 0000000..6b8b0c0 --- /dev/null +++ b/tests/src/com/android/messaging/ui/conversation/ConversationMessageViewTest.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversation; + +import android.test.suitebuilder.annotation.MediumTest; +import android.test.suitebuilder.annotation.Suppress; +import android.view.View; +import android.widget.TextView; + +import com.android.messaging.FakeFactory; +import com.android.messaging.R; +import com.android.messaging.datamodel.FakeCursor; +import com.android.messaging.datamodel.data.TestDataFactory; +import com.android.messaging.ui.ViewTest; +import com.android.messaging.ui.conversation.ConversationMessageView; +import com.android.messaging.ui.conversation.ConversationMessageView.ConversationMessageViewHost; +import com.android.messaging.util.Dates; + +import org.mockito.Mock; + +@MediumTest +public class ConversationMessageViewTest extends ViewTest { + @Mock ConversationMessageViewHost mockHost; + + @Override + protected void setUp() throws Exception { + super.setUp(); + FakeFactory.register(getInstrumentation().getTargetContext()); + } + + @Override + protected ConversationMessageView getView() { + final ConversationMessageView view = super.getView(); + view.setHost(mockHost); + return view; + } + + protected void verifyContent(final ConversationMessageView view, final String messageText, + final boolean showTimestamp, final String timestampText) { + + final TextView messageTextView = (TextView) view.findViewById(R.id.message_text); + final TextView statusTextView = (TextView) view.findViewById(R.id.message_status); + + assertNotNull(messageTextView); + assertEquals(messageText, messageTextView.getText()); + + if (showTimestamp) { + assertEquals(View.VISIBLE, statusTextView.getVisibility()); + assertEquals(timestampText, statusTextView.getText()); + } else { + assertEquals(View.GONE, statusTextView.getVisibility()); + } + } + + public void testBind() { + final ConversationMessageView view = getView(); + + final FakeCursor cursor = TestDataFactory.getConversationMessageCursor(); + cursor.moveToFirst(); + + view.bind(cursor); + verifyContent(view, TestDataFactory.getMessageText(cursor, 0), true, Dates + .getMessageTimeString((Long) cursor.getAt("received_timestamp", 0)).toString()); + } + + public void testBindTwice() { + final ConversationMessageView view = getView(); + + final FakeCursor cursor = TestDataFactory.getConversationMessageCursor(); + cursor.moveToFirst(); + view.bind(cursor); + + cursor.moveToNext(); + view.bind(cursor); + verifyContent(view, TestDataFactory.getMessageText(cursor, 1), true, Dates + .getMessageTimeString((Long) cursor.getAt("received_timestamp", 1)).toString()); + } + + public void testBindLast() { + final ConversationMessageView view = getView(); + + final FakeCursor cursor = TestDataFactory.getConversationMessageCursor(); + final int lastPos = cursor.getCount() - 1; + cursor.moveToPosition(lastPos); + + view.bind(cursor); + verifyContent(view, TestDataFactory.getMessageText(cursor, lastPos), true, Dates + .getMessageTimeString((Long) cursor.getAt("received_timestamp", lastPos)) + .toString()); + } + + @Override + protected int getLayoutIdForView() { + return R.layout.conversation_message_view; + } +} diff --git a/tests/src/com/android/messaging/ui/conversationlist/ConversationListFragmentTest.java b/tests/src/com/android/messaging/ui/conversationlist/ConversationListFragmentTest.java new file mode 100644 index 0000000..f9cc9e1 --- /dev/null +++ b/tests/src/com/android/messaging/ui/conversationlist/ConversationListFragmentTest.java @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversationlist; + +import android.content.Context; +import android.database.Cursor; +import android.support.v7.widget.RecyclerView; +import android.test.suitebuilder.annotation.LargeTest; +import android.view.View; +import android.widget.ImageView; +import android.widget.ListView; + +import com.android.messaging.FakeFactory; +import com.android.messaging.R; +import com.android.messaging.datamodel.FakeDataModel; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.TestDataFactory; +import com.android.messaging.ui.FragmentTestCase; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.conversationlist.ConversationListFragment; +import com.android.messaging.ui.conversationlist.ConversationListFragment.ConversationListFragmentHost; + +import org.mockito.Matchers; +import org.mockito.Mock; +import org.mockito.Mockito; + + +/** + * Unit tests for {@link ConversationListFragment}. + */ +@LargeTest +public class ConversationListFragmentTest + extends FragmentTestCase { + + @Mock protected ConversationListData mMockConversationListData; + @Mock protected ConversationListFragmentHost mMockConversationHostListHost; + @Mock protected UIIntents mMockUIIntents; + protected FakeDataModel mFakeDataModel; + + public ConversationListFragmentTest() { + super(ConversationListFragment.class); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + + final Context context = getInstrumentation().getTargetContext(); + mFakeDataModel = new FakeDataModel(context) + .withConversationListData(mMockConversationListData); + FakeFactory.register(context) + .withDataModel(mFakeDataModel) + .withUIIntents(mMockUIIntents); + } + + /** + * Helper that will do the 'binding' of ConversationListFragmentTest with ConversationListData + * and leave fragment in 'ready' state. + * @param cursor + */ + private void loadWith(final Cursor cursor) { + Mockito.when(mMockConversationListData.isBound(Matchers.anyString())) + .thenReturn(true); + + final ConversationListFragment fragment = getFragment(); + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + fragment.setHost(mMockConversationHostListHost); + getActivity().setFragment(fragment); + Mockito.verify(mMockConversationListData).init(fragment.getLoaderManager(), + fragment.mListBinding); + fragment.onConversationListCursorUpdated(mMockConversationListData, cursor); + } + }); + getInstrumentation().waitForIdleSync(); + } + + /** + * Verifies that list view gets correctly populated given a cursor. + */ + public void testLoadListView() { + final Cursor cursor = TestDataFactory.getConversationListCursor(); + loadWith(cursor); + final RecyclerView listView = + (RecyclerView) getFragment().getView().findViewById(android.R.id.list); + //assertEquals(cursor.getCount(), listView.getCount()); + assertEquals(cursor.getCount(), listView.getChildCount()); + } + + /** + * Verifies that 'empty list' promo is rendered with an empty cursor. + */ + public void testEmptyView() { + loadWith(TestDataFactory.getEmptyConversationListCursor()); + final RecyclerView listView = + (RecyclerView) getFragment().getView().findViewById(android.R.id.list); + final View emptyMessageView = + getFragment().getView().findViewById(R.id.no_conversations_view); + assertEquals(View.VISIBLE, emptyMessageView.getVisibility()); + assertEquals(0, listView.getChildCount()); + } + + /** + * Verifies that the button to start a new conversation works. + */ + public void testStartNewConversation() { + final Cursor cursor = TestDataFactory.getConversationListCursor(); + loadWith(cursor); + final ImageView startNewConversationButton = (ImageView) + getFragment().getView().findViewById(R.id.start_new_conversation_button); + + clickButton(startNewConversationButton); + Mockito.verify(mMockConversationHostListHost).onCreateConversationClick(); + } +} diff --git a/tests/src/com/android/messaging/ui/conversationlist/ConversationListItemViewTest.java b/tests/src/com/android/messaging/ui/conversationlist/ConversationListItemViewTest.java new file mode 100644 index 0000000..be054a8 --- /dev/null +++ b/tests/src/com/android/messaging/ui/conversationlist/ConversationListItemViewTest.java @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversationlist; + +import android.content.Context; +import android.test.suitebuilder.annotation.MediumTest; +import android.text.TextUtils; +import android.view.View; +import android.widget.TextView; + +import com.android.messaging.Factory; +import com.android.messaging.FakeFactory; +import com.android.messaging.R; +import com.android.messaging.datamodel.FakeCursor; +import com.android.messaging.datamodel.FakeDataModel; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.TestDataFactory; +import com.android.messaging.ui.AsyncImageView; +import com.android.messaging.ui.UIIntentsImpl; +import com.android.messaging.ui.ViewTest; +import com.android.messaging.ui.conversationlist.ConversationListItemView; +import com.android.messaging.util.Dates; + +import org.mockito.ArgumentMatcher; +import org.mockito.Mock; +import org.mockito.Mockito; + +@MediumTest +public class ConversationListItemViewTest extends ViewTest { + + @Mock private ConversationListItemView.HostInterface mockHost; + private FakeCursor mCursor; + + @Override + protected void setUp() throws Exception { + super.setUp(); + final Context context = getInstrumentation().getTargetContext(); + FakeFactory.register(context) + .withDataModel(new FakeDataModel(context)) + .withUIIntents(new UIIntentsImpl()); + mCursor = TestDataFactory.getConversationListCursor(); + } + + + protected void verifyLaunchedConversationForId(final String id, + final ConversationListItemView conversationView) { + // Must be a short click. + final ArgumentMatcher itemDataIdMatcher = + new ArgumentMatcher() { + @Override + public boolean matches(final Object arg) { + return TextUtils.equals(id, ((ConversationListItemData) arg).getConversationId()); + } + }; + Mockito.verify(mockHost).onConversationClicked( + Mockito.argThat(itemDataIdMatcher), Mockito.eq(false), + Mockito.eq(conversationView)); + } + + protected void verifyContent( + final ConversationListItemView view, final FakeCursor cursor, final int index) { + /* ConversationQueryColumns.NAME */ + final String conversationQueryColumnsName = "name"; + final String name = (String) cursor.getAt(conversationQueryColumnsName, index); + + /* ConversationQueryColumns.SNIPPET_TEXT */ + final String conversationQueryColumnsSnippetText = "snippet_text"; + final String snippet = (String) cursor.getAt(conversationQueryColumnsSnippetText, index); + + /* ConversationQueryColumns.SORT_TIMESTAMP */ + final String conversationQueryColumnsSortTimestamp = "sort_timestamp"; + final String timestamp = Dates.getConversationTimeString( + (Long) cursor.getAt(conversationQueryColumnsSortTimestamp, index)).toString(); + + final boolean unread = !isRead(cursor, index); + verifyContent(view, name, snippet, timestamp, unread); + } + + protected void verifyContent( + final ConversationListItemView view, + final String conversationName, + final String snippet, + final String timestamp, + final boolean unread) { + final TextView conversationNameView = + (TextView) view.findViewById(R.id.conversation_name); + final TextView snippetTextView = (TextView) view.findViewById(R.id.conversation_snippet); + final TextView timestampTextView = (TextView) view.findViewById( + R.id.conversation_timestamp); + final AsyncImageView imagePreviewView = + (AsyncImageView) view.findViewById(R.id.conversation_image_preview); + + final Context context = Factory.get().getApplicationContext(); + assertNotNull(conversationNameView); + assertEquals(conversationName, conversationNameView.getText()); + assertNotNull(snippetTextView); + if (unread) { + assertEquals(ConversationListItemView.UNREAD_SNIPPET_LINE_COUNT, + snippetTextView.getMaxLines()); + assertEquals(context.getResources().getColor(R.color.conversation_list_item_unread), + snippetTextView.getCurrentTextColor()); + assertEquals(context.getResources().getColor(R.color.conversation_list_item_unread), + conversationNameView.getCurrentTextColor()); + + } else { + assertEquals(ConversationListItemView.NO_UNREAD_SNIPPET_LINE_COUNT, + snippetTextView.getMaxLines()); + assertEquals(context.getResources().getColor(R.color.conversation_list_item_read), + snippetTextView.getCurrentTextColor()); + assertEquals(context.getResources().getColor(R.color.conversation_list_item_read), + conversationNameView.getCurrentTextColor()); + } + + assertEquals(View.VISIBLE, imagePreviewView.getVisibility()); + assertTrue(snippetTextView.getText().toString().contains(snippet)); + assertEquals(timestamp, timestampTextView.getText()); + } + + protected boolean isRead(final FakeCursor cursor, final int index) { + return 1 == ((Integer) cursor.getAt("read", index)).intValue(); + } + + public void testBindUnread() { + final ConversationListItemView view = getView(); + final int messageIndex = TestDataFactory.CONVERSATION_LIST_CURSOR_UNREAD_MESSAGE_INDEX; + mCursor.moveToPosition(messageIndex); + assertFalse(isRead(mCursor, messageIndex)); + view.bind(mCursor, mockHost); + verifyContent(view, mCursor, messageIndex); + } + + public void testBindRead() { + final ConversationListItemView view = getView(); + + final int messageIndex = TestDataFactory.CONVERSATION_LIST_CURSOR_READ_MESSAGE_INDEX; + mCursor.moveToPosition(messageIndex); + assertTrue(isRead(mCursor, messageIndex)); + view.bind(mCursor, mockHost); + verifyContent(view, mCursor, messageIndex); + } + + public void testClickLaunchesConversation() { + final ConversationListItemView view = getView(); + final View swipeableContainer = view.findViewById(R.id.swipeableContainer); + mCursor.moveToFirst(); + view.bind(mCursor, mockHost); + swipeableContainer.performClick(); + verifyLaunchedConversationForId( + mCursor.getAt("_id" /* ConversationQueryColumns._ID */, 0).toString(), view); + } + + public void testBindTwice() { + final ConversationListItemView view = getView(); + + mCursor.moveToFirst(); + view.bind(mCursor, mockHost); + + mCursor.moveToNext(); + view.bind(mCursor, mockHost); + verifyContent(view, mCursor, mCursor.getPosition()); + } + + @Override + protected int getLayoutIdForView() { + return R.layout.conversation_list_item_view; + } +} diff --git a/tests/src/com/android/messaging/ui/mediapicker/AudioRecordViewTest.java b/tests/src/com/android/messaging/ui/mediapicker/AudioRecordViewTest.java new file mode 100644 index 0000000..a38dac2 --- /dev/null +++ b/tests/src/com/android/messaging/ui/mediapicker/AudioRecordViewTest.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.mediapicker; + +import android.media.MediaPlayer; +import android.media.MediaRecorder.OnErrorListener; +import android.media.MediaRecorder.OnInfoListener; +import android.net.Uri; + +import com.android.messaging.FakeFactory; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.ui.ViewTest; +import com.android.messaging.util.FakeMediaUtil; + +import org.mockito.Matchers; +import org.mockito.Mock; +import org.mockito.Mockito; + +public class AudioRecordViewTest extends ViewTest { + + @Mock AudioRecordView.HostInterface mockHost; + @Mock LevelTrackingMediaRecorder mockRecorder; + @Mock MediaPlayer mockMediaPlayer; + + @Override + protected void setUp() throws Exception { + super.setUp(); + FakeFactory.register(getInstrumentation().getContext()) + .withMediaUtil(new FakeMediaUtil(mockMediaPlayer)); + } + + private void verifyAudioSubmitted() { + Mockito.verify(mockHost).onAudioRecorded(Matchers.any(MessagePartData.class)); + } + + private AudioRecordView initView() { + final AudioRecordView view = getView(); + view.testSetMediaRecorder(mockRecorder); + view.setHostInterface(mockHost); + return view; + } + + public void testRecording() { + Mockito.when(mockRecorder.isRecording()).thenReturn(false); + Mockito.when(mockRecorder.startRecording(Matchers.any(), + Matchers.any(), Matchers.anyInt())).thenReturn(true); + Mockito.when(mockRecorder.stopRecording()).thenReturn(Uri.parse("content://someaudio/2")); + final AudioRecordView view = initView(); + view.onRecordButtonTouchDown(); + Mockito.verify(mockRecorder).startRecording(Matchers.any(), + Matchers.any(), Matchers.anyInt()); + Mockito.when(mockRecorder.isRecording()).thenReturn(true); + // Record for 1 second to make it meaningful. + sleepNoThrow(1000); + view.onRecordButtonTouchUp(); + // We added some buffer to the end of the audio recording, so sleep for sometime and + // verify audio is recorded. + sleepNoThrow(700); + Mockito.verify(mockRecorder).stopRecording(); + verifyAudioSubmitted(); + } + + private void sleepNoThrow(final long duration) { + try { + Thread.sleep(duration); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Override + protected int getLayoutIdForView() { + return R.layout.mediapicker_audio_chooser; + } +} diff --git a/tests/src/com/android/messaging/ui/mediapicker/CameraManagerTest.java b/tests/src/com/android/messaging/ui/mediapicker/CameraManagerTest.java new file mode 100644 index 0000000..951c694 --- /dev/null +++ b/tests/src/com/android/messaging/ui/mediapicker/CameraManagerTest.java @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker; + +import android.hardware.Camera; +import android.hardware.Camera.CameraInfo; +import android.os.AsyncTask; +import android.test.suitebuilder.annotation.SmallTest; +import com.android.messaging.BugleTestCase; +import com.android.messaging.ui.mediapicker.CameraManager.CameraWrapper; +import org.mockito.InOrder; +import org.mockito.Mockito; + +@SmallTest +public class CameraManagerTest extends BugleTestCase { + @Override + protected void setUp() throws Exception { + super.setUp(); + // Force each test to set up a camera wrapper to match their needs + CameraManager.setCameraWrapper(null); + } + + @Override + protected void tearDown() throws Exception { + super.tearDown(); + MockCameraFactory.cleanup(); + } + + public void testNoCameraDeviceGetInfo() { + CameraManager.setCameraWrapper(MockCameraFactory.createCameraWrapper()); + assertEquals(false, CameraManager.get().hasAnyCamera()); + assertEquals(false, CameraManager.get().hasFrontAndBackCamera()); + try { + CameraManager.get().selectCamera(CameraInfo.CAMERA_FACING_BACK); + fail("selectCamera should have thrown"); + } catch (AssertionError e) { + } + } + + public void testFrontFacingOnlyGetInfo() { + CameraManager.setCameraWrapper(MockCameraFactory.createCameraWrapper( + MockCameraFactory.createCamera(CameraInfo.CAMERA_FACING_FRONT) + )); + assertEquals(true, CameraManager.get().hasAnyCamera()); + assertEquals(false, CameraManager.get().hasFrontAndBackCamera()); + CameraManager.get().selectCamera(CameraInfo.CAMERA_FACING_FRONT); + assertEquals(CameraInfo.CAMERA_FACING_FRONT, CameraManager.get().getCameraInfo().facing); + CameraManager.get().selectCamera(CameraInfo.CAMERA_FACING_BACK); + assertEquals(CameraInfo.CAMERA_FACING_FRONT, CameraManager.get().getCameraInfo().facing); + } + + public void testBackFacingOnlyGetInfo() { + CameraManager.setCameraWrapper(MockCameraFactory.createCameraWrapper( + MockCameraFactory.createCamera(CameraInfo.CAMERA_FACING_BACK) + )); + assertEquals(true, CameraManager.get().hasAnyCamera()); + assertEquals(false, CameraManager.get().hasFrontAndBackCamera()); + CameraManager.get().selectCamera(CameraInfo.CAMERA_FACING_FRONT); + assertEquals(CameraInfo.CAMERA_FACING_BACK, CameraManager.get().getCameraInfo().facing); + CameraManager.get().selectCamera(CameraInfo.CAMERA_FACING_BACK); + assertEquals(CameraInfo.CAMERA_FACING_BACK, CameraManager.get().getCameraInfo().facing); + } + + public void testFrontAndBackGetInfo() { + CameraManager.setCameraWrapper(MockCameraFactory.createCameraWrapper( + MockCameraFactory.createCamera(CameraInfo.CAMERA_FACING_FRONT), + MockCameraFactory.createCamera(CameraInfo.CAMERA_FACING_BACK) + )); + assertEquals(true, CameraManager.get().hasAnyCamera()); + assertEquals(true, CameraManager.get().hasFrontAndBackCamera()); + CameraManager.get().selectCamera(CameraInfo.CAMERA_FACING_FRONT); + assertEquals(CameraInfo.CAMERA_FACING_FRONT, CameraManager.get().getCameraInfo().facing); + CameraManager.get().selectCamera(CameraInfo.CAMERA_FACING_BACK); + assertEquals(CameraInfo.CAMERA_FACING_BACK, CameraManager.get().getCameraInfo().facing); + } + + public void testSwapCamera() { + CameraManager.setCameraWrapper(MockCameraFactory.createCameraWrapper( + MockCameraFactory.createCamera(CameraInfo.CAMERA_FACING_FRONT), + MockCameraFactory.createCamera(CameraInfo.CAMERA_FACING_BACK) + )); + CameraManager.get().selectCamera(CameraInfo.CAMERA_FACING_FRONT); + assertEquals(CameraInfo.CAMERA_FACING_FRONT, CameraManager.get().getCameraInfo().facing); + CameraManager.get().swapCamera(); + assertEquals(CameraInfo.CAMERA_FACING_BACK, CameraManager.get().getCameraInfo().facing); + } + + public void testOpenCamera() { + Camera backCamera = MockCameraFactory.createCamera(CameraInfo.CAMERA_FACING_BACK); + Camera frontCamera = MockCameraFactory.createCamera(CameraInfo.CAMERA_FACING_FRONT); + CameraWrapper wrapper = MockCameraFactory.createCameraWrapper(frontCamera, backCamera); + CameraManager.setCameraWrapper(wrapper); + CameraManager.get().selectCamera(CameraInfo.CAMERA_FACING_BACK); + CameraManager.get().openCamera(); + CameraManager.get().openCamera(); + CameraManager.get().openCamera(); + waitForPendingAsyncTasks(); + Mockito.verify(wrapper, Mockito.never()).open(0); + Mockito.verify(wrapper).open(1); + Mockito.verify(wrapper, Mockito.never()).release(frontCamera); + Mockito.verify(wrapper, Mockito.never()).release(backCamera); + CameraManager.get().swapCamera(); + waitForPendingAsyncTasks(); + Mockito.verify(wrapper).open(0); + Mockito.verify(wrapper).open(1); + Mockito.verify(wrapper, Mockito.never()).release(frontCamera); + Mockito.verify(wrapper).release(backCamera); + InOrder inOrder = Mockito.inOrder(wrapper); + inOrder.verify(wrapper).open(1); + inOrder.verify(wrapper).release(backCamera); + inOrder.verify(wrapper).open(0); + } + + private void waitForPendingAsyncTasks() { + try { + final Object lockObject = new Object(); + + new AsyncTask() { + @Override + protected Void doInBackground(Void... voids) { + return null; + } + + @Override + protected void onPostExecute(Void aVoid) { + super.onPostExecute(aVoid); + synchronized (lockObject) { + lockObject.notifyAll(); + } + } + }.execute(); + + synchronized (lockObject) { + lockObject.wait(500); + } + } catch (InterruptedException e) { + fail(); + } + } +} diff --git a/tests/src/com/android/messaging/ui/mediapicker/GalleryGridItemViewTest.java b/tests/src/com/android/messaging/ui/mediapicker/GalleryGridItemViewTest.java new file mode 100644 index 0000000..83d8ac9 --- /dev/null +++ b/tests/src/com/android/messaging/ui/mediapicker/GalleryGridItemViewTest.java @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.mediapicker; + +import android.content.Context; +import android.provider.MediaStore.Images.Media; +import android.view.View; +import android.widget.CheckBox; + +import com.android.messaging.FakeFactory; +import com.android.messaging.R; +import com.android.messaging.datamodel.FakeCursor; +import com.android.messaging.datamodel.FakeDataModel; +import com.android.messaging.datamodel.data.GalleryGridItemData; +import com.android.messaging.datamodel.data.TestDataFactory; +import com.android.messaging.ui.AsyncImageView; +import com.android.messaging.ui.ViewTest; +import com.android.messaging.util.UriUtil; + +import org.mockito.Matchers; +import org.mockito.Mock; +import org.mockito.Mockito; + +public class GalleryGridItemViewTest extends ViewTest { + + @Mock GalleryGridItemView.HostInterface mockHost; + + @Override + protected void setUp() throws Exception { + super.setUp(); + final Context context = getInstrumentation().getTargetContext(); + FakeFactory.register(context) + .withDataModel(new FakeDataModel(context)); + } + + protected void verifyClickedItem(final View view, final GalleryGridItemData data) { + Mockito.verify(mockHost).onItemClicked(view, data, false /* longClick */); + } + + protected void verifyContent( + final GalleryGridItemView view, + final String imageUrl, + final boolean showCheckbox, + final boolean isSelected) { + final AsyncImageView imageView = (AsyncImageView) view.findViewById(R.id.image); + final CheckBox checkBox = (CheckBox) view.findViewById(R.id.checkbox); + + assertNotNull(imageView); + assertTrue(imageView.mImageRequestBinding.isBound()); + assertTrue(imageView.mImageRequestBinding.getData().getKey().startsWith(imageUrl)); + assertNotNull(checkBox); + if (showCheckbox) { + assertEquals(View.VISIBLE, checkBox.getVisibility()); + assertEquals(isSelected, checkBox.isChecked()); + } else { + assertNotSame(View.VISIBLE, checkBox.getVisibility()); + } + } + + public void testBind() { + Mockito.when(mockHost.isMultiSelectEnabled()).thenReturn(false); + Mockito.when(mockHost.isItemSelected(Matchers.any())) + .thenReturn(false); + final GalleryGridItemView view = getView(); + final FakeCursor cursor = TestDataFactory.getGalleryGridCursor(); + cursor.moveToFirst(); + final String path = (String) cursor.getAt(Media.DATA, 0); + view.bind(cursor, mockHost); + verifyContent(view, UriUtil.getUriForResourceFile(path).toString(), + false, false); + } + + public void testBindMultiSelectUnSelected() { + Mockito.when(mockHost.isMultiSelectEnabled()).thenReturn(true); + Mockito.when(mockHost.isItemSelected(Matchers.any())) + .thenReturn(false); + final GalleryGridItemView view = getView(); + final FakeCursor cursor = TestDataFactory.getGalleryGridCursor(); + cursor.moveToFirst(); + final String path = (String) cursor.getAt(Media.DATA, 0); + view.bind(cursor, mockHost); + verifyContent(view, UriUtil.getUriForResourceFile(path).toString(), + true, false); + } + + public void testBindMultiSelectSelected() { + Mockito.when(mockHost.isMultiSelectEnabled()).thenReturn(true); + Mockito.when(mockHost.isItemSelected(Matchers.any())) + .thenReturn(true); + final GalleryGridItemView view = getView(); + final FakeCursor cursor = TestDataFactory.getGalleryGridCursor(); + cursor.moveToFirst(); + final String path = (String) cursor.getAt(Media.DATA, 0); + view.bind(cursor, mockHost); + verifyContent(view, UriUtil.getUriForResourceFile(path).toString(), + true, true); + } + + public void testClick() { + Mockito.when(mockHost.isMultiSelectEnabled()).thenReturn(false); + Mockito.when(mockHost.isItemSelected(Matchers.any())) + .thenReturn(false); + final GalleryGridItemView view = getView(); + final FakeCursor cursor = TestDataFactory.getGalleryGridCursor(); + cursor.moveToFirst(); + view.bind(cursor, mockHost); + view.performClick(); + verifyClickedItem(view, view.mData); + } + + public void testBindTwice() { + Mockito.when(mockHost.isMultiSelectEnabled()).thenReturn(true); + Mockito.when(mockHost.isItemSelected(Matchers.any())) + .thenReturn(false); + final GalleryGridItemView view = getView(); + final FakeCursor cursor = TestDataFactory.getGalleryGridCursor(); + + cursor.moveToFirst(); + view.bind(cursor, mockHost); + + cursor.moveToNext(); + final String path = (String) cursor.getAt(Media.DATA, 1); + view.bind(cursor, mockHost); + verifyContent(view, UriUtil.getUriForResourceFile(path).toString(), + true, false); + } + + @Override + protected int getLayoutIdForView() { + return R.layout.gallery_grid_item_view; + } +} diff --git a/tests/src/com/android/messaging/ui/mediapicker/MediaPickerTest.java b/tests/src/com/android/messaging/ui/mediapicker/MediaPickerTest.java new file mode 100644 index 0000000..4a7040e --- /dev/null +++ b/tests/src/com/android/messaging/ui/mediapicker/MediaPickerTest.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; + +import com.android.messaging.FakeFactory; +import com.android.messaging.R; +import com.android.messaging.datamodel.FakeDataModel; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.MediaPickerData; +import com.android.messaging.ui.FragmentTestCase; + +import org.mockito.Matchers; +import org.mockito.Mock; +import org.mockito.Mockito; + +public class MediaPickerTest extends FragmentTestCase { + @Mock protected MediaPickerData mMockMediaPickerData; + @Mock protected DraftMessageData mMockDraftMessageData; + protected FakeDataModel mFakeDataModel; + + public MediaPickerTest() { + super(MediaPicker.class); + } + + @Override + protected MediaPicker getFragment() { + if (mFragment == null) { + mFragment = new MediaPicker(getInstrumentation().getTargetContext()); + } + return mFragment; + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + final Context context = getInstrumentation().getTargetContext(); + mFakeDataModel = new FakeDataModel(context) + .withMediaPickerData(mMockMediaPickerData); + FakeFactory.register(context) + .withDataModel(mFakeDataModel); + } + + /** + * Helper method to initialize the MediaPicker and its data. + */ + private void initFragment(final int supportedMediaTypes, final Integer[] expectedLoaderIds, + final boolean filterTabBeforeAttach) { + Mockito.when(mMockMediaPickerData.isBound(Matchers.anyString())) + .thenReturn(true); + Mockito.when(mMockDraftMessageData.isBound(Matchers.anyString())) + .thenReturn(true); + final Binding draftBinding = BindingBase.createBinding(this); + draftBinding.bind(mMockDraftMessageData); + + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + final MediaPicker fragment = getFragment(); + if (filterTabBeforeAttach) { + fragment.setSupportedMediaTypes(supportedMediaTypes); + getActivity().setFragment(fragment); + } else { + getActivity().setFragment(fragment); + fragment.setSupportedMediaTypes(supportedMediaTypes); + } + fragment.setDraftMessageDataModel(draftBinding); + Mockito.verify(mMockMediaPickerData, + Mockito.atLeastOnce()).init( + Matchers.eq(fragment.getLoaderManager())); + fragment.open(MediaPicker.MEDIA_TYPE_ALL, false); + } + }); + getInstrumentation().waitForIdleSync(); + } + + public void testDefaultTabs() { + Mockito.when(mMockMediaPickerData.getSelectedChooserIndex()).thenReturn(0); + initFragment(MediaPicker.MEDIA_TYPE_ALL, new Integer[] { + MediaPickerData.GALLERY_IMAGE_LOADER }, + false); + final MediaPicker mediaPicker = getFragment(); + final View view = mediaPicker.getView(); + assertNotNull(view); + final ViewGroup tabStrip = (ViewGroup) view.findViewById(R.id.mediapicker_tabstrip); + assertEquals(tabStrip.getChildCount(), 3); + for (int i = 0; i < tabStrip.getChildCount(); i++) { + final ImageButton tabButton = (ImageButton) tabStrip.getChildAt(i); + assertEquals(View.VISIBLE, tabButton.getVisibility()); + assertEquals(i == 0, tabButton.isSelected()); + } + } + + public void testFilterTabsBeforeAttach() { + Mockito.when(mMockMediaPickerData.getSelectedChooserIndex()).thenReturn(0); + initFragment(MediaPicker.MEDIA_TYPE_IMAGE, new Integer[] { + MediaPickerData.GALLERY_IMAGE_LOADER }, + true); + final MediaPicker mediaPicker = getFragment(); + final View view = mediaPicker.getView(); + assertNotNull(view); + final ViewGroup tabStrip = (ViewGroup) view.findViewById(R.id.mediapicker_tabstrip); + assertEquals(tabStrip.getChildCount(), 3); + for (int i = 0; i < tabStrip.getChildCount(); i++) { + final ImageButton tabButton = (ImageButton) tabStrip.getChildAt(i); + assertEquals(i == 0, tabButton.isSelected()); + } + } +} diff --git a/tests/src/com/android/messaging/ui/mediapicker/MockCameraFactory.java b/tests/src/com/android/messaging/ui/mediapicker/MockCameraFactory.java new file mode 100644 index 0000000..789a78f --- /dev/null +++ b/tests/src/com/android/messaging/ui/mediapicker/MockCameraFactory.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.mediapicker; + +import android.hardware.Camera; +import android.hardware.Camera.CameraInfo; + +import com.android.messaging.ui.mediapicker.CameraManager.CameraWrapper; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import java.util.HashMap; +import java.util.Map; + +class MockCameraFactory { + private static Map sCameraInfos = new HashMap(); + + public static Camera createCamera(int facing) { + Camera camera = Mockito.mock(Camera.class); + CameraInfo cameraInfo = new CameraInfo(); + cameraInfo.facing = facing; + sCameraInfos.put(camera, cameraInfo); + return camera; + } + + public static void getCameraInfo(Camera camera, CameraInfo outCameraInfo) { + CameraInfo cameraInfo = sCameraInfos.get(camera); + outCameraInfo.facing = cameraInfo.facing; + outCameraInfo.orientation = cameraInfo.orientation; + outCameraInfo.canDisableShutterSound = cameraInfo.canDisableShutterSound; + } + + public static CameraWrapper createCameraWrapper(final Camera... cameras) { + CameraWrapper wrapper = Mockito.mock(CameraWrapper.class); + Mockito.when(wrapper.getNumberOfCameras()).thenReturn(cameras.length); + Mockito.when(wrapper.open(Mockito.anyInt())).then(new Answer() { + @Override + public Camera answer(InvocationOnMock invocation) { + return cameras[(Integer) invocation.getArguments()[0]]; + } + }); + Mockito.doAnswer(new Answer() { + @Override + public Void answer(InvocationOnMock invocation) throws Throwable { + getCameraInfo( + cameras[(Integer) invocation.getArguments()[0]], + (CameraInfo) invocation.getArguments()[1] + ); + return null; + } + }).when(wrapper).getCameraInfo(Mockito.anyInt(), Mockito.any(CameraInfo.class)); + return wrapper; + } + + public static void cleanup() { + sCameraInfos.clear(); + } +} diff --git a/tests/src/com/android/messaging/util/BugleGservicesTest.java b/tests/src/com/android/messaging/util/BugleGservicesTest.java new file mode 100644 index 0000000..1a0a10e --- /dev/null +++ b/tests/src/com/android/messaging/util/BugleGservicesTest.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import android.test.suitebuilder.annotation.SmallTest; + +import com.android.messaging.BugleTestCase; + +@SmallTest +public class BugleGservicesTest extends BugleTestCase { + + public void testGServiceGet() { + final BugleGservices bugleGservices = new FakeBugleGservices(); + + assertEquals(BugleGservicesKeys.SMS_IGNORE_MESSAGE_REGEX_DEFAULT, + bugleGservices.getString( + BugleGservicesKeys.SMS_IGNORE_MESSAGE_REGEX, + BugleGservicesKeys.SMS_IGNORE_MESSAGE_REGEX_DEFAULT)); + } +} diff --git a/tests/src/com/android/messaging/util/ContactUtilTest.java b/tests/src/com/android/messaging/util/ContactUtilTest.java new file mode 100644 index 0000000..48a7ced --- /dev/null +++ b/tests/src/com/android/messaging/util/ContactUtilTest.java @@ -0,0 +1,306 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.content.ContentProviderOperation; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; +import android.test.suitebuilder.annotation.LargeTest; +import android.text.TextUtils; + +import com.android.messaging.BugleTestCase; +import com.android.messaging.FakeFactory; + +import org.junit.Assert; + +import java.util.ArrayList; + +/* + * Class for testing ContactUtil. + */ +@LargeTest +public class ContactUtilTest extends BugleTestCase { + private static final String TEST_NAME_PREFIX = "BugleTest:"; + + @Override + protected void setUp() throws Exception { + super.setUp(); + + // TODO: This test will actually mess with contacts on your phone. + // Ideally we would use a fake content provider to give us contact data... + FakeFactory.registerWithoutFakeContext(getTestContext()); + + // add test contacts. + addTestContact("John", "650-123-1233", "john@gmail.com", false); + addTestContact("Joe", "(650)123-1233", "joe@gmail.com", false); + addTestContact("Jim", "650 123 1233", "jim@gmail.com", false); + addTestContact("Samantha", "650-123-1235", "samantha@gmail.com", true); + addTestContact("Adrienne", "650-123-1236", "adrienne@gmail.com", true); + } + + @Override + protected void tearDown() throws Exception { + deleteTestContacts(); + super.tearDown(); + } + + /** + * Add a test contact based on contact name, phone and email. + */ + private void addTestContact( + final String name, final String phone, final String email, final boolean starred) + throws Exception { + final ArrayList ops = new ArrayList(); + + ops.add(ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI) + .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null) + .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null) + .build()); + + ops.add(ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) + .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0) + .withValue(ContactsContract.Data.MIMETYPE, + ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) + .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, + TEST_NAME_PREFIX + name).build()); + + if (phone != null) { + ops.add(ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) + .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0) + .withValue(ContactsContract.Data.MIMETYPE, + ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE) + .withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, phone) + .withValue(ContactsContract.CommonDataKinds.Phone.TYPE, + ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE) + .build()); + } + + if (email != null) { + ops.add(ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) + .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0) + .withValue(ContactsContract.Data.MIMETYPE, + ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE) + .withValue(ContactsContract.CommonDataKinds.Email.DATA, email) + .withValue(ContactsContract.CommonDataKinds.Email.TYPE, + ContactsContract.CommonDataKinds.Email.TYPE_WORK) + .build()); + } + + mContext.getContentResolver().applyBatch(ContactsContract.AUTHORITY, ops); + + // Star the whole contact if needed. + if (starred) { + final ContentValues values = new ContentValues(); + values.put(Contacts.STARRED, 1); + getContext().getContentResolver().update(Contacts.CONTENT_URI, values, + Contacts.DISPLAY_NAME + "= ?", new String[] { TEST_NAME_PREFIX + name }); + } + } + + /** + * Remove test contacts added during test setup. + */ + private void deleteTestContacts() { + final Uri contactUri = Uri.withAppendedPath(Contacts.CONTENT_FILTER_URI, + Uri.encode(TEST_NAME_PREFIX)); + final Cursor cur = + mContext.getContentResolver().query(contactUri, null, null, null, null); + try { + if (cur.moveToFirst()) { + do { + final String lookupKey = cur.getString(cur.getColumnIndex(Contacts.LOOKUP_KEY)); + final Uri uri = Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, lookupKey); + mContext.getContentResolver().delete(uri, null, null); + } while (cur.moveToNext()); + } + } catch (final Exception e) { + System.out.println(e.getStackTrace()); + } + } + + /** + * Verify ContactUtil.getPhone will return all phones, including the ones added for test. + */ + public void ingoredTestGetPhones() { + final Cursor cur = ContactUtil.getPhones(getContext()) + .performSynchronousQuery(); + + LogUtil.i(LogUtil.BUGLE_TAG, "testGetPhones: Number of phones on the device:" + + cur.getCount()); + + verifyCursorContains(cur, TEST_NAME_PREFIX + "John"); + verifyCursorContains(cur, TEST_NAME_PREFIX + "Joe"); + verifyCursorContains(cur, TEST_NAME_PREFIX + "Jim"); + verifyCursorContains(cur, TEST_NAME_PREFIX + "Samantha"); + verifyCursorContains(cur, TEST_NAME_PREFIX + "Adrienne"); + } + + /** + * Verify ContactUtil.filterPhone will work on name based matches. + */ + public void ingoredTestFilterPhonesByName() { + final Cursor cur = ContactUtil.filterPhones(getContext(), TEST_NAME_PREFIX) + .performSynchronousQuery(); + + if (cur.getCount() != 5) { + Assert.fail("Cursor should have size of 5"); + return; + } + + verifyCursorContains(cur, TEST_NAME_PREFIX + "John"); + verifyCursorContains(cur, TEST_NAME_PREFIX + "Joe"); + verifyCursorContains(cur, TEST_NAME_PREFIX + "Jim"); + verifyCursorContains(cur, TEST_NAME_PREFIX + "Samantha"); + verifyCursorContains(cur, TEST_NAME_PREFIX + "Adrienne"); + } + + /** + * Verify ContactUtil.filterPhone will work on partial number matches. + */ + public void ingoredTestFilterPhonesByPartialNumber() { + final String[] filters = new String[] { "650123", "650-123", "(650)123", "650 123" }; + + for (final String filter : filters) { + final Cursor cur = ContactUtil.filterPhones(getContext(), filter) + .performSynchronousQuery(); + + LogUtil.i(LogUtil.BUGLE_TAG, "testFilterPhonesByPartialNumber: Number of phones:" + + cur.getCount()); + + verifyCursorContains(cur, TEST_NAME_PREFIX + "John"); + verifyCursorContains(cur, TEST_NAME_PREFIX + "Joe"); + verifyCursorContains(cur, TEST_NAME_PREFIX + "Jim"); + verifyCursorContains(cur, TEST_NAME_PREFIX + "Samantha"); + verifyCursorContains(cur, TEST_NAME_PREFIX + "Adrienne"); + } + } + + /** + * Verify ContactUtil.filterPhone will work on full number matches. + */ + public void ingoredTestFilterPhonesByFullNumber() { + final String[] filters = new String[] { + "6501231233", "650-123-1233", "(650)123-1233", "650 123 1233" }; + + for (final String filter : filters) { + final Cursor cur = ContactUtil.filterPhones(getContext(), filter) + .performSynchronousQuery(); + + LogUtil.i(LogUtil.BUGLE_TAG, "testFilterPhonesByFullNumber: Number of phones:" + + cur.getCount()); + + verifyCursorContains(cur, TEST_NAME_PREFIX + "John"); + verifyCursorContains(cur, TEST_NAME_PREFIX + "Joe"); + verifyCursorContains(cur, TEST_NAME_PREFIX + "Jim"); + } + } + + /** + * Verify ContactUtil.lookPhone will work on number including area code. + */ + public void ingoredTestLookupPhoneWithAreaCode() { + final String[] filters = new String[] { + "6501231233", "650-123-1233", "(650)123-1233", "650 123 1233" }; + + for (final String filter : filters) { + final Cursor cur = ContactUtil.lookupPhone(getContext(), filter) + .performSynchronousQuery(); + + LogUtil.i(LogUtil.BUGLE_TAG, "testLookupPhoneWithAreaCode: Number of phones:" + + cur.getCount()); + + verifyCursorContains(cur, TEST_NAME_PREFIX + "John"); + verifyCursorContains(cur, TEST_NAME_PREFIX + "Joe"); + verifyCursorContains(cur, TEST_NAME_PREFIX + "Jim"); + } + } + + /** + * Verify ContactUtil.lookPhone will work on number without area code. + */ + public void ingoredTestLookupPhoneWithoutAreaCode() { + final String[] filters = new String[] { + "1231233", "123-1233", "123 1233" }; + + for (final String filter : filters) { + final Cursor cur = ContactUtil.lookupPhone(getContext(), filter) + .performSynchronousQuery(); + + LogUtil.i(LogUtil.BUGLE_TAG, "testLookupPhoneWithoutAreaCode: Number of phones:" + + cur.getCount()); + + verifyCursorContains(cur, TEST_NAME_PREFIX + "John"); + verifyCursorContains(cur, TEST_NAME_PREFIX + "Joe"); + verifyCursorContains(cur, TEST_NAME_PREFIX + "Jim"); + } + } + + public void ingoredTestGetFrequentPhones() { + final Cursor cur = ContactUtil.getFrequentContacts(getContext()) + .performSynchronousQuery(); + + LogUtil.i(LogUtil.BUGLE_TAG, "testGetFrequentPhones: Number of phones on the device:" + + cur.getCount()); + + verifyCursorContains(cur, TEST_NAME_PREFIX + "Samantha"); + verifyCursorContains(cur, TEST_NAME_PREFIX + "Adrienne"); + } + + /** + * Verify ContactUtil.filterEmails will work on partial email. + */ + public void ingoredTestFilterEmails() { + final Cursor cur = ContactUtil.filterEmails(getContext(), "john@") + .performSynchronousQuery(); + + LogUtil.i(LogUtil.BUGLE_TAG, "testFilterEmails: Number of emails:" + + cur.getCount()); + + verifyCursorContains(cur, TEST_NAME_PREFIX + "John"); + } + + /** + * Verify ContactUtil.lookupEmail will work on full email. + */ + public void ingoredTestLookupEmail() { + final Cursor cur = ContactUtil.lookupEmail(getContext(), "john@gmail.com") + .performSynchronousQuery(); + + LogUtil.i(LogUtil.BUGLE_TAG, "testLookupEmail: Number of emails:" + + cur.getCount()); + + verifyCursorContains(cur, TEST_NAME_PREFIX + "John"); + } + + /** + * Utility method to check whether cursor contains a particular contact. + */ + private void verifyCursorContains(final Cursor cursor, final String nameToVerify) { + if (cursor.moveToFirst()) { + do { + final String name = cursor.getString(ContactUtil.INDEX_DISPLAY_NAME); + if (TextUtils.equals(name, nameToVerify)) { + return; + } + } while (cursor.moveToNext()); + } + Assert.fail("Cursor should have " + nameToVerify); + } +} diff --git a/tests/src/com/android/messaging/util/FakeBugleGservices.java b/tests/src/com/android/messaging/util/FakeBugleGservices.java new file mode 100644 index 0000000..f7d90da --- /dev/null +++ b/tests/src/com/android/messaging/util/FakeBugleGservices.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +/** + * Fake implementation which just returns the default values. + */ +public class FakeBugleGservices extends BugleGservices { + public FakeBugleGservices() { + } + + @Override + public void registerForChanges(final Runnable r) { + } + + @Override + public long getLong(final String key, final long defaultValue) { + return defaultValue; + } + + @Override + public int getInt(final String key, final int defaultValue) { + return defaultValue; + } + + @Override + public boolean getBoolean(final String key, final boolean defaultValue) { + return defaultValue; + } + + @Override + public String getString(final String key, final String defaultValue) { + return defaultValue; + } + + @Override + public float getFloat(String key, float defaultValue) { + return defaultValue; + } + +} diff --git a/tests/src/com/android/messaging/util/FakeBuglePrefs.java b/tests/src/com/android/messaging/util/FakeBuglePrefs.java new file mode 100644 index 0000000..b3429a6 --- /dev/null +++ b/tests/src/com/android/messaging/util/FakeBuglePrefs.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + + +/** + * Fake implementation which just returns the default values and ignores put operations. + */ +public class FakeBuglePrefs extends BuglePrefs { + public FakeBuglePrefs() { + } + + @Override + public int getInt(final String key, final int defaultValue) { + return defaultValue; + } + + @Override + public long getLong(final String key, final long defaultValue) { + return defaultValue; + } + + @Override + public boolean getBoolean(final String key, final boolean defaultValue) { + return defaultValue; + } + + @Override + public String getString(final String key, final String defaultValue) { + return defaultValue; + } + + @Override + public byte[] getBytes(String key) { + return null; + } + + @Override + public void putInt(final String key, final int value) { + } + + @Override + public void putLong(final String key, final long value) { + } + + @Override + public void putBoolean(final String key, final boolean value) { + } + + @Override + public void putString(final String key, final String value) { + } + + @Override + public void putBytes(String key, byte[] value) { + } + + @Override + public String getSharedPreferencesName() { + return "FakeBuglePrefs"; + } + + @Override + public void onUpgrade(int oldVersion, int newVersion) { + } + + @Override + public void remove(String key) { + } +} diff --git a/tests/src/com/android/messaging/util/FakeMediaUtil.java b/tests/src/com/android/messaging/util/FakeMediaUtil.java new file mode 100644 index 0000000..e37ff0c --- /dev/null +++ b/tests/src/com/android/messaging/util/FakeMediaUtil.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.util; + +import android.content.Context; +import android.media.MediaPlayer; + +public class FakeMediaUtil extends MediaUtil { + private final MediaPlayer mMediaPlayer; + public FakeMediaUtil(final MediaPlayer mediaPlayer) { + mMediaPlayer = mediaPlayer; + } + + @Override + public void playSound(Context context, int resId, OnCompletionListener completionListener) { + if (completionListener != null) { + completionListener.onCompletion(); + } + } +} diff --git a/tests/src/com/android/messaging/util/YouTubeUtilTest.java b/tests/src/com/android/messaging/util/YouTubeUtilTest.java new file mode 100644 index 0000000..03c461a --- /dev/null +++ b/tests/src/com/android/messaging/util/YouTubeUtilTest.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.util; + +import android.test.suitebuilder.annotation.SmallTest; + +import com.android.messaging.BugleTestCase; + +/* + * Class for testing YouTubeUtil. + */ +@SmallTest +public class YouTubeUtilTest extends BugleTestCase { + public void testGetYoutubePreviewImageLink() { + final String videoId = "dQw4w9WgXcQ"; + final String videoThumbnailUrl = YouTubeUtil.YOUTUBE_STATIC_THUMBNAIL_PREFIX + videoId + + YouTubeUtil.YOUTUBE_STATIC_THUMBNAIL_END; + + // Check known valid youtube links to videos + assertEquals( + YouTubeUtil.getYoutubePreviewImageLink("http://www.youtube.com/watch?v=" + videoId), + videoThumbnailUrl); + assertEquals( + YouTubeUtil.getYoutubePreviewImageLink("https://www.youtube.com/watch?v=" + videoId + + "&feature=youtu.be"), videoThumbnailUrl); + assertEquals( + YouTubeUtil.getYoutubePreviewImageLink("www.youtube.com/watch?v=" + videoId), + videoThumbnailUrl); + assertEquals( + YouTubeUtil.getYoutubePreviewImageLink("http://www.youtube.com/embed/" + videoId), + videoThumbnailUrl); + assertEquals(YouTubeUtil.getYoutubePreviewImageLink("http://www.youtube.com/v/" + videoId), + videoThumbnailUrl); + assertEquals( + YouTubeUtil.getYoutubePreviewImageLink("https://youtube.googleapis.com/v/" + + videoId), videoThumbnailUrl); + assertEquals( + YouTubeUtil.getYoutubePreviewImageLink("http://www.youtube.com/apiplayer?video_id=" + + videoId), videoThumbnailUrl); + // This is the type of links that are used as shares from YouTube and will be the most + // likely case that we see + assertEquals(YouTubeUtil.getYoutubePreviewImageLink("http://youtu.be/" + videoId), + videoThumbnailUrl); + + // Try links that shouldn't work + assertNull(YouTubeUtil.getYoutubePreviewImageLink("http://www.youtube.com")); + } +} \ No newline at end of file diff --git a/tools/buglesql b/tools/buglesql new file mode 100755 index 0000000..c9ac8ef --- /dev/null +++ b/tools/buglesql @@ -0,0 +1,43 @@ +#!/bin/bash +# Copyright (C) 2015 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +usage='buglesql [-c] [-r] sql + -c display in columns with headers (default) + -r display as records' + +opts='-column -header' + +while test $# -gt 0 +do + case $1 in + -c) + opts='-column -header' + shift + ;; + -r) + opts='-line' + shift + ;; + *) + break; + esac +done + +if [ $# -lt 1 ]; then + echo "$usage" + exit 1 +fi + +adb shell su -c sqlite3 $opts data/data/com.android.messaging/databases/bugle_db "$1" diff --git a/tools/dumpapkversion.sh b/tools/dumpapkversion.sh new file mode 100755 index 0000000..48ff590 --- /dev/null +++ b/tools/dumpapkversion.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Copyright (C) 2015 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +for i in `find . -name \*.apk` +do + echo -n $i + echo -n " " + aapt dump badging $i | grep versionCode | sed -E 's/^.*versionCode/versionCode/' +done diff --git a/tools/messagegen/fillsms b/tools/messagegen/fillsms new file mode 100755 index 0000000..6903941 --- /dev/null +++ b/tools/messagegen/fillsms @@ -0,0 +1,287 @@ +#!/bin/bash +# Copyright (C) 2015 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################### +## Script that generates SMS and MMS messages and fills the Android +## telephony provider mmssms.db. This is used for testing SMS/MMS. +################################################################### + +AREA_CODE=605 + +TABLE_CANONICAL_ADDRESSES_START_ID=100 +TABLE_THREADS_START_ID=100 +TABLE_SMS_START_ID=1000 + +START_TIMESTAMP_IN_SECONDS=1357683093 # 1/8/2013 2:11:33 PM +TIMESTAMP_INC_IN_SECONDS=120 + +PART_DIR="/data/data/com.android.providers.telephony/app_parts" + +USAGE='fillsms [-f] [-x] <# of threads> <# of sms per thread> <# of mms per thread> + -f -- Only generates the SQL file, do not push to the device + -x -- Only execute a SQL file + -g -- For GB devices +Examples: + # Generate 2 threads each with 10 SMSes and 10 MMSes on device with phone + # number +16508619525. MMS messages use images listed in ./images, which list + # *.jpg and *.gif files in local directory. The SQL commands are in sql.txt + fillsms +16508619525 2 10 10 images sql.txt + + # Same as above but only creating the SQL command file without pushing to + # device + fillsms -f +16508619525 2 10 10 images sql.txt + + # Just push the sql.txt to device without generating new SQLs + fillsms -x +16508619525 2 10 10 images sql.txt +' + +SMIL=' ' + +MAX_WORDS_PER_MESSAGE=15 + +DICT=american-english + +# don't actually run the sql on device +opt_sql_only=0 +opt_exec_only=0 +opt_for_gb=0 + +while test $# -gt 0 +do + case $1 in + -f) + opt_sql_only=1 + shift + ;; + -x) + opt_exec_only=1 + shift + ;; + -g) + opt_for_gb=1 + shift + ;; + *) + break; + esac +done + + +if [ $opt_sql_only -eq "1" -a $opt_exec_only -eq "1" ]; then + echo "-f and -x can not coexist" + echo "$USAGE" + exit 1 +fi + +if [ $# -lt 6 ]; then + echo "$USAGE" + exit 1 +fi +device_phone=$1 +shift +num_of_threads=$1 +shift +sms_per_thread=$1 +shift +mms_per_thread=$1 +shift +image_list_file=$1 +shift +sql_file=$1 +shift + +dict_lines=`wc -l < $DICT` +image_files=`wc -l < $image_list_file` + +if [ $mms_per_thread -gt "0" ]; then + if [ ! -f $image_list_file ]; then + echo "No image files for creating MMS messages" + exit 1 + fi +fi + +echoerr () +{ + echo "$@" 1>&2; +} + +random_value () +{ + echo $(( $RANDOM % $1 + 1 )) +} + +dict_word () +{ + local v=$(random_value 30000) + sed $v"q;d" $DICT +} + +gen_message () +{ + local words=$(random_value $MAX_WORDS_PER_MESSAGE) + local message= + for k in `seq 1 $words`; + do + local word=$(dict_word) + message="$message $word" + done + echo $message | sed -e "s/'//g" +} + +random_image () +{ + local v=$(random_value $image_files) + sed $v"q;d" $image_list_file +} + +add_sql () +{ + echo $1 >> $sql_file +} + +adb_sql () +{ + echo $1 + adb shell sqlite3 data/data/com.android.providers.telephony/databases/mmssms.db "$1" +} + +###################################################################################### +###################################################################################### + +if [ $opt_exec_only -eq "0" ]; then + # clean up sql file + rm -f $sql_file + + # add sql to clean up database + add_sql "delete from pdu where _id>=$TABLE_SMS_START_ID;" + add_sql "delete from part where _id>=$TABLE_SMS_START_ID;" + add_sql "delete from addr where _id>=$TABLE_SMS_START_ID;" + add_sql "delete from sms where _id>=$TABLE_SMS_START_ID;" + add_sql "delete from threads where _id>=$TABLE_THREADS_START_ID;" + add_sql "delete from canonical_addresses where _id>=$TABLE_CANONICAL_ADDRESSES_START_ID;" + + for i in `seq 1 $num_of_threads`; + do + echo + echo "Creating thread $i ......" + echo + + # Get random phone number + value=$(random_value 1000) + middle=$(printf '%03d' $value) + value=$(random_value 10000) + last=$(printf '%04d' $value) + phone="+1$AREA_CODE$middle$last" + echo $phone + echo + + timestamp=$(( $START_TIMESTAMP_IN_SECONDS + 5 * $TIMESTAMP_INC_IN_SECONDS * $i )) + + # Generate threads + addr_id=$(( $TABLE_CANONICAL_ADDRESSES_START_ID + $i )) + add_sql "insert into canonical_addresses (_id,address) values ($addr_id,'$phone');" + + thread_id=$(( $TABLE_THREADS_START_ID + $i )) + add_sql "insert into threads (_id,date,message_count,recipient_ids,snippet,snippet_cs,read,type,error,has_attachment) values ($thread_id, $timestamp, $sms_per_thread, $addr_id, 'snippet', 0, 1, 0, 0, 0);" + + # Generate SMS + if [ $sms_per_thread -gt "0" ]; then + half_timestamp_inc=$(( 500 + ((($sms_per_thread + $mms_per_thread) * $TIMESTAMP_INC_IN_SECONDS) * 500 / $sms_per_thread) )) + for j in `seq 1 $sms_per_thread`; + do + message=$(gen_message) + date=$(( ( 1000 * $timestamp ) - $half_timestamp_inc * ( 2 * ($sms_per_thread - $j) + ( $i % 2 ) ) )) + message_id=$(( $TABLE_SMS_START_ID + $sms_per_thread * $i * 2 + (2 * $j) )) + message_type=$(( $j % 2 + 1 )) + add_sql "insert into sms (_id,thread_id,address,person,date,status,type,body,read,seen) values ($message_id, $thread_id, '$phone', '$phone', $date, -1, $message_type, '$message', 1, 1);" + done + fi + + # Generate MMS + if [ $mms_per_thread -gt "0" ]; then + half_timestamp_inc=$(( 1 + ((($sms_per_thread + $mms_per_thread) * $TIMESTAMP_INC_IN_SECONDS) / ( 2 * $mms_per_thread) ) )) + for j in `seq 1 $mms_per_thread`; + do + image_line=$(random_image) + image=`echo $image_line | awk '{ print $1 }'` + width=`echo $image_line | awk '{ print $2 }'` + height=`echo $image_line | awk '{ print $3 }'` + size=`echo $image_line | awk '{ print $4 }'` + date=$(( $timestamp - $half_timestamp_inc * ( 2 * ($mms_per_thread - $j) + ( ($i+1) % 2 ) ) )) + message_id=$(( $TABLE_SMS_START_ID + $sms_per_thread * $i * 2 + (2 * $j + 1) )) + message_type=$(( $j % 2 + 1 )) + if [ $message_type -eq '1' ]; then + m_type=132 + else + m_type=128 + fi + if [ $opt_for_gb -eq "0" ]; then + add_sql "insert into pdu (_id,thread_id,date,date_sent,msg_box,read,m_id,sub,sub_cs,ct_t,m_cls,m_type,v,m_size,pri,rr,tr_id,d_rpt,locked,seen,text_only) values ($message_id, $thread_id, $date, 0, $message_type, 1, 'hmma3p5s1a3m526@w.tmomail.net', 'no subject', 106, 'application/vnd.wap.multipart.related', 'personal', $m_type, 18, $size, 129, 129 , '$message_id', 129, 0, 1, 0);" + else + add_sql "insert into pdu (_id,thread_id,date,msg_box,read,m_id,sub,sub_cs,ct_t,m_cls,m_type,v,m_size,pri,rr,tr_id,d_rpt,locked,seen) values ($message_id, $thread_id, $date, $message_type, 1, 'hmma3p5s1a3m526@w.tmomail.net', 'no subject', 106, 'application/vnd.wap.multipart.related', 'personal', $m_type, 18, $size, 129, 129 , '$message_id', 129, 0, 1);" + fi + id_1=$(( $message_id )) + id_2=$(( $message_id + 1 )) + smil=$(printf "$SMIL" $height $width $height $width $image) + add_sql "insert into part (_id,mid,seq,ct,cid,cl,text) values ($id_1, $message_id, -1, 'application/smil', '', 'smil.xml', '$smil');" + image_no_suffix=${image%.*} + add_sql "insert into part (_id,mid,seq,ct,cid,cl,_data) values ($id_2, $message_id, 0, 'image/jpeg', '$image_no_suffix', '$image', '$PART_DIR/$image');" + if [ $message_type -eq '1' ]; then + add_sql "insert into addr (_id,msg_id,address,type,charset) values ($id_1, $message_id, '$phone', 137, 106);" + add_sql "insert into addr (_id,msg_id,address,type,charset) values ($id_2, $message_id, '$device_phone', 151, 106);" + else + add_sql "insert into addr (_id,msg_id,address,type,charset) values ($id_1, $message_id, 'insert-address-token', 137, 106);" + add_sql "insert into addr (_id,msg_id,address,type,charset) values ($id_2, $message_id, '$phone', 151, 106);" + fi + done + fi + done +fi + +# Push to device +if [ $opt_sql_only -eq "0" ]; then + # make sure we have access + adb root + + # Push all local jpgs or gifs to device, being lazy here. + if [ $mms_per_thread -gt "0" ]; then + for file in `ls *.jpg *.gif`; + do + echo "adb push $file $PART_DIR/$file" + adb push $file $PART_DIR/$file + done + fi + + echo "adb push $sql_file /data/fillsms" + adb push $sql_file /data/fillsms + echo + adb_sql ".read /data/fillsms" + echo + adb_sql "select count(*) from canonical_addresses where _id>=$TABLE_CANONICAL_ADDRESSES_START_ID;" + echo + adb_sql "select count(*) from threads where _id>=$TABLE_THREADS_START_ID;" + echo + if [ $sms_per_thread -gt "0" ]; then + adb_sql "select count(*) from sms where _id>=$TABLE_SMS_START_ID;" + echo + fi + if [ $mms_per_thread -gt "0" ]; then + adb_sql "select count(*) from pdu where _id>=$TABLE_SMS_START_ID;" + echo + adb_sql "select count(*) from part where _id>=$TABLE_SMS_START_ID;" + echo + adb_sql "select count(*) from addr where _id>=$TABLE_SMS_START_ID;" + echo + fi +fi diff --git a/tools/messagegen/listimages b/tools/messagegen/listimages new file mode 100755 index 0000000..9c50072 --- /dev/null +++ b/tools/messagegen/listimages @@ -0,0 +1,21 @@ +#!/bin/bash +# Copyright (C) 2015 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +for file in `ls *.jpg *.gif`; +do + dimension=`identify -format "%w %h" $file` + size=`stat -c%s $file` + echo "$file $dimension $size" +done diff --git a/tools/mmssql b/tools/mmssql new file mode 100755 index 0000000..1643dbb --- /dev/null +++ b/tools/mmssql @@ -0,0 +1,43 @@ +#!/bin/bash +# Copyright (C) 2015 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +usage='mmssql [-c] [-r] sql + -c display in columns with headers (default) + -r display as records' + +opts='-column -header' + +while test $# -gt 0 +do + case $1 in + -c) + opts='-column -header' + shift + ;; + -r) + opts='-line' + shift + ;; + *) + break; + esac +done + +if [ $# -lt 1 ]; then + echo "$usage" + exit 1 +fi + +adb shell su -c sqlite3 $opts data/data/com.android.providers.telephony/databases/mmssms.db "$1" diff --git a/version.mk b/version.mk new file mode 100644 index 0000000..7407993 --- /dev/null +++ b/version.mk @@ -0,0 +1,123 @@ +# Copyright (C) 2015 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# The version code scheme for the package apk is: +# Mmbbbtad +# where +# M - major version (one or more digits) +# m - minor version (exactly 1 digit) +# bbb - manually specified build number (exactly 3 digits) +# t - build type (exactly 1 digit). Current valid values are: +# 0 : internal (dev build) +# 1 : prod build +# a - device architecture (exactly 1 digit). Current valid values are: +# 0 : non-native +# 1 : armv5te +# 3 : armv7-a +# 4 : arm64-v8a +# 5 : mips +# 6 : mips-64 +# 7 : x86 +# 8 : x86-64 +# d - asset density (exactly 1 digit). Current valid values are: +# 0 : all densities +# 2 : mdpi +# 4 : hdpi +# 6 : xhdpi +# 8 : xxhdpi +# 9 : xxxhdpi +# Mmbbb is specified manually. tad is automatically set during the build. +# +# For the client jar, the version code is agnostic to the target architecture and density: Mmbbbt00 +# +# NOTE: arch needs to be more significant than density because x86 devices support running ARM +# code in emulation mode, so all x86 versions must be higher than all ARM versions to ensure +# we deliver true x86 code to those devices. + +# Specify the following manually. Note that base_version_minor must be exactly 1 digit and +# base_version_build must be exactly 3 digits. +base_version_major := 1 +base_version_minor := 0 +base_version_build := 001 + +##################################################### +##################################################### +# Collect automatic version code parameters +ifneq "" "$(filter eng.%,$(BUILD_NUMBER))" + # This is an eng build + base_version_buildtype := 0 +else + # This is a build server build + base_version_buildtype := 1 +endif + +# Set the device architecture digit +ifeq "$(TARGET_ARCH)" "arm" + ifeq "$(TARGET_ARCH_VARIANT)" "armv5te" + base_version_arch := 1 + else ifeq "$(TARGET_ARCH_VARIANT)" "armv7-a" + base_version_arch := 3 + endif +else ifeq "$(TARGET_ARCH)" "arm64" + base_version_arch := 4 +else ifeq "$(TARGET_ARCH)" "mips" + base_version_arch := 5 +else ifeq "$(TARGET_ARCH)" "x86" + base_version_arch := 7 +else ifeq "$(TARGET_ARCH)" "x86_64" + base_version_arch := 8 +else + base_version_arch := 0 +endif + +ifeq "$(package_dpi)" "mdpi" + base_version_density := 2 +else ifeq "$(package_dpi)" "hdpi" + base_version_density := 4 +else ifeq "$(package_dpi)" "xhdpi" + base_version_density := 6 +else ifeq "$(package_dpi)" "xxhdpi" + base_version_density := 8 +else ifeq "$(package_dpi)" "xxxhdpi" + base_version_density := 9 +else + base_version_density := 0 +endif + +# Build the version code +version_code_package := $(base_version_major)$(base_version_minor)$(base_version_build)$(base_version_buildtype)$(base_version_arch)$(base_version_density) + +# The version name scheme for the package apk is: +# - For eng build (t=1): M.m.bbb eng.$(USER)-hh +# - For build server (t=0): M.m.bbb (nnnnnn-hh) +# where nnnnnn is the build number from the build server (no zero-padding) +# On eng builds, the BUILD_NUMBER has the user and timestamp inline +ifneq "" "$(filter eng.%,$(BUILD_NUMBER))" + git_hash := $(shell git --git-dir $(LOCAL_PATH)/.git log -n 1 --pretty=format:%h) + date_string := $(shell date +%m%d%y_%H%M%S) + version_name_package := $(base_version_major).$(base_version_minor).$(base_version_build) (eng.$(USER).$(git_hash).$(date_string)-$(base_version_arch)$(base_version_density)) +else + version_name_package := $(base_version_major).$(base_version_minor).$(base_version_build) ($(BUILD_NUMBER)-$(base_version_arch)$(base_version_density)) +endif + +# Cleanup the locals +base_version_major := +base_version_minor := +base_version_build := +base_version_buildtype := +base_version_arch := +base_version_density := +git_hash := +date_string :=