diff options
Diffstat (limited to 'android/source/src')
96 files changed, 17381 insertions, 0 deletions
diff --git a/android/source/src/java/org/libreoffice/AboutDialogFragment.java b/android/source/src/java/org/libreoffice/AboutDialogFragment.java new file mode 100644 index 0000000000..0d9fc45856 --- /dev/null +++ b/android/source/src/java/org/libreoffice/AboutDialogFragment.java @@ -0,0 +1,102 @@ +/* + * + * * This file is part of the LibreOffice project. + * * This Source Code Form is subject to the terms of the Mozilla Public + * * License, v. 2.0. If a copy of the MPL was not distributed with this + * * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + */ + +package org.libreoffice; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.ComponentName; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; +import android.text.Html; +import android.text.Spanned; +import android.text.method.LinkMovementMethod; +import android.view.View; +import android.widget.TextView; + +public class AboutDialogFragment extends DialogFragment { + + @NonNull @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + + @SuppressLint("InflateParams") //suppressed because the view will be placed in a dialog + View messageView = getActivity().getLayoutInflater().inflate(R.layout.about, null, false); + + // When linking text, force to always use default color. This works + // around a pressed color state bug. + TextView textView = messageView.findViewById(R.id.about_credits); + int defaultColor = textView.getTextColors().getDefaultColor(); + textView.setTextColor(defaultColor); + + // Take care of placeholders and set text in version and vendor text views. + try + { + String versionName = getActivity().getPackageManager() + .getPackageInfo(getActivity().getPackageName(), 0).versionName; + String version = String.format(getString(R.string.app_version), versionName, BuildConfig.BUILD_ID_SHORT); + @SuppressWarnings("deprecation") // since 24 with additional option parameter + Spanned versionString = Html.fromHtml(version); + TextView versionView = messageView.findViewById(R.id.about_version); + versionView.setText(versionString); + versionView.setMovementMethod(LinkMovementMethod.getInstance()); + TextView vendorView = messageView.findViewById(R.id.about_vendor); + String vendor = getString(R.string.app_vendor).replace("$VENDOR", BuildConfig.VENDOR); + vendorView.setText(vendor); + } + catch (PackageManager.NameNotFoundException e) + { + } + + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder .setIcon(R.mipmap.ic_launcher) + .setTitle(R.string.app_name) + .setView(messageView) + .setNegativeButton(R.string.about_license, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + loadFromAbout(R.raw.license); + dialog.dismiss(); + } + }) + .setPositiveButton(R.string.about_notice, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + loadFromAbout(R.raw.notice); + dialog.dismiss(); + } + }); + + // when privacy policy URL is set (via '--with-privacy-policy-url=<url>' autogen option), + // add button to open that URL + final String privacyUrl = BuildConfig.PRIVACY_POLICY_URL; + if (!privacyUrl.isEmpty() && privacyUrl != "undefined") { + builder.setNeutralButton(R.string.about_privacy_policy, (DialogInterface dialog, int id) -> { + Intent openPrivacyUrlIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(privacyUrl)); + startActivity(openPrivacyUrlIntent); + dialog.dismiss(); + }); + } + + return builder.create(); + } + + private void loadFromAbout(int resourceId) { + Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse("android.resource://" + BuildConfig.APPLICATION_ID + "/" + resourceId)); + String packageName = getActivity().getApplicationContext().getPackageName(); + ComponentName componentName = new ComponentName(packageName, LibreOfficeMainActivity.class.getName()); + i.setComponent(componentName); + getActivity().startActivity(i); + } +} diff --git a/android/source/src/java/org/libreoffice/ColorPaletteAdapter.java b/android/source/src/java/org/libreoffice/ColorPaletteAdapter.java new file mode 100644 index 0000000000..16d8a97786 --- /dev/null +++ b/android/source/src/java/org/libreoffice/ColorPaletteAdapter.java @@ -0,0 +1,135 @@ +package org.libreoffice; + +import android.content.Context; +import androidx.recyclerview.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.ImageButton; + + +public class ColorPaletteAdapter extends RecyclerView.Adapter<ColorPaletteAdapter.ColorPaletteViewHolder> { + + private int[][] color_palette; + private final Context mContext; + private int upperSelectedBox = -1; + private int selectedBox = 0; + private boolean animate; + private final ColorPaletteListener colorPaletteListener; + + public ColorPaletteAdapter(Context mContext, ColorPaletteListener colorPaletteListener) { + this.mContext = mContext; + this.color_palette = new int[11][8]; + this.colorPaletteListener = colorPaletteListener; + } + + @Override + public ColorPaletteViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View item = LayoutInflater.from(mContext).inflate(R.layout.colorbox, parent, false); + return new ColorPaletteViewHolder(item); + } + + + public int getSelectedBox() { + return selectedBox; + } + + public int getUpperSelectedBox() { + return upperSelectedBox; + } + + @Override + public void onBindViewHolder(final ColorPaletteViewHolder holder, int position) { + + holder.colorBox.setBackgroundColor(color_palette[upperSelectedBox][position]); + if (selectedBox == position) { + holder.colorBox.setImageResource(R.drawable.ic_done_all_white_12dp); + } else { + holder.colorBox.setImageDrawable(null); + } + + holder.colorBox.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + LibreOfficeMainActivity.setDocumentChanged(true); + setPosition(holder.getAdapterPosition()); + } + }); + if (animate) //it will only animate when the upper color box is selected + setAnimation(holder.colorBox); + + } + + private void setAnimation(View viewToAnimate) { + Animation animation = AnimationUtils.loadAnimation(mContext, android.R.anim.fade_in); + viewToAnimate.startAnimation(animation); + } + + @Override + public int getItemCount() { + return color_palette[0].length; + } + + private void setPosition(int position) { + this.selectedBox = position; + colorPaletteListener.applyColor(color_palette[upperSelectedBox][position]); + animate = false; + updateAdapter(); + } + + public void setPosition(int upperSelectedBox, int position) { + if (this.upperSelectedBox != upperSelectedBox) { + this.upperSelectedBox = upperSelectedBox; + this.selectedBox = position; + colorPaletteListener.applyColor(color_palette[upperSelectedBox][position]); + animate = true; + updateAdapter(); + } + } + + /* + this is for InvalidationHandler when .uno:FontColor is captured + */ + public void changePosition(int upperSelectedBox, int position) { + if(this.upperSelectedBox != upperSelectedBox){ + this.upperSelectedBox = upperSelectedBox; + animate=true; + } + + this.selectedBox = position; + + updateAdapter(); + + } + + public void setColorPalette(int[][] color_palette) { + this.color_palette = color_palette; + this.upperSelectedBox = 0; + this.selectedBox = 0; + } + + private void updateAdapter(){ + + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + ColorPaletteAdapter.this.notifyDataSetChanged(); + } + }); + } + + + class ColorPaletteViewHolder extends RecyclerView.ViewHolder { + + ImageButton colorBox; + + public ColorPaletteViewHolder(View itemView) { + super(itemView); + colorBox = itemView.findViewById(R.id.fontColorBox); + } + } + + +} diff --git a/android/source/src/java/org/libreoffice/ColorPaletteListener.java b/android/source/src/java/org/libreoffice/ColorPaletteListener.java new file mode 100644 index 0000000000..a79a19e5c9 --- /dev/null +++ b/android/source/src/java/org/libreoffice/ColorPaletteListener.java @@ -0,0 +1,6 @@ +package org.libreoffice; + +public interface ColorPaletteListener { + void applyColor(int color); + void updateColorPickerPosition(int color); +} diff --git a/android/source/src/java/org/libreoffice/ColorPickerAdapter.java b/android/source/src/java/org/libreoffice/ColorPickerAdapter.java new file mode 100644 index 0000000000..a17dd264fb --- /dev/null +++ b/android/source/src/java/org/libreoffice/ColorPickerAdapter.java @@ -0,0 +1,162 @@ +package org.libreoffice; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Color; +import androidx.recyclerview.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; + + +public class ColorPickerAdapter extends RecyclerView.Adapter<ColorPickerAdapter.ColorPickerViewHolder> { + + private final Context mContext; + private final ColorPaletteAdapter colorPaletteAdapter; + private final ColorPaletteListener colorPaletteListener; + private final int[] colorList; + private final int[][] colorPalette = new int[11][8]; + + public ColorPickerAdapter(Context mContext, final ColorPaletteAdapter colorPaletteAdapter, ColorPaletteListener colorPaletteListener) { + this.mContext = mContext; + this.colorPaletteAdapter = colorPaletteAdapter; + this.colorPaletteListener = colorPaletteListener; + Resources r = mContext.getResources(); + this.colorList = r.getIntArray(R.array.fontcolors); + initializeColorPalette(); + + + } + + @Override + public ColorPickerViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View item = LayoutInflater.from(mContext).inflate(R.layout.colorbox, parent, false); + return new ColorPickerViewHolder(item); + + } + + @Override + public void onBindViewHolder(final ColorPickerViewHolder holder, int position) { + holder.colorBox.setBackgroundColor(colorList[position]); + + if (colorPaletteAdapter.getUpperSelectedBox() == position + && colorPaletteAdapter.getSelectedBox() >= 0) { + holder.colorBox.setImageResource(R.drawable.ic_done_white_12dp); + } else { + holder.colorBox.setImageDrawable(null); + } + + holder.colorBox.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + LibreOfficeMainActivity.setDocumentChanged(true); + setPosition(holder.getAdapterPosition()); + colorPaletteListener.applyColor(colorList[holder.getAdapterPosition()]); + } + }); + } + + @Override + public int getItemCount() { + return colorList.length; + } + + + private void setPosition(int position) { + selectSubColor(position, position==0?0:3); + colorPaletteListener.applyColor(colorList[position]); + updateAdapter(); + } + + /** + * Switches to first palette, but doesn't mark any color as selected. + * Use this if no color in the palette matches the actual one. + */ + public void unselectColors() { + colorPaletteAdapter.changePosition(0, -1); + updateAdapter(); + } + + private void selectSubColor(int position1, int position2) { + colorPaletteAdapter.setPosition(position1, position2); + } + + private void initializeColorPalette() { + + for (int i = 0; i < 11; i++) { + int red = Color.red(colorList[i]); + int green = Color.green(colorList[i]); + int blue = Color.blue(colorList[i]); + + int red_tint = red; + int green_tint = green; + int blue_tint = blue; + + int red_shade = red; + int green_shade = green; + int blue_shade = blue; + if (i == 0) { + colorPalette[0][0] = colorList[i]; + for (int k = 1; k < 7; k++) { + red_tint = (int) (red_tint + (255 - red_tint) * 0.25); + green_tint = (int) (green_tint + (255 - green_tint) * 0.25); + blue_tint = (int) (blue_tint + (255 - blue_tint) * 0.25); + colorPalette[i][k] = (Color.rgb(red_tint, green_tint, blue_tint)); + } + } else { + colorPalette[i][3] = colorList[i]; + for (int k = 2; k >= 0; k--) { + red_shade = (int) (red_shade * 0.75); + green_shade = (int) (green_shade * 0.75); + blue_shade = (int) (blue_shade * 0.75); + colorPalette[i][k] = (Color.rgb(red_shade, green_shade, blue_shade)); + } + for (int k = 4; k < 7; k++) { + red_tint = (int) (red_tint + (255 - red_tint) * 0.45); + green_tint = (int) (green_tint + (255 - green_tint) * 0.45); + blue_tint = (int) (blue_tint + (255 - blue_tint) * 0.45); + colorPalette[i][k] = (Color.rgb(red_tint, green_tint, blue_tint)); + } + } + colorPalette[i][7] = Color.WHITE; // last one is always white + } + colorPaletteAdapter.setColorPalette(colorPalette); + } + + public void findSelectedTextColor(int color) { + // try to find and highlight the color in the existing palettes + for (int i = 0; i < 11; i++) { + for (int k = 0; k < 8; k++) { + if (colorPalette[i][k] == color) { + colorPaletteAdapter.changePosition(i, k); + updateAdapter(); + return; + } + } + } + + // no color in the palettes matched + unselectColors(); + } + + private void updateAdapter(){ + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + ColorPickerAdapter.this.notifyDataSetChanged(); + } + }); + + } + + class ColorPickerViewHolder extends RecyclerView.ViewHolder { + + ImageButton colorBox; + + public ColorPickerViewHolder(View itemView) { + super(itemView); + this.colorBox = itemView.findViewById(R.id.fontColorBox); + } + } +} diff --git a/android/source/src/java/org/libreoffice/DocumentPartView.java b/android/source/src/java/org/libreoffice/DocumentPartView.java new file mode 100644 index 0000000000..f1ce71900d --- /dev/null +++ b/android/source/src/java/org/libreoffice/DocumentPartView.java @@ -0,0 +1,21 @@ +/* -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.libreoffice; + +public class DocumentPartView { + public int partIndex; + public String partName; + + public DocumentPartView(int partIndex, String partName) { + this.partIndex = partIndex; + this.partName = partName; + } +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/DocumentPartViewListAdapter.java b/android/source/src/java/org/libreoffice/DocumentPartViewListAdapter.java new file mode 100644 index 0000000000..a0ed871a40 --- /dev/null +++ b/android/source/src/java/org/libreoffice/DocumentPartViewListAdapter.java @@ -0,0 +1,50 @@ +/* -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.libreoffice; + +import android.app.Activity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +import java.util.List; + +public class DocumentPartViewListAdapter extends ArrayAdapter<DocumentPartView> { + + private final Activity activity; + private final ThumbnailCreator thumbnailCollector; + + public DocumentPartViewListAdapter(Activity activity, int resource, List<DocumentPartView> objects) { + super(activity, resource, objects); + this.activity = activity; + this.thumbnailCollector = new ThumbnailCreator(); + } + + @Override + public View getView(int position, View view, ViewGroup parent) { + if (view == null) { + LayoutInflater layoutInflater = activity.getLayoutInflater(); + view = layoutInflater.inflate(R.layout.document_part_list_layout, null); + } + + DocumentPartView documentPartView = getItem(position); + TextView textView = view.findViewById(R.id.text); + textView.setText(documentPartView.partName); + + ImageView imageView = view.findViewById(R.id.image); + thumbnailCollector.createThumbnail(position, imageView); + + return view; + } +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/FontController.java b/android/source/src/java/org/libreoffice/FontController.java new file mode 100644 index 0000000000..72f35d8b42 --- /dev/null +++ b/android/source/src/java/org/libreoffice/FontController.java @@ -0,0 +1,446 @@ +package org.libreoffice; + +import android.graphics.Color; +import android.graphics.Rect; +import com.google.android.material.bottomsheet.BottomSheetBehavior; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.Spinner; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; + +public class FontController implements AdapterView.OnItemSelectedListener { + + /** -1 as value in ".uno:Color" et al. means "automatic color"/no color set. */ + private static final int COLOR_AUTO = -1; + + private boolean mFontNameSpinnerSet = false; + private boolean mFontSizeSpinnerSet = false; + private final LibreOfficeMainActivity mActivity; + private final ArrayList<String> mFontList = new ArrayList<>(); + private final ArrayList<String> mFontSizes = new ArrayList<>(); + private final HashMap<String, ArrayList<String>> mAllFontSizes = new HashMap<>(); + + private String mCurrentFontSelected = null; + private String mCurrentFontSizeSelected = null; + + public FontController(LibreOfficeMainActivity activity) { + mActivity = activity; + } + private BottomSheetBehavior colorPickerBehavior; + private BottomSheetBehavior backColorPickerBehavior; + private BottomSheetBehavior toolBarBottomBehavior; + private ColorPickerAdapter colorPickerAdapter; + private ColorPickerAdapter backColorPickerAdapter; + + final ColorPaletteListener colorPaletteListener = new ColorPaletteListener() { + @Override + public void applyColor(int color) { + sendFontColorChange(color, false); + } + + @Override + public void updateColorPickerPosition(int color) { + if (colorPickerAdapter == null) { + return; + } + if (color == COLOR_AUTO) { + colorPickerAdapter.unselectColors(); + changeFontColorBoxColor(Color.TRANSPARENT); + return; + } + final int colorWithAlpha = color | 0xFF000000; + colorPickerAdapter.findSelectedTextColor(colorWithAlpha); + changeFontColorBoxColor(colorWithAlpha); + } + }; + + final ColorPaletteListener backColorPaletteListener = new ColorPaletteListener() { + @Override + public void applyColor(int color) { + sendFontBackColorChange(color, false); + } + + @Override + public void updateColorPickerPosition(int color) { + if (backColorPickerAdapter == null) { + return; + } + if (color == COLOR_AUTO) { + backColorPickerAdapter.unselectColors(); + changeFontBackColorBoxColor(Color.TRANSPARENT); + return; + } + final int colorWithAlpha = color | 0xFF000000; + backColorPickerAdapter.findSelectedTextColor(colorWithAlpha); + changeFontBackColorBoxColor(colorWithAlpha); + } + }; + + private void changeFontColorBoxColor(final int color){ + final ImageButton fontColorPickerButton = mActivity.findViewById(R.id.font_color_picker_button); + + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + fontColorPickerButton.setBackgroundColor(color); + } + }); + } + + private void changeFontBackColorBoxColor(final int color){ + final ImageButton fontBackColorPickerButton = mActivity.findViewById(R.id.font_back_color_picker_button); + + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + fontBackColorPickerButton.setBackgroundColor(color); + } + }); + } + + private void sendFontChange(String fontName) { + try { + JSONObject json = new JSONObject(); + JSONObject valueJson = new JSONObject(); + valueJson.put("type", "string"); + valueJson.put("value", fontName); + json.put("CharFontName.FamilyName", valueJson); + + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:CharFontName", json.toString())); + + } catch (JSONException e) { + e.printStackTrace(); + } + } + + private void sendFontSizeChange(String fontSize) { + try { + JSONObject json = new JSONObject(); + JSONObject valueJson = new JSONObject(); + valueJson.put("type", "float"); + valueJson.put("value", fontSize); + json.put("FontHeight.Height", valueJson); + + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:FontHeight", json.toString())); + + } catch (JSONException e) { + e.printStackTrace(); + } + } + + private void sendFontColorChange(int color, boolean keepAlpha){ + try { + JSONObject json = new JSONObject(); + JSONObject valueJson = new JSONObject(); + valueJson.put("type", "long"); + valueJson.put("value", keepAlpha ? color : 0x00FFFFFF & color); + json.put("Color", valueJson); + + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:Color", json.toString())); + changeFontColorBoxColor(color); + + } catch (JSONException e) { + e.printStackTrace(); + } + } + + /* + * 0x00FFFFFF & color operation removes the alpha which is FF, + * if we don't remove it, the color value becomes negative which is not recognized by LOK + */ + private void sendFontBackColorChange(int color, boolean keepAlpha) { + try { + JSONObject json = new JSONObject(); + JSONObject valueJson = new JSONObject(); + valueJson.put("type", "long"); + valueJson.put("value", keepAlpha ? color : 0x00FFFFFF & color); + if(mActivity.getTileProvider().isSpreadsheet()){ + json.put("BackgroundColor", valueJson); + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:BackgroundColor", json.toString())); + }else { + json.put("CharBackColor", valueJson); + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:CharBackColor", json.toString())); + } + + changeFontBackColorBoxColor(color); + + } catch (JSONException e) { + e.printStackTrace(); + } + } + + + @Override + public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) { + if (mFontList.isEmpty() || !mFontNameSpinnerSet) + return; + if (parent == mActivity.findViewById(R.id.font_name_spinner)) { + String currentFontSelected = parent.getItemAtPosition(pos).toString(); + if (!currentFontSelected.equals(mCurrentFontSelected)) { + mCurrentFontSelected = currentFontSelected; + sendFontChange(mCurrentFontSelected); + } + } else if (parent == mActivity.findViewById(R.id.font_size_spinner)) { + String currentFontSizeSelected = parent.getItemAtPosition(pos).toString(); + if (!currentFontSizeSelected.equals(mCurrentFontSizeSelected)) { + mCurrentFontSizeSelected = currentFontSizeSelected; + sendFontSizeChange(mCurrentFontSizeSelected); + } + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + // Do nothing. + } + + public void parseJson(String json) { + mFontList.clear(); + mAllFontSizes.clear(); + try { + JSONObject jObject = new JSONObject(json); + JSONObject jObject2 = jObject.getJSONObject("commandValues"); + Iterator<String> keys = jObject2.keys(); + ArrayList<String> fontSizes; + while (keys.hasNext()) { + String key = keys.next(); + mFontList.add(key); + JSONArray array = jObject2.getJSONArray(key); + fontSizes = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + fontSizes.add(array.getString(i)); + } + mAllFontSizes.put(key, fontSizes); + } + } catch (JSONException e) { + e.printStackTrace(); + } + } + + public void setupFontViews() { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + setupFontNameSpinner(); + setupFontSizeSpinner(); + setupColorPicker(); + setupBackColorPicker(); + } + }); + } + + private void setupFontNameSpinner() { + Spinner fontSpinner = mActivity.findViewById(R.id.font_name_spinner); + ArrayAdapter<String> dataAdapter = new ArrayAdapter<>(mActivity, android.R.layout.simple_spinner_item, mFontList); + dataAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + fontSpinner.setAdapter(dataAdapter); + } + + private void setupFontSizeSpinner() { + Spinner fontSizeSpinner = mActivity.findViewById(R.id.font_size_spinner); + ArrayAdapter<String> dataAdapter = new ArrayAdapter<>(mActivity, android.R.layout.simple_spinner_item, mFontSizes); + dataAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + fontSizeSpinner.setAdapter(dataAdapter); + } + + private void setupColorPicker(){ + LinearLayout colorPickerLayout = mActivity.findViewById(R.id.toolbar_color_picker); + + RecyclerView recyclerView = colorPickerLayout.findViewById(R.id.fontColorView); + GridLayoutManager gridLayoutManager = new GridLayoutManager(mActivity, 11, GridLayoutManager.VERTICAL, true); + recyclerView.setHasFixedSize(true); + recyclerView.setLayoutManager(gridLayoutManager); + + + + RecyclerView recyclerView2 = colorPickerLayout.findViewById(R.id.fontColorViewSub); + GridLayoutManager gridLayoutManager2 = new GridLayoutManager(mActivity,4); + recyclerView2.setHasFixedSize(true); + recyclerView2.addItemDecoration(new RecyclerView.ItemDecoration() { + + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { + outRect.bottom = 3; + outRect.top = 3; + outRect.left = 3; + outRect.right = 3; + } + }); + recyclerView2.setLayoutManager(gridLayoutManager2); + + ColorPaletteAdapter colorPaletteAdapter = new ColorPaletteAdapter(mActivity, colorPaletteListener); + recyclerView2.setAdapter(colorPaletteAdapter); + + this.colorPickerAdapter = new ColorPickerAdapter(mActivity, colorPaletteAdapter, colorPaletteListener); + recyclerView.setAdapter(colorPickerAdapter); + RelativeLayout fontColorPicker = mActivity.findViewById(R.id.font_color_picker); + ImageButton fontColorPickerButton = mActivity.findViewById(R.id.font_color_picker_button); + View.OnClickListener clickListener = new View.OnClickListener(){ + @Override + public void onClick(View view) { + toolBarBottomBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + colorPickerBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); + mActivity.findViewById(R.id.search_toolbar).setVisibility(View.GONE); + } + }; + LinearLayout toolbarBottomLayout = mActivity.findViewById(R.id.toolbar_bottom); + colorPickerBehavior = BottomSheetBehavior.from(colorPickerLayout); + toolBarBottomBehavior = BottomSheetBehavior.from(toolbarBottomLayout); + + ImageButton pickerGoBackButton = colorPickerLayout.findViewById(R.id.button_go_back_color_picker); + pickerGoBackButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + toolBarBottomBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); + colorPickerBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + } + }); + + + fontColorPicker.setOnClickListener(clickListener); + fontColorPickerButton.setOnClickListener(clickListener); + + final Button autoColorButton = colorPickerLayout.findViewById(R.id.button_auto_color); + autoColorButton.setOnClickListener(view -> { + sendFontColorChange(COLOR_AUTO, true); + }); + } + + private void setupBackColorPicker(){ + LinearLayout backColorPickerLayout = mActivity.findViewById(R.id.toolbar_back_color_picker); + + RecyclerView recyclerView = backColorPickerLayout.findViewById(R.id.fontColorView); + GridLayoutManager gridLayoutManager = new GridLayoutManager(mActivity, 11, GridLayoutManager.VERTICAL, true); + recyclerView.setHasFixedSize(true); + recyclerView.setLayoutManager(gridLayoutManager); + + + + RecyclerView recyclerView2 = backColorPickerLayout.findViewById(R.id.fontColorViewSub); + GridLayoutManager gridLayoutManager2 = new GridLayoutManager(mActivity,4); + recyclerView2.setHasFixedSize(true); + recyclerView2.addItemDecoration(new RecyclerView.ItemDecoration() { + + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { + outRect.bottom = 3; + outRect.top = 3; + outRect.left = 3; + outRect.right = 3; + } + }); + recyclerView2.setLayoutManager(gridLayoutManager2); + + ColorPaletteAdapter colorPaletteAdapter = new ColorPaletteAdapter(mActivity, backColorPaletteListener); + recyclerView2.setAdapter(colorPaletteAdapter); + + this.backColorPickerAdapter = new ColorPickerAdapter(mActivity, colorPaletteAdapter, backColorPaletteListener); + recyclerView.setAdapter(backColorPickerAdapter); + RelativeLayout fontColorPicker = mActivity.findViewById(R.id.font_back_color_picker); + ImageButton fontColorPickerButton = mActivity.findViewById(R.id.font_back_color_picker_button); + View.OnClickListener clickListener = new View.OnClickListener(){ + @Override + public void onClick(View view) { + toolBarBottomBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + backColorPickerBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); + mActivity.findViewById(R.id.search_toolbar).setVisibility(View.GONE); + } + }; + LinearLayout toolbarBottomLayout = mActivity.findViewById(R.id.toolbar_bottom); + backColorPickerBehavior = BottomSheetBehavior.from(backColorPickerLayout); + toolBarBottomBehavior = BottomSheetBehavior.from(toolbarBottomLayout); + + ImageButton pickerGoBackButton = backColorPickerLayout.findViewById(R.id.button_go_back_color_picker); + pickerGoBackButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + toolBarBottomBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); + backColorPickerBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + } + }); + + + fontColorPicker.setOnClickListener(clickListener); + fontColorPickerButton.setOnClickListener(clickListener); + + final Button autoColorButton = backColorPickerLayout.findViewById(R.id.button_auto_color); + autoColorButton.setOnClickListener(view -> { + sendFontBackColorChange(COLOR_AUTO, true); + }); + } + + public void selectFont(final String fontName) { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + selectFontCurrentThread(fontName); + } + }); + } + + private void selectFontCurrentThread(String fontName) { + Spinner spinner = mActivity.findViewById(R.id.font_name_spinner); + if (!mFontNameSpinnerSet) { + spinner.setOnItemSelectedListener(this); + mFontNameSpinnerSet = true; + } + + if (fontName.equals(mCurrentFontSelected)) + return; + + int position = mFontList.indexOf(fontName); + if (position != -1) { + mCurrentFontSelected = fontName; + spinner.setSelection(position,false); + } + + resetFontSizes(fontName); + } + + private void resetFontSizes(String fontName) { + if (mAllFontSizes.get(fontName) != null) { + mFontSizes.clear(); + mFontSizes.addAll(mAllFontSizes.get(fontName)); + Spinner spinner = mActivity.findViewById(R.id.font_size_spinner); + ArrayAdapter<?> arrayAdapter = (ArrayAdapter<?>)spinner.getAdapter(); + arrayAdapter.notifyDataSetChanged(); + } + } + + public void selectFontSize(final String fontSize) { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + selectFontSizeCurrentThread(fontSize); + } + }); + } + + private void selectFontSizeCurrentThread(String fontSize) { + Spinner spinner = mActivity.findViewById(R.id.font_size_spinner); + if (!mFontSizeSpinnerSet) { + spinner.setOnItemSelectedListener(this); + mFontSizeSpinnerSet = true; + } + + if (fontSize.equals(mCurrentFontSizeSelected)) + return; + + int position = mFontSizes.indexOf(fontSize); + if (position != -1) { + mCurrentFontSizeSelected = fontSize; + spinner.setSelection(position, false); + } + } +} diff --git a/android/source/src/java/org/libreoffice/FormattingController.java b/android/source/src/java/org/libreoffice/FormattingController.java new file mode 100644 index 0000000000..49e81eb697 --- /dev/null +++ b/android/source/src/java/org/libreoffice/FormattingController.java @@ -0,0 +1,502 @@ +package org.libreoffice; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.os.Environment; +import android.provider.MediaStore; +import com.google.android.material.snackbar.Snackbar; +import androidx.core.content.FileProvider; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageButton; +import android.widget.TextView; + +import org.json.JSONException; +import org.json.JSONObject; +import org.libreoffice.kit.Document; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +import static org.libreoffice.SearchController.addProperty; + +class FormattingController implements View.OnClickListener { + private static final String LOGTAG = ToolbarController.class.getSimpleName(); + private static final int TAKE_PHOTO = 1; + private static final int SELECT_PHOTO = 2; + private static final int IMAGE_BUFFER_SIZE = 4 * 1024; + + private final LibreOfficeMainActivity mContext; + private String mCurrentPhotoPath; + + FormattingController(LibreOfficeMainActivity context) { + mContext = context; + + mContext.findViewById(R.id.button_insertFormatListBullets).setOnClickListener(this); + mContext.findViewById(R.id.button_insertFormatListNumbering).setOnClickListener(this); + mContext.findViewById(R.id.button_increaseIndent).setOnClickListener(this); + mContext.findViewById(R.id.button_decreaseIndent).setOnClickListener(this); + + mContext.findViewById(R.id.button_bold).setOnClickListener(this); + mContext.findViewById(R.id.button_italic).setOnClickListener(this); + mContext.findViewById(R.id.button_strikethrough).setOnClickListener(this); + mContext.findViewById(R.id.button_underlined).setOnClickListener(this); + mContext.findViewById(R.id.button_clearformatting).setOnClickListener(this); + + mContext.findViewById(R.id.button_align_left).setOnClickListener(this); + mContext.findViewById(R.id.button_align_center).setOnClickListener(this); + mContext.findViewById(R.id.button_align_right).setOnClickListener(this); + mContext.findViewById(R.id.button_align_justify).setOnClickListener(this); + + mContext.findViewById(R.id.button_insert_line).setOnClickListener(this); + mContext.findViewById(R.id.button_insert_rect).setOnClickListener(this); + mContext.findViewById(R.id.button_insert_picture).setOnClickListener(this); + + mContext.findViewById(R.id.button_insert_table).setOnClickListener(this); + mContext.findViewById(R.id.button_delete_table).setOnClickListener(this); + + mContext.findViewById(R.id.button_font_shrink).setOnClickListener(this); + mContext.findViewById(R.id.button_font_grow).setOnClickListener(this); + + mContext.findViewById(R.id.button_subscript).setOnClickListener(this); + mContext.findViewById(R.id.button_superscript).setOnClickListener(this); + } + + @Override + public void onClick(View view) { + ImageButton button = (ImageButton) view; + + if (button.isSelected()) { + button.getBackground().setState(new int[]{-android.R.attr.state_selected}); + } else { + button.getBackground().setState(new int[]{android.R.attr.state_selected}); + } + + final int buttonId = button.getId(); + if (buttonId == R.id.button_insertFormatListBullets) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:DefaultBullet")); + } else if (buttonId == R.id.button_insertFormatListNumbering) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:DefaultNumbering")); + } else if (buttonId == R.id.button_increaseIndent) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:IncrementIndent")); + } else if (buttonId == R.id.button_decreaseIndent) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:DecrementIndent")); + } else if (buttonId == R.id.button_bold) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:Bold")); + } else if (buttonId == R.id.button_italic) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:Italic")); + } else if (buttonId == R.id.button_strikethrough) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:Strikeout")); + } else if (buttonId == R.id.button_clearformatting) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:ResetAttributes")); + } else if (buttonId == R.id.button_underlined) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:UnderlineDouble")); + } else if (buttonId == R.id.button_align_left) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:LeftPara")); + } else if (buttonId == R.id.button_align_center) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:CenterPara")); + } else if (buttonId == R.id.button_align_right) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:RightPara")); + } else if (buttonId == R.id.button_align_justify) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:JustifyPara")); + } else if (buttonId == R.id.button_insert_line) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:Line")); + } else if (buttonId == R.id.button_insert_rect) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:Rect")); + } else if (buttonId == R.id.button_font_shrink) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:Shrink")); + } else if (buttonId == R.id.button_font_grow) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:Grow")); + } else if (buttonId == R.id.button_subscript) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:SubScript")); + }else if (buttonId == R.id.button_superscript) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:SuperScript")); + } else if (buttonId == R.id.button_insert_picture) { + insertPicture(); + } else if (buttonId == R.id.button_insert_table) { + insertTable(); + } else if (buttonId == R.id.button_delete_table) { + deleteTable(); + } + } + + void onToggleStateChanged(final int type, final boolean selected) { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + Integer buttonId; + switch (type) { + case Document.BOLD: + buttonId = R.id.button_bold; + break; + case Document.ITALIC: + buttonId = R.id.button_italic; + break; + case Document.UNDERLINE: + buttonId = R.id.button_underlined; + break; + case Document.STRIKEOUT: + buttonId = R.id.button_strikethrough; + break; + case Document.ALIGN_LEFT: + buttonId = R.id.button_align_left; + break; + case Document.ALIGN_CENTER: + buttonId = R.id.button_align_center; + break; + case Document.ALIGN_RIGHT: + buttonId = R.id.button_align_right; + break; + case Document.ALIGN_JUSTIFY: + buttonId = R.id.button_align_justify; + break; + case Document.BULLET_LIST: + buttonId = R.id.button_insertFormatListBullets; + break; + case Document.NUMBERED_LIST: + buttonId = R.id.button_insertFormatListNumbering; + break; + default: + Log.e(LOGTAG, "Uncaptured state change type: " + type); + return; + } + + ImageButton button = mContext.findViewById(buttonId); + button.setSelected(selected); + if (selected) { + button.getBackground().setState(new int[]{android.R.attr.state_selected}); + } else { + button.getBackground().setState(new int[]{-android.R.attr.state_selected}); + } + } + }); + } + + private void insertPicture() { + AlertDialog.Builder builder = new AlertDialog.Builder(mContext); + String[] options = {mContext.getResources().getString(R.string.take_photo), + mContext.getResources().getString(R.string.select_photo)}; + builder.setItems(options, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + switch (which) { + case 0: + dispatchTakePictureIntent(); + break; + case 1: + sendImagePickingIntent(); + break; + default: + sendImagePickingIntent(); + } + } + }); + builder.show(); + } + + private void insertTable() { + final AlertDialog.Builder insertTableBuilder = new AlertDialog.Builder(mContext); + insertTableBuilder.setTitle(R.string.insert_table); + LayoutInflater layoutInflater = mContext.getLayoutInflater(); + View numberPicker = layoutInflater.inflate(R.layout.number_picker, null); + final int minValue = 1; + final int maxValue = 20; + TextView npRowPositive = numberPicker.findViewById(R.id.number_picker_rows_positive); + TextView npRowNegative = numberPicker.findViewById(R.id.number_picker_rows_negative); + TextView npColPositive = numberPicker.findViewById(R.id.number_picker_cols_positive); + TextView npColNegative = numberPicker.findViewById(R.id.number_picker_cols_negative); + final TextView npRowCount = numberPicker.findViewById(R.id.number_picker_row_count); + final TextView npColCount = numberPicker.findViewById(R.id.number_picker_col_count); + + View.OnClickListener positiveButtonClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + int rowCount = Integer.parseInt(npRowCount.getText().toString()); + int colCount = Integer.parseInt(npColCount.getText().toString()); + final int id = v.getId(); + if (id == R.id.number_picker_rows_positive && rowCount < maxValue) { + npRowCount.setText(String.valueOf(++rowCount)); + } else if (id == R.id.number_picker_cols_positive && colCount < maxValue) { + npColCount.setText(String.valueOf(++colCount)); + } + } + }; + + View.OnClickListener negativeButtonClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + int rowCount = Integer.parseInt(npRowCount.getText().toString()); + int colCount = Integer.parseInt(npColCount.getText().toString()); + final int id = v.getId(); + if (id == R.id.number_picker_rows_negative && rowCount > minValue) { + npRowCount.setText(String.valueOf(--rowCount)); + } else if (id == R.id.number_picker_cols_negative && colCount > minValue) { + npColCount.setText(String.valueOf(--colCount)); + } + } + }; + + npRowPositive.setOnClickListener(positiveButtonClickListener); + npColPositive.setOnClickListener(positiveButtonClickListener); + npRowNegative.setOnClickListener(negativeButtonClickListener); + npColNegative.setOnClickListener(negativeButtonClickListener); + + insertTableBuilder.setView(numberPicker); + insertTableBuilder.setNeutralButton(R.string.alert_cancel, null); + insertTableBuilder.setPositiveButton(R.string.alert_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + + try { + JSONObject cols = new JSONObject(); + cols.put("type", "long"); + cols.put("value", Integer.valueOf(npColCount.getText().toString())); + JSONObject rows = new JSONObject(); + rows.put("type","long"); + rows.put("value",Integer.valueOf(npRowCount.getText().toString())); + JSONObject params = new JSONObject(); + params.put("Columns", cols); + params.put("Rows", rows); + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:InsertTable",params.toString())); + LibreOfficeMainActivity.setDocumentChanged(true); + } catch (JSONException e) { + e.printStackTrace(); + } + + } + }); + + AlertDialog.Builder insertBuilder = new AlertDialog.Builder(mContext); + insertBuilder.setTitle(R.string.select_insert_options); + insertBuilder.setNeutralButton(R.string.alert_cancel, null); + final int[] selectedItem = new int[1]; + insertBuilder.setSingleChoiceItems(mContext.getResources().getStringArray(R.array.insertrowscolumns), -1, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + selectedItem[0] = which; + } + }); + insertBuilder.setPositiveButton(R.string.alert_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + switch (selectedItem[0]){ + case 0: + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:InsertRowsBefore")); + LibreOfficeMainActivity.setDocumentChanged(true); + break; + case 1: + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:InsertRowsAfter")); + LibreOfficeMainActivity.setDocumentChanged(true); + break; + case 2: + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:InsertColumnsBefore")); + LibreOfficeMainActivity.setDocumentChanged(true); + break; + case 3: + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:InsertColumnsAfter")); + LibreOfficeMainActivity.setDocumentChanged(true); + break; + case 4: + insertTableBuilder.show(); + break; + + } + } + }); + insertBuilder.show(); + } + + private void deleteTable() { + AlertDialog.Builder deleteBuilder = new AlertDialog.Builder(mContext); + deleteBuilder.setTitle(R.string.select_delete_options); + deleteBuilder.setNeutralButton(R.string.alert_cancel,null); + final int[] selectedItem = new int[1]; + deleteBuilder.setSingleChoiceItems(mContext.getResources().getStringArray(R.array.deleterowcolumns), -1, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + selectedItem[0] = which; + } + }); + deleteBuilder.setPositiveButton(R.string.alert_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + switch (selectedItem[0]){ + case 0: + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:DeleteRows")); + LibreOfficeMainActivity.setDocumentChanged(true); + break; + case 1: + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:DeleteColumns")); + LibreOfficeMainActivity.setDocumentChanged(true); + break; + case 2: + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:DeleteTable")); + LibreOfficeMainActivity.setDocumentChanged(true); + break; + } + } + }); + deleteBuilder.show(); + } + + private void sendImagePickingIntent() { + Intent intent = new Intent(Intent.ACTION_PICK); + intent.setType("image/*"); + mContext.startActivityForResult(Intent.createChooser(intent, + mContext.getResources().getString(R.string.select_photo_title)), SELECT_PHOTO); + } + + private void dispatchTakePictureIntent() { + if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA)) { + Snackbar.make(mContext.findViewById(R.id.button_insert_picture), + mContext.getResources().getString(R.string.no_camera_found), Snackbar.LENGTH_SHORT).show(); + return; + } + Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + // Ensure that there's a camera activity to handle the intent + if (takePictureIntent.resolveActivity(mContext.getPackageManager()) != null) { + // Create the File where the photo should go + File photoFile = null; + try { + photoFile = createImageFile(); + } catch (IOException ex) { + ex.printStackTrace(); + } + // Continue only if the File was successfully created + if (photoFile != null) { + Uri photoURI = FileProvider.getUriForFile(mContext, + mContext.getPackageName() + ".fileprovider", + photoFile); + takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI); + // Grant permissions to potential photo/camera apps (for some Android versions) + List<ResolveInfo> resInfoList = mContext.getPackageManager() + .queryIntentActivities(takePictureIntent, PackageManager.MATCH_DEFAULT_ONLY); + for (ResolveInfo resolveInfo : resInfoList) { + String packageName = resolveInfo.activityInfo.packageName; + mContext.grantUriPermission(packageName, photoURI, Intent.FLAG_GRANT_WRITE_URI_PERMISSION + | Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + mContext.startActivityForResult(takePictureIntent, TAKE_PHOTO); + } + } + } + + void handleActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == TAKE_PHOTO && resultCode == Activity.RESULT_OK) { + compressAndInsertImage(); + } else if (requestCode == SELECT_PHOTO && resultCode == Activity.RESULT_OK) { + getFileFromURI(data.getData()); + compressAndInsertImage(); + } + } + + void compressAndInsertImage() { + AlertDialog.Builder builder = new AlertDialog.Builder(mContext); + String[] options = {mContext.getResources().getString(R.string.compress_photo_smallest_size), + mContext.getResources().getString(R.string.compress_photo_medium_size), + mContext.getResources().getString(R.string.compress_photo_max_quality), + mContext.getResources().getString(R.string.compress_photo_no_compress)}; + builder.setTitle(mContext.getResources().getString(R.string.compress_photo_title)); + builder.setItems(options, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + int compressGrade; + switch (which) { + case 0: + compressGrade = 0; + break; + case 1: + compressGrade = 50; + break; + case 2: + compressGrade = 100; + break; + case 3: + compressGrade = -1; + break; + default: + compressGrade = -1; + } + compressImage(compressGrade); + sendInsertGraphic(); + } + }); + builder.show(); + } + + private void getFileFromURI(Uri uri) { + try { + InputStream input = mContext.getContentResolver().openInputStream(uri); + mCurrentPhotoPath = createImageFile().getAbsolutePath(); + FileOutputStream output = new FileOutputStream(mCurrentPhotoPath); + if (input != null) { + byte[] buffer = new byte[IMAGE_BUFFER_SIZE]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + input.close(); + } + output.flush(); + output.close(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + private void sendInsertGraphic() { + JSONObject rootJson = new JSONObject(); + try { + addProperty(rootJson, "FileName", "string", "file://" + mCurrentPhotoPath); + } catch (JSONException ex) { + ex.printStackTrace(); + } + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:InsertGraphic", rootJson.toString())); + LOKitShell.sendEvent(new LOEvent(LOEvent.REFRESH)); + mContext.setDocumentChanged(true); + } + + private void compressImage(int grade) { + if (grade < 0 || grade > 100) { + return; + } + mContext.showProgressSpinner(); + Bitmap bmp = BitmapFactory.decodeFile(mCurrentPhotoPath); + try { + mCurrentPhotoPath = createImageFile().getAbsolutePath(); + FileOutputStream out = new FileOutputStream(mCurrentPhotoPath); + bmp.compress(Bitmap.CompressFormat.JPEG, grade, out); + } catch (Exception e) { + e.printStackTrace(); + } + mContext.hideProgressSpinner(); + } + + private File createImageFile() throws IOException { + // Create an image file name + String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()); + String imageFileName = "JPEG_" + timeStamp + "_"; + File storageDir = mContext.getExternalFilesDir(Environment.DIRECTORY_PICTURES); + File image = File.createTempFile( + imageFileName, /* prefix */ + ".jpg", /* suffix */ + storageDir /* directory */ + ); + // Save a file: path for use with ACTION_VIEW intents + mCurrentPhotoPath = image.getAbsolutePath(); + return image; + } +} diff --git a/android/source/src/java/org/libreoffice/InvalidationHandler.java b/android/source/src/java/org/libreoffice/InvalidationHandler.java new file mode 100644 index 0000000000..c48127cce6 --- /dev/null +++ b/android/source/src/java/org/libreoffice/InvalidationHandler.java @@ -0,0 +1,768 @@ +package org.libreoffice; + +import android.content.Intent; +import android.graphics.PointF; +import android.graphics.RectF; +import android.net.Uri; +import android.util.Log; +import android.widget.EditText; +import android.widget.Toast; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.libreoffice.canvas.SelectionHandle; +import org.libreoffice.kit.Document; +import org.libreoffice.kit.Office; +import org.libreoffice.overlay.DocumentOverlay; +import org.mozilla.gecko.gfx.GeckoLayerClient; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Parses (interprets) and handles invalidation messages from LibreOffice. + */ +public class InvalidationHandler implements Document.MessageCallback, Office.MessageCallback { + private static final String LOGTAG = InvalidationHandler.class.getSimpleName(); + private final DocumentOverlay mDocumentOverlay; + private final GeckoLayerClient mLayerClient; + private OverlayState mState; + private boolean mKeyEvent = false; + private final LibreOfficeMainActivity mContext; + + private int currentTotalPageNumber = 0; // total page number of the current document + + public InvalidationHandler(LibreOfficeMainActivity context) { + mContext = context; + mDocumentOverlay = mContext.getDocumentOverlay(); + mLayerClient = mContext.getLayerClient(); + mState = OverlayState.NONE; + } + + /** + * Processes callback message + * + * @param messageID - ID of the message + * @param payload - additional invalidation message payload + */ + @Override + public void messageRetrieved(int messageID, String payload) { + if (!LOKitShell.isEditingEnabled()) { + // enable handling of hyperlinks and search result even in the Viewer + if (messageID != Document.CALLBACK_INVALIDATE_TILES + && messageID != Document.CALLBACK_DOCUMENT_PASSWORD + && messageID != Document.CALLBACK_HYPERLINK_CLICKED + && messageID != Document.CALLBACK_SEARCH_RESULT_SELECTION + && messageID != Document.CALLBACK_SC_FOLLOW_JUMP + && messageID != Document.CALLBACK_TEXT_SELECTION + && messageID != Document.CALLBACK_TEXT_SELECTION_START + && messageID != Document.CALLBACK_TEXT_SELECTION_END) + return; + } + switch (messageID) { + case Document.CALLBACK_INVALIDATE_TILES: + invalidateTiles(payload); + break; + case Document.CALLBACK_UNO_COMMAND_RESULT: + unoCommandResult(payload); + break; + case Document.CALLBACK_INVALIDATE_VISIBLE_CURSOR: + invalidateCursor(payload); + break; + case Document.CALLBACK_TEXT_SELECTION: + textSelection(payload); + break; + case Document.CALLBACK_TEXT_SELECTION_START: + textSelectionStart(payload); + break; + case Document.CALLBACK_TEXT_SELECTION_END: + textSelectionEnd(payload); + break; + case Document.CALLBACK_CURSOR_VISIBLE: + cursorVisibility(payload); + break; + case Document.CALLBACK_GRAPHIC_SELECTION: + graphicSelection(payload); + break; + case Document.CALLBACK_HYPERLINK_CLICKED: + if (!payload.startsWith("http://") && !payload.startsWith("https://")) { + payload = "http://" + payload; + } + Intent urlIntent = new Intent(Intent.ACTION_VIEW); + urlIntent.setData(Uri.parse(payload)); + mContext.startActivity(urlIntent); + break; + case Document.CALLBACK_STATE_CHANGED: + stateChanged(payload); + break; + case Document.CALLBACK_SEARCH_RESULT_SELECTION: + searchResultSelection(payload); + // when doing a search, CALLBACK_SEARCH_RESULT_SELECTION is called in addition + // to the CALLBACK_TEXT_SELECTION{,_START,_END} callbacks and the handling of + // the previous 3 makes the cursor shown in addition to the selection rectangle, + // so hide the cursor again to just show the selection rectangle for the search result + mDocumentOverlay.hideCursor(); + mDocumentOverlay.hideHandle(SelectionHandle.HandleType.MIDDLE); + mDocumentOverlay.hideHandle(SelectionHandle.HandleType.START); + mDocumentOverlay.hideHandle(SelectionHandle.HandleType.END); + break; + case Document.CALLBACK_SEARCH_NOT_FOUND: + Log.d(LOGTAG, "LOK_CALLBACK: Search not found."); + // this callback is never caught. Hope someone fix this. + break; + case Document.CALLBACK_CELL_CURSOR: + invalidateCellCursor(payload); + break; + case Document.CALLBACK_SC_FOLLOW_JUMP: + jumpToCell(payload); + break; + case Document.CALLBACK_INVALIDATE_HEADER: + invalidateHeader(); + break; + case Document.CALLBACK_CELL_ADDRESS: + cellAddress(payload); + break; + case Document.CALLBACK_CELL_FORMULA: + cellFormula(payload); + break; + case Document.CALLBACK_DOCUMENT_PASSWORD: + documentPassword(); + break; + case Document.CALLBACK_DOCUMENT_SIZE_CHANGED: + pageSizeChanged(payload); + default: + + Log.d(LOGTAG, "LOK_CALLBACK uncaught: " + messageID + " : " + payload); + } + } + + private void unoCommandResult(String payload) { + try { + JSONObject payloadObject = new JSONObject(payload); + if (payloadObject.getString("commandName").equals(".uno:Save")) { + if (payloadObject.getString("success").equals("true")) { + mContext.saveFileToOriginalSource(); + } + }else if(payloadObject.getString("commandName").equals(".uno:Name") || + payloadObject.getString("commandName").equals(".uno:RenamePage")){ + //success returns false even though its true for some reason, + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + mContext.getTileProvider().resetParts(); + mContext.getDocumentPartViewListAdapter().notifyDataSetChanged(); + LibreOfficeMainActivity.setDocumentChanged(true); + Toast.makeText(mContext, mContext.getString(R.string.part_name_changed), Toast.LENGTH_SHORT).show(); + } + }); + } else if(payloadObject.getString("commandName").equals(".uno:Remove") || + payloadObject.getString("commandName").equals(".uno:DeletePage") ) { + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + mContext.getTileProvider().resetParts(); + mContext.getDocumentPartViewListAdapter().notifyDataSetChanged(); + LibreOfficeMainActivity.setDocumentChanged(true); + Toast.makeText(mContext, mContext.getString(R.string.part_deleted), Toast.LENGTH_SHORT).show(); + } + }); + } + }catch(JSONException e){ + e.printStackTrace(); + } + } + + private void cellFormula(final String payload) { + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + ((EditText)mContext.findViewById(R.id.calc_formula)).setText(payload); + } + }); + } + + private void cellAddress(final String payload) { + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + ((EditText)mContext.findViewById(R.id.calc_address)).setText(payload); + } + }); + } + + private void invalidateHeader() { + LOKitShell.sendEvent(new LOEvent(LOEvent.UPDATE_CALC_HEADERS)); + } + + private void documentPassword() { + mContext.setPasswordProtected(true); + mContext.promptForPassword(); + synchronized (this) { + try { + this.wait(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + mContext.setPassword(); + } + + private void invalidateCellCursor(String payload) { + RectF cellCursorRect = convertPayloadToRectangle(payload); + + if (cellCursorRect != null) { + mDocumentOverlay.showCellSelection(cellCursorRect); + moveViewportToMakeSelectionVisible(cellCursorRect); + } + } + + private void jumpToCell(String payload) { + RectF cellCursorRect = convertPayloadCellToRectangle(payload); + + if (cellCursorRect != null) { + moveViewportToMakeSelectionVisible(cellCursorRect); + } + } + + /** + * Handles the search result selection message, which is a JSONObject + * + * @param payload + */ + private void searchResultSelection(String payload) { + RectF selectionRectangle = null; + try { + JSONObject collectiveResult = new JSONObject(payload); + JSONArray searchResult = collectiveResult.getJSONArray("searchResultSelection"); + if (searchResult.length() == 1) { + String rectangle = searchResult.getJSONObject(0).getString("rectangles"); + selectionRectangle = convertPayloadToRectangle(rectangle); + } + } catch (JSONException e) { + e.printStackTrace(); + } + if (selectionRectangle != null) { + moveViewportToMakeSelectionVisible(selectionRectangle); + } + } + + /** + * Move the viewport to show the selection. The selection will appear at the + * viewport position depending on where the selection is relative to the + * viewport (either selection is above, below, on left or right). The difference + * between this method and moveViewportToMakeCursorVisible() is that this method + * takes into account the width and height of the selection and zooms out + * accordingly. + * + * @param selectionRectangle - selection position on the document + */ + public void moveViewportToMakeSelectionVisible(RectF selectionRectangle) { + RectF moveToRect = mLayerClient.getViewportMetrics().getCssViewport(); + if (moveToRect.contains(selectionRectangle)) { + return; + } + + float newLeft = moveToRect.left; + float newTop = moveToRect.top; + + // if selection rectangle is wider or taller than current viewport, we need to zoom out + float oldZoom = mLayerClient.getViewportMetrics().getZoomFactor(); + float widthRatio = 1f; + float heightRatio = 1f; + if (moveToRect.width() < selectionRectangle.width()) { + widthRatio = selectionRectangle.width() / moveToRect.width() / 0.85f; // 0.85f gives some margin (must < 0.9) + } + if (moveToRect.height() < selectionRectangle.height()) { + heightRatio = selectionRectangle.height() / moveToRect.height() / 0.45f; // 0.45f gives some margin (must < 0.5) + } + float newZoom = widthRatio > heightRatio ? oldZoom/widthRatio : oldZoom/heightRatio; + + // if selection is out of viewport we need to adjust accordingly + if (selectionRectangle.right < moveToRect.left || selectionRectangle.left < moveToRect.left) { + newLeft = selectionRectangle.left - (moveToRect.width() * 0.1f) * oldZoom / newZoom; // 0.1f gives left margin + } else if (selectionRectangle.right > moveToRect.right || selectionRectangle.left > moveToRect.right) { + newLeft = selectionRectangle.right - (moveToRect.width() * 0.9f) * oldZoom / newZoom; // 0.9f gives right margin + } + + if (selectionRectangle.top < moveToRect.top || selectionRectangle.bottom < moveToRect.top) { + newTop = selectionRectangle.top - (moveToRect.height() * 0.1f) * oldZoom / newZoom; // 0.1f gives top margin + } else if (selectionRectangle.bottom > moveToRect.bottom || selectionRectangle.top > moveToRect.bottom){ + newTop = selectionRectangle.bottom - (moveToRect.height() * 0.5f) * oldZoom / newZoom; // 0.5 f gives bottom margin + } + + LOKitShell.moveViewportTo(mContext, new PointF(newLeft, newTop), newZoom); + } + + private void pageSizeChanged(String payload){ + if(mContext.getTileProvider().isTextDocument()){ + String[] bounds = payload.split(","); + int pageWidth = Integer.parseInt(bounds[0]); + int pageHeight = Integer.parseInt(bounds[1].trim()); + LOKitShell.sendEvent(new LOEvent(LOEvent.PAGE_SIZE_CHANGED, pageWidth, pageHeight)); + } + } + + private void stateChanged(String payload) { + String[] parts = payload.split("="); + if (parts.length < 2) { + Log.e(LOGTAG, "LOK_CALLBACK_STATE_CHANGED unexpected payload: " + payload); + return; + } + final String value = parts[1]; + boolean pressed = Boolean.parseBoolean(value); + if (!mContext.getTileProvider().isReady()) { + Log.w(LOGTAG, "tile provider not ready, ignoring payload "+payload); + return; + } + if (parts[0].equals(".uno:Bold")) { + mContext.getFormattingController().onToggleStateChanged(Document.BOLD, pressed); + } else if (parts[0].equals(".uno:Italic")) { + mContext.getFormattingController().onToggleStateChanged(Document.ITALIC, pressed); + } else if (parts[0].equals(".uno:Underline")) { + mContext.getFormattingController().onToggleStateChanged(Document.UNDERLINE, pressed); + } else if (parts[0].equals(".uno:Strikeout")) { + mContext.getFormattingController().onToggleStateChanged(Document.STRIKEOUT, pressed); + } else if (parts[0].equals(".uno:CharFontName")) { + mContext.getFontController().selectFont(value); + } else if (parts[0].equals(".uno:FontHeight")) { + mContext.getFontController().selectFontSize(value); + } else if (parts[0].equals(".uno:LeftPara")) { + mContext.getFormattingController().onToggleStateChanged(Document.ALIGN_LEFT, pressed); + } else if (parts[0].equals(".uno:CenterPara")) { + mContext.getFormattingController().onToggleStateChanged(Document.ALIGN_CENTER, pressed); + } else if (parts[0].equals(".uno:RightPara")) { + mContext.getFormattingController().onToggleStateChanged(Document.ALIGN_RIGHT, pressed); + } else if (parts[0].equals(".uno:JustifyPara")) { + mContext.getFormattingController().onToggleStateChanged(Document.ALIGN_JUSTIFY, pressed); + } else if (parts[0].equals(".uno:DefaultBullet")) { + mContext.getFormattingController().onToggleStateChanged(Document.BULLET_LIST, pressed); + } else if (parts[0].equals(".uno:DefaultNumbering")) { + mContext.getFormattingController().onToggleStateChanged(Document.NUMBERED_LIST, pressed); + } else if (parts[0].equals(".uno:Color")) { + mContext.getFontController().colorPaletteListener.updateColorPickerPosition(Integer.parseInt(value)); + } else if (mContext.getTileProvider().isTextDocument() && (parts[0].equals(".uno:BackColor") || parts[0].equals(".uno:CharBackColor"))) { + mContext.getFontController().backColorPaletteListener.updateColorPickerPosition(Integer.parseInt(value)); + } else if (mContext.getTileProvider().isPresentation() && parts[0].equals(".uno:CharBackColor")) { + mContext.getFontController().backColorPaletteListener.updateColorPickerPosition(Integer.parseInt(value)); + } else if (mContext.getTileProvider().isSpreadsheet() && parts[0].equals(".uno:BackgroundColor")) { + mContext.getFontController().backColorPaletteListener.updateColorPickerPosition(Integer.parseInt(value)); + } else if (parts[0].equals(".uno:StatePageNumber")) { + // get the total page number and compare to the current value and update accordingly + String[] splitStrings = parts[1].split(" "); + int totalPageNumber = Integer.valueOf(splitStrings[splitStrings.length - 1]); + if (totalPageNumber != currentTotalPageNumber) { + currentTotalPageNumber = totalPageNumber; + // update part page rectangles stored in DocumentOverlayView object + LOKitShell.sendEvent(new LOEvent(LOEvent.UPDATE_PART_PAGE_RECT)); + } + } else { + Log.d(LOGTAG, "LOK_CALLBACK_STATE_CHANGED type uncatched: " + payload); + } + } + + /** + * Parses the payload text with rectangle coordinates and converts to rectangle in pixel coordinates + * + * @param payload - invalidation message payload text + * @return rectangle in pixel coordinates + */ + public RectF convertPayloadToRectangle(String payload) { + String payloadWithoutWhitespace = payload.replaceAll("\\s", ""); // remove all whitespace from the string + + if (payloadWithoutWhitespace.isEmpty() || payloadWithoutWhitespace.equals("EMPTY")) { + return null; + } + + String[] coordinates = payloadWithoutWhitespace.split(","); + + if (coordinates.length != 4) { + return null; + } + return convertPayloadToRectangle(coordinates); + } + + /** + * Parses the payload text with rectangle coordinates and converts to rectangle in pixel coordinates + * + * @param payload - invalidation message payload text + * @return rectangle in pixel coordinates + */ + public RectF convertPayloadCellToRectangle(String payload) { + String payloadWithoutWhitespace = payload.replaceAll("\\s", ""); // remove all whitespace from the string + + if (payloadWithoutWhitespace.isEmpty() || payloadWithoutWhitespace.equals("EMPTY")) { + return null; + } + + String[] coordinates = payloadWithoutWhitespace.split(","); + + if (coordinates.length != 6 ) { + return null; + } + return convertPayloadToRectangle(coordinates); + } + + /** + * Converts rectangle coordinates to rectangle in pixel coordinates + * + * @param coordinates - the first four items defines the rectangle + * @return rectangle in pixel coordinates + */ + public RectF convertPayloadToRectangle(String[] coordinates) { + if (coordinates.length < 4 ) { + return null; + } + + int x = Integer.decode(coordinates[0]); + int y = Integer.decode(coordinates[1]); + int width = Integer.decode(coordinates[2]); + int height = Integer.decode(coordinates[3]); + + float dpi = LOKitShell.getDpi(mContext); + + return new RectF( + LOKitTileProvider.twipToPixel(x, dpi), + LOKitTileProvider.twipToPixel(y, dpi), + LOKitTileProvider.twipToPixel(x + width, dpi), + LOKitTileProvider.twipToPixel(y + height, dpi) + ); + } + + /** + * Parses the payload text with more rectangles (separated by ';') and converts to a list of rectangles. + * + * @param payload - invalidation message payload text + * @return list of rectangles + */ + public List<RectF> convertPayloadToRectangles(String payload) { + List<RectF> rectangles = new ArrayList<RectF>(); + String[] rectangleArray = payload.split(";"); + + for (String coordinates : rectangleArray) { + RectF rectangle = convertPayloadToRectangle(coordinates); + if (rectangle != null) { + rectangles.add(rectangle); + } + + } + + return rectangles; + } + + /** + * Handles the tile invalidation message + * + * @param payload + */ + private void invalidateTiles(String payload) { + RectF rectangle = convertPayloadToRectangle(payload); + if (rectangle != null) { + LOKitShell.sendTileInvalidationRequest(rectangle); + } + } + + /** + * Handles the cursor invalidation message + * + * @param payload + */ + private synchronized void invalidateCursor(String payload) { + RectF cursorRectangle = convertPayloadToRectangle(payload); + if (cursorRectangle != null) { + mDocumentOverlay.positionCursor(cursorRectangle); + mDocumentOverlay.positionHandle(SelectionHandle.HandleType.MIDDLE, cursorRectangle); + + if (mState == OverlayState.TRANSITION || mState == OverlayState.CURSOR) { + changeStateTo(OverlayState.CURSOR); + } + + if (mKeyEvent) { + moveViewportToMakeCursorVisible(cursorRectangle); + mKeyEvent = false; + } + } + } + + /** + * Move the viewport to show the cursor. The cursor will appear at the + * viewport position depending on where the cursor is relative to the + * viewport (either cursor is above, below, on left or right). + * + * @param cursorRectangle - cursor position on the document + */ + public void moveViewportToMakeCursorVisible(RectF cursorRectangle) { + RectF moveToRect = mLayerClient.getViewportMetrics().getCssViewport(); + if (moveToRect.contains(cursorRectangle)) { + return; + } + + float newLeft = moveToRect.left; + float newTop = moveToRect.top; + + if (cursorRectangle.right < moveToRect.left || cursorRectangle.left < moveToRect.left) { + newLeft = cursorRectangle.left - (moveToRect.width() * 0.1f); + } else if (cursorRectangle.right > moveToRect.right || cursorRectangle.left > moveToRect.right) { + newLeft = cursorRectangle.right - (moveToRect.width() * 0.9f); + } + + if (cursorRectangle.top < moveToRect.top || cursorRectangle.bottom < moveToRect.top) { + newTop = cursorRectangle.top - (moveToRect.height() * 0.1f); + } else if (cursorRectangle.bottom > moveToRect.bottom || cursorRectangle.top > moveToRect.bottom) { + newTop = cursorRectangle.bottom - (moveToRect.height() / 2.0f); + } + + LOKitShell.moveViewportTo(mContext, new PointF(newLeft, newTop), null); + } + + /** + * Handles the text selection start message + * + * @param payload + */ + private synchronized void textSelectionStart(String payload) { + RectF selectionRect = convertPayloadToRectangle(payload); + if (selectionRect != null) { + mDocumentOverlay.positionHandle(SelectionHandle.HandleType.START, selectionRect); + } + } + + /** + * Handles the text selection end message + * + * @param payload + */ + private synchronized void textSelectionEnd(String payload) { + RectF selectionRect = convertPayloadToRectangle(payload); + if (selectionRect != null) { + mDocumentOverlay.positionHandle(SelectionHandle.HandleType.END, selectionRect); + } + } + + /** + * Handles the text selection message + * + * @param payload + */ + private synchronized void textSelection(String payload) { + if (payload.isEmpty() || payload.equals("EMPTY")) { + if (mState == OverlayState.SELECTION) { + changeStateTo(OverlayState.TRANSITION); + } + mDocumentOverlay.changeSelections(Collections.<RectF>emptyList()); + if (mContext.getTileProvider().isSpreadsheet()) { + mDocumentOverlay.showHeaderSelection(null); + } + mContext.getToolbarController().showHideClipboardCutAndCopy(false); + } else { + List<RectF> rectangles = convertPayloadToRectangles(payload); + if (mState != OverlayState.SELECTION) { + changeStateTo(OverlayState.TRANSITION); + } + changeStateTo(OverlayState.SELECTION); + mDocumentOverlay.changeSelections(rectangles); + if (mContext.getTileProvider().isSpreadsheet()) { + mDocumentOverlay.showHeaderSelection(rectangles.get(0)); + } + String selectedText = mContext.getTileProvider().getTextSelection(""); + mContext.getToolbarController().showClipboardActions(selectedText); + } + } + + /** + * Handles the cursor visibility message + * + * @param payload + */ + private synchronized void cursorVisibility(String payload) { + if (payload.equals("true")) { + mDocumentOverlay.showCursor(); + if (mState != OverlayState.SELECTION) { + mDocumentOverlay.showHandle(SelectionHandle.HandleType.MIDDLE); + } + } else if (payload.equals("false")) { + mDocumentOverlay.hideCursor(); + mDocumentOverlay.hideHandle(SelectionHandle.HandleType.MIDDLE); + } + } + + /** + * Handles the graphic selection change message + * + * @param payload + */ + private void graphicSelection(String payload) { + if (payload.isEmpty() || payload.equals("EMPTY")) { + if (mState == OverlayState.GRAPHIC_SELECTION) { + changeStateTo(OverlayState.TRANSITION); + } + } else { + RectF rectangle = convertPayloadToRectangle(payload); + mDocumentOverlay.changeGraphicSelection(rectangle); + if (mState != OverlayState.GRAPHIC_SELECTION) { + changeStateTo(OverlayState.TRANSITION); + } + changeStateTo(OverlayState.GRAPHIC_SELECTION); + } + } + + /** + * Trigger a transition to a new overlay state. + * + * @param next - new state to transition to + */ + public synchronized void changeStateTo(OverlayState next) { + changeState(mState, next); + } + + /** + * Executes a transition from old overlay state to a new overlay state. + * + * @param previous - old state + * @param next - new state + */ + private synchronized void changeState(OverlayState previous, OverlayState next) { + mState = next; + handleGeneralChangeState(previous, next); + switch (next) { + case CURSOR: + handleCursorState(previous); + break; + case SELECTION: + handleSelectionState(previous); + break; + case GRAPHIC_SELECTION: + handleGraphicSelectionState(previous); + break; + case TRANSITION: + handleTransitionState(previous); + break; + case NONE: + handleNoneState(previous); + break; + } + } + + /** + * Handle a general transition - executed for all transitions. + */ + private void handleGeneralChangeState(OverlayState previous, OverlayState next) { + if (previous == OverlayState.NONE && + !mContext.getToolbarController().getEditModeStatus()) { + mContext.getToolbarController().switchToEditMode(); + } else if (next == OverlayState.NONE && + mContext.getToolbarController().getEditModeStatus()) { + mContext.getToolbarController().switchToViewMode(); + } + } + + /** + * Handle a transition to OverlayState.NONE state. + */ + private void handleNoneState(OverlayState previous) { + if (previous == OverlayState.NONE) { + return; + } + + // Just hide everything + mDocumentOverlay.hideHandle(SelectionHandle.HandleType.START); + mDocumentOverlay.hideHandle(SelectionHandle.HandleType.END); + mDocumentOverlay.hideHandle(SelectionHandle.HandleType.MIDDLE); + mDocumentOverlay.hideSelections(); + mDocumentOverlay.hideCursor(); + mDocumentOverlay.hideGraphicSelection(); + mContext.hideSoftKeyboard(); + } + + /** + * Handle a transition to OverlayState.SELECTION state. + */ + private void handleSelectionState(OverlayState previous) { + mDocumentOverlay.showHandle(SelectionHandle.HandleType.START); + mDocumentOverlay.showHandle(SelectionHandle.HandleType.END); + mDocumentOverlay.showSelections(); + } + + /** + * Handle a transition to OverlayState.CURSOR state. + */ + private void handleCursorState(OverlayState previous) { + mContext.showSoftKeyboardOrFormattingToolbar(); + if (previous == OverlayState.TRANSITION) { + mDocumentOverlay.showHandle(SelectionHandle.HandleType.MIDDLE); + mDocumentOverlay.showCursor(); + } + } + + /** + * Handle a transition to OverlayState.TRANSITION state. + */ + private void handleTransitionState(OverlayState previous) { + switch (previous) { + case SELECTION: + mDocumentOverlay.hideHandle(SelectionHandle.HandleType.START); + mDocumentOverlay.hideHandle(SelectionHandle.HandleType.END); + mDocumentOverlay.hideSelections(); + break; + case CURSOR: + mDocumentOverlay.hideHandle(SelectionHandle.HandleType.MIDDLE); + break; + case GRAPHIC_SELECTION: + mDocumentOverlay.hideGraphicSelection(); + break; + } + } + + /** + * Handle a transition to OverlayState.GRAPHIC_SELECTION state. + */ + private void handleGraphicSelectionState(OverlayState previous) { + mDocumentOverlay.showGraphicSelection(); + mContext.hideSoftKeyboard(); + } + + /** + * The current state the overlay is in. + */ + public OverlayState getCurrentState() { + return mState; + } + + /** + * A key event happened (i.e. user started typing). + */ + public void keyEvent() { + mKeyEvent = true; + } + + /** + * The states the overlay. + */ + public enum OverlayState { + /** + * State where the overlay is empty + */ + NONE, + /** + * In-between state where we need to transition to a new overlay state. + * In this state we properly disable the older state and wait to transition + * to a new state triggered by an invalidation. + */ + TRANSITION, + /** + * State where we operate with the cursor. + */ + CURSOR, + /** + * State where we operate the graphic selection. + */ + GRAPHIC_SELECTION, + /** + * State where we operate the text selection. + */ + SELECTION + } +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/LOEvent.java b/android/source/src/java/org/libreoffice/LOEvent.java new file mode 100644 index 0000000000..d1170eee12 --- /dev/null +++ b/android/source/src/java/org/libreoffice/LOEvent.java @@ -0,0 +1,177 @@ +/* -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.libreoffice; + +import android.graphics.PointF; +import android.graphics.RectF; +import android.view.KeyEvent; + +import org.libreoffice.canvas.SelectionHandle; +import org.mozilla.gecko.gfx.ComposedTileLayer; + +/** + * Events and data that is queued and processed by LOKitThread. + */ +public class LOEvent implements Comparable<LOEvent> { + public static final int SIZE_CHANGED = 1; + public static final int CHANGE_PART = 2; + public static final int LOAD = 3; + public static final int CLOSE = 4; + public static final int TILE_REEVALUATION_REQUEST = 5; + public static final int THUMBNAIL = 6; + public static final int TILE_INVALIDATION = 7; + public static final int TOUCH = 8; + public static final int KEY_EVENT = 9; + public static final int CHANGE_HANDLE_POSITION = 10; + public static final int SWIPE_RIGHT = 11; + public static final int SWIPE_LEFT = 12; + public static final int NAVIGATION_CLICK = 13; + public static final int UNO_COMMAND = 14; + public static final int LOAD_NEW = 16; + public static final int SAVE_AS = 17; + public static final int UPDATE_PART_PAGE_RECT = 18; + public static final int UPDATE_ZOOM_CONSTRAINTS = 19; + public static final int UPDATE_CALC_HEADERS = 20; + public static final int REFRESH = 21; + public static final int PAGE_SIZE_CHANGED = 22; + public static final int UNO_COMMAND_NOTIFY = 23; + public static final int SAVE_COPY_AS = 24; + + + public final int mType; + public int mPriority = 0; + private String mTypeString; + + public ThumbnailCreator.ThumbnailCreationTask mTask; + public int mPartIndex; + public String mString; + public String filePath; + public String fileType; + public ComposedTileLayer mComposedTileLayer; + public String mTouchType; + public PointF mDocumentCoordinate; + public KeyEvent mKeyEvent; + public RectF mInvalidationRect; + public SelectionHandle.HandleType mHandleType; + public String mValue; + public int mPageWidth; + public int mPageHeight; + public boolean mNotify; + + public LOEvent(int type) { + mType = type; + } + + public LOEvent(int type, ComposedTileLayer composedTileLayer) { + mType = type; + mTypeString = "Tile Reevaluation"; + mComposedTileLayer = composedTileLayer; + } + + public LOEvent(int type, String someString) { + mType = type; + mTypeString = "String"; + mString = someString; + mValue = null; + } + + public LOEvent(int type, String someString, boolean notify) { + mType = type; + mTypeString = "String"; + mString = someString; + mValue = null; + mNotify = notify; + } + + public LOEvent(int type, String someString, String value, boolean notify) { + mType = type; + mTypeString = "String"; + mString = someString; + mValue = value; + mNotify = notify; + } + + public LOEvent(int type, String key, String value) { + mType = type; + mTypeString = "key / value"; + mString = key; + mValue = value; + } + + public LOEvent(String filePath, int type) { + mType = type; + mTypeString = "Load"; + this.filePath = filePath; + } + + public LOEvent(String filePath, String fileType, int type) { + mType = type; + mTypeString = "Load New/Save As"; + this.filePath = filePath; + this.fileType = fileType; + } + + public LOEvent(int type, int partIndex) { + mType = type; + mPartIndex = partIndex; + mTypeString = "Change part"; + } + + public LOEvent(int type, ThumbnailCreator.ThumbnailCreationTask task) { + mType = type; + mTask = task; + mTypeString = "Thumbnail"; + } + + public LOEvent(int type, String touchType, PointF documentTouchCoordinate) { + mType = type; + mTypeString = "Touch"; + mTouchType = touchType; + mDocumentCoordinate = documentTouchCoordinate; + } + + public LOEvent(int type, KeyEvent keyEvent) { + mType = type; + mTypeString = "Key Event"; + mKeyEvent = keyEvent; + } + + public LOEvent(int type, RectF rect) { + mType = type; + mTypeString = "Tile Invalidation"; + mInvalidationRect = rect; + } + + public LOEvent(int type, SelectionHandle.HandleType handleType, PointF documentCoordinate) { + mType = type; + mHandleType = handleType; + mDocumentCoordinate = documentCoordinate; + } + + public LOEvent(int type, int pageWidth, int pageHeight){ + mType = type; + mPageWidth = pageWidth; + mPageHeight = pageHeight; + } + + public String getTypeString() { + if (mTypeString == null) { + return "Event type: " + mType; + } + return mTypeString; + } + + @Override + public int compareTo(LOEvent another) { + return mPriority - another.mPriority; + } + +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/LOKitInputConnectionHandler.java b/android/source/src/java/org/libreoffice/LOKitInputConnectionHandler.java new file mode 100644 index 0000000000..7b50ef5ff7 --- /dev/null +++ b/android/source/src/java/org/libreoffice/LOKitInputConnectionHandler.java @@ -0,0 +1,74 @@ +/* -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.libreoffice; + +import android.view.KeyEvent; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; + +import org.mozilla.gecko.gfx.InputConnectionHandler; + +/** + * Implementation of InputConnectionHandler. When a key event happens it is + * directed to this class which is then directed further to LOKitThread. + */ +public class LOKitInputConnectionHandler implements InputConnectionHandler { + private static final String LOGTAG = LOKitInputConnectionHandler.class.getSimpleName(); + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + return null; + } + + /** + * When key pre-Ime happens. + */ + @Override + public boolean onKeyPreIme(int keyCode, KeyEvent event) { + return false; + } + + /** + * When key down event happens. + */ + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + LOKitShell.sendKeyEvent(event); + return false; + } + + /** + * When key long press event happens. + */ + @Override + public boolean onKeyLongPress(int keyCode, KeyEvent event) { + return false; + } + + /** + * When key multiple event happens. Key multiple event is triggered when + * non-ascii characters are entered on soft keyboard. + */ + @Override + public boolean onKeyMultiple(int keyCode, int repeatCount, KeyEvent event) { + LOKitShell.sendKeyEvent(event); + return false; + } + + /** + * When key up event happens. + */ + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + LOKitShell.sendKeyEvent(event); + return false; + } +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/LOKitShell.java b/android/source/src/java/org/libreoffice/LOKitShell.java new file mode 100644 index 0000000000..f6a76228e0 --- /dev/null +++ b/android/source/src/java/org/libreoffice/LOKitShell.java @@ -0,0 +1,169 @@ +/* -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.libreoffice; + + +import android.app.ActivityManager; +import android.content.Context; +import android.graphics.PointF; +import android.graphics.RectF; +import android.os.Handler; +import android.util.DisplayMetrics; +import android.view.KeyEvent; + +import org.libreoffice.canvas.SelectionHandle; +import org.mozilla.gecko.gfx.ComposedTileLayer; + +/** + * Common static LOKit functions, functions to send events. + */ +public class LOKitShell { + public static float getDpi(Context context) { + LOKitTileProvider tileProvider = ((LibreOfficeMainActivity)context).getTileProvider(); + if (tileProvider != null && tileProvider.isSpreadsheet()) + return 96f; + DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + return metrics.density * 160; + } + + // Get a Handler for the main java thread + public static Handler getMainHandler() { + return LibreOfficeApplication.getMainHandler(); + } + + public static void showProgressSpinner(final LibreOfficeMainActivity context) { + getMainHandler().post(new Runnable() { + @Override + public void run() { + context.showProgressSpinner(); + } + }); + } + + public static void hideProgressSpinner(final LibreOfficeMainActivity context) { + getMainHandler().post(new Runnable() { + @Override + public void run() { + context.hideProgressSpinner(); + } + }); + } + + public static int getMemoryClass(Context context) { + ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + return activityManager.getMemoryClass() * 1024 * 1024; + } + + public static boolean isEditingEnabled() { + return !LibreOfficeMainActivity.isReadOnlyMode(); + } + + // EVENTS + + /** + * Make sure LOKitThread is running and send event to it. + */ + public static void sendEvent(LOEvent event) { + LibreOfficeMainActivity.loKitThread.queueEvent(event); + } + + public static void sendThumbnailEvent(ThumbnailCreator.ThumbnailCreationTask task) { + LOKitShell.sendEvent(new LOEvent(LOEvent.THUMBNAIL, task)); + } + + /** + * Send touch event to LOKitThread. + */ + public static void sendTouchEvent(String touchType, PointF documentTouchCoordinate) { + LOKitShell.sendEvent(new LOEvent(LOEvent.TOUCH, touchType, documentTouchCoordinate)); + } + + /** + * Send key event to LOKitThread. + */ + public static void sendKeyEvent(KeyEvent event) { + LOKitShell.sendEvent(new LOEvent(LOEvent.KEY_EVENT, event)); + } + + public static void sendSizeChangedEvent(int width, int height) { + LOKitShell.sendEvent(new LOEvent(LOEvent.SIZE_CHANGED)); + } + + public static void sendSwipeRightEvent() { + LOKitShell.sendEvent(new LOEvent(LOEvent.SWIPE_RIGHT)); + } + + public static void sendSwipeLeftEvent() { + LOKitShell.sendEvent(new LOEvent(LOEvent.SWIPE_LEFT)); + } + + public static void sendChangePartEvent(int part) { + LOKitShell.sendEvent(new LOEvent(LOEvent.CHANGE_PART, part)); + } + + public static void sendLoadEvent(String inputFilePath) { + LOKitShell.sendEvent(new LOEvent(inputFilePath, LOEvent.LOAD)); + } + + public static void sendNewDocumentLoadEvent(String newDocumentPath, String newDocumentType) { + LOKitShell.sendEvent(new LOEvent(newDocumentPath, newDocumentType, LOEvent.LOAD_NEW)); + } + + public static void sendSaveAsEvent(String filePath, String fileFormat) { + LOKitShell.sendEvent(new LOEvent(filePath, fileFormat, LOEvent.SAVE_AS)); + } + + public static void sendSaveCopyAsEvent(String filePath, String fileFormat) { + LOKitShell.sendEvent(new LOEvent(filePath, fileFormat, LOEvent.SAVE_COPY_AS)); + } + + public static void sendCloseEvent() { + LOKitShell.sendEvent(new LOEvent(LOEvent.CLOSE)); + } + + /** + * Send tile reevaluation to LOKitThread. + */ + public static void sendTileReevaluationRequest(ComposedTileLayer composedTileLayer) { + LOKitShell.sendEvent(new LOEvent(LOEvent.TILE_REEVALUATION_REQUEST, composedTileLayer)); + } + + /** + * Send tile invalidation to LOKitThread. + */ + public static void sendTileInvalidationRequest(RectF rect) { + LOKitShell.sendEvent(new LOEvent(LOEvent.TILE_INVALIDATION, rect)); + } + + /** + * Send change handle position event to LOKitThread. + */ + public static void sendChangeHandlePositionEvent(SelectionHandle.HandleType handleType, PointF documentCoordinate) { + LOKitShell.sendEvent(new LOEvent(LOEvent.CHANGE_HANDLE_POSITION, handleType, documentCoordinate)); + } + + public static void sendNavigationClickEvent() { + LOKitShell.sendEvent(new LOEvent(LOEvent.NAVIGATION_CLICK)); + } + + /** + * Move the viewport to the desired point (top-left), and change the zoom level. + * Ensure this runs on the UI thread. + */ + public static void moveViewportTo(final LibreOfficeMainActivity context, final PointF position, final Float zoom) { + context.getLayerClient().post(new Runnable() { + @Override + public void run() { + context.getLayerClient().moveTo(position, zoom); + } + }); + } +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/LOKitThread.java b/android/source/src/java/org/libreoffice/LOKitThread.java new file mode 100644 index 0000000000..fd40c30891 --- /dev/null +++ b/android/source/src/java/org/libreoffice/LOKitThread.java @@ -0,0 +1,449 @@ +package org.libreoffice; + +import android.graphics.Bitmap; +import android.graphics.PointF; +import android.graphics.RectF; +import android.util.Log; +import android.view.KeyEvent; + +import org.libreoffice.canvas.SelectionHandle; +import org.mozilla.gecko.ZoomConstraints; +import org.mozilla.gecko.gfx.CairoImage; +import org.mozilla.gecko.gfx.ComposedTileLayer; +import org.mozilla.gecko.gfx.GeckoLayerClient; +import org.mozilla.gecko.gfx.ImmutableViewportMetrics; +import org.mozilla.gecko.gfx.SubTile; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.LinkedBlockingQueue; + +/* + * Thread that communicates with LibreOffice through LibreOfficeKit JNI interface. The thread + * consumes events from other threads (mainly the UI thread) and acts accordingly. + */ +class LOKitThread extends Thread { + private static final String LOGTAG = LOKitThread.class.getSimpleName(); + + private final LinkedBlockingQueue<LOEvent> mEventQueue = new LinkedBlockingQueue<LOEvent>(); + + private TileProvider mTileProvider; + private InvalidationHandler mInvalidationHandler; + private ImmutableViewportMetrics mViewportMetrics; + private GeckoLayerClient mLayerClient; + private final LibreOfficeMainActivity mContext; + + LOKitThread(LibreOfficeMainActivity context) { + mContext = context; + mInvalidationHandler = null; + TileProviderFactory.initialize(); + } + + /** + * Starting point of the thread. Processes events that gather in the queue. + */ + @Override + public void run() { + while (true) { + LOEvent event; + try { + event = mEventQueue.take(); + processEvent(event); + } catch (InterruptedException exception) { + throw new RuntimeException(exception); + } + } + } + + /** + * Viewport changed, Recheck if tiles need to be added / removed. + */ + private void tileReevaluationRequest(ComposedTileLayer composedTileLayer) { + if (mTileProvider == null) { + return; + } + List<SubTile> tiles = new ArrayList<SubTile>(); + + mLayerClient.beginDrawing(); + composedTileLayer.addNewTiles(tiles); + mLayerClient.endDrawing(); + + for (SubTile tile : tiles) { + TileIdentifier tileId = tile.id; + CairoImage image = mTileProvider.createTile(tileId.x, tileId.y, tileId.size, tileId.zoom); + mLayerClient.beginDrawing(); + if (image != null) { + tile.setImage(image); + } + mLayerClient.endDrawing(); + mLayerClient.forceRender(); + } + + mLayerClient.beginDrawing(); + composedTileLayer.markTiles(); + composedTileLayer.clearMarkedTiles(); + mLayerClient.endDrawing(); + mLayerClient.forceRender(); + } + + /** + * Invalidate tiles that intersect the input rect. + */ + private void tileInvalidation(RectF rect) { + if (mLayerClient == null || mTileProvider == null) { + return; + } + + mLayerClient.beginDrawing(); + + List<SubTile> tiles = new ArrayList<SubTile>(); + mLayerClient.invalidateTiles(tiles, rect); + + for (SubTile tile : tiles) { + CairoImage image = mTileProvider.createTile(tile.id.x, tile.id.y, tile.id.size, tile.id.zoom); + tile.setImage(image); + tile.invalidate(); + } + mLayerClient.endDrawing(); + mLayerClient.forceRender(); + } + + /** + * Handle the geometry change + draw. + */ + private void redraw(boolean resetZoomAndPosition) { + if (mLayerClient == null || mTileProvider == null) { + // called too early... + return; + } + + mLayerClient.setPageRect(0, 0, mTileProvider.getPageWidth(), mTileProvider.getPageHeight()); + mViewportMetrics = mLayerClient.getViewportMetrics(); + mLayerClient.setViewportMetrics(mViewportMetrics); + + if (resetZoomAndPosition) { + zoomAndRepositionTheDocument(); + } + + mLayerClient.forceRedraw(); + mLayerClient.forceRender(); + } + + /** + * Reposition the view (zoom and position) when the document is firstly shown. This is document type dependent. + */ + private void zoomAndRepositionTheDocument() { + if (mTileProvider.isSpreadsheet()) { + // Don't do anything for spreadsheets - show at 100% + } else if (mTileProvider.isTextDocument()) { + // Always zoom text document to the beginning of the document and centered by width + float centerY = mViewportMetrics.getCssViewport().centerY(); + mLayerClient.zoomTo(new RectF(0, centerY, mTileProvider.getPageWidth(), centerY)); + } else { + // Other documents - always show the whole document on the screen, + // regardless of document shape and orientation. + if (mViewportMetrics.getViewport().width() < mViewportMetrics.getViewport().height()) { + mLayerClient.zoomTo(mTileProvider.getPageWidth(), 0); + } else { + mLayerClient.zoomTo(0, mTileProvider.getPageHeight()); + } + } + } + + /** + * Invalidate everything + handle the geometry change + */ + private void refresh(boolean resetZoomAndPosition) { + mLayerClient.clearAndResetlayers(); + redraw(resetZoomAndPosition); + updatePartPageRectangles(); + if (mTileProvider != null && mTileProvider.isSpreadsheet()) { + updateCalcHeaders(); + } + } + + /** + * Update part page rectangles which hold positions of each document page. + * Result is stored in DocumentOverlayView class. + */ + private void updatePartPageRectangles() { + if (mTileProvider == null) { + Log.d(LOGTAG, "mTileProvider==null when calling updatePartPageRectangles"); + return; + } + String partPageRectString = ((LOKitTileProvider) mTileProvider).getPartPageRectangles(); + List<RectF> partPageRectangles = mInvalidationHandler.convertPayloadToRectangles(partPageRectString); + mContext.getDocumentOverlay().setPartPageRectangles(partPageRectangles); + } + + private void updatePageSize(int pageWidth, int pageHeight){ + mTileProvider.setDocumentSize(pageWidth, pageHeight); + redraw(true); + } + + private void updateZoomConstraints() { + if (mTileProvider == null) return; + mLayerClient = mContext.getLayerClient(); + // Set default zoom to the page width and min zoom so that the whole page is visible + final float pageHeightZoom = mLayerClient.getViewportMetrics().getHeight() / mTileProvider.getPageHeight(); + final float pageWidthZoom = mLayerClient.getViewportMetrics().getWidth() / mTileProvider.getPageWidth(); + final float minZoom = Math.min(pageWidthZoom, pageHeightZoom); + mLayerClient.setZoomConstraints(new ZoomConstraints(pageWidthZoom, minZoom, 0f)); + } + + /** + * Change part of the document. + */ + private void changePart(int partIndex) { + LOKitShell.showProgressSpinner(mContext); + mTileProvider.changePart(partIndex); + mViewportMetrics = mLayerClient.getViewportMetrics(); + // mLayerClient.setViewportMetrics(mViewportMetrics.scaleTo(0.9f, new PointF())); + refresh(true); + LOKitShell.hideProgressSpinner(mContext); + } + + /** + * Handle load document event. + * @param filePath - filePath to where the document is located + * @return Whether the document has been loaded successfully. + */ + private boolean loadDocument(String filePath) { + mLayerClient = mContext.getLayerClient(); + + mInvalidationHandler = new InvalidationHandler(mContext); + mTileProvider = TileProviderFactory.create(mContext, mInvalidationHandler, filePath); + + if (mTileProvider.isReady()) { + LOKitShell.showProgressSpinner(mContext); + updateZoomConstraints(); + refresh(true); + LOKitShell.hideProgressSpinner(mContext); + return true; + } else { + closeDocument(); + return false; + } + } + + /** + * Handle load new document event. + * @param filePath - filePath to where new document is to be created + * @param fileType - fileType what type of new document is to be loaded + */ + private void loadNewDocument(String filePath, String fileType) { + boolean ok = loadDocument(fileType); + if (ok) { + mTileProvider.saveDocumentAs(filePath, true); + } + } + + /** + * Save the currently loaded document. + */ + private void saveDocumentAs(String filePath, String fileType, boolean bTakeOwnership) { + if (mTileProvider == null) { + Log.e(LOGTAG, "Error in saving, Tile Provider instance is null"); + } else { + mTileProvider.saveDocumentAs(filePath, fileType, bTakeOwnership); + } + } + + /** + * Close the currently loaded document. + */ + private void closeDocument() { + if (mTileProvider != null) { + mTileProvider.close(); + mTileProvider = null; + } + } + + /** + * Process the input event. + */ + private void processEvent(LOEvent event) { + switch (event.mType) { + case LOEvent.LOAD: + loadDocument(event.filePath); + break; + case LOEvent.LOAD_NEW: + loadNewDocument(event.filePath, event.fileType); + break; + case LOEvent.SAVE_AS: + saveDocumentAs(event.filePath, event.fileType, true); + break; + case LOEvent.SAVE_COPY_AS: + saveDocumentAs(event.filePath, event.fileType, false); + break; + case LOEvent.CLOSE: + closeDocument(); + break; + case LOEvent.SIZE_CHANGED: + redraw(false); + break; + case LOEvent.CHANGE_PART: + changePart(event.mPartIndex); + break; + case LOEvent.TILE_INVALIDATION: + tileInvalidation(event.mInvalidationRect); + break; + case LOEvent.THUMBNAIL: + createThumbnail(event.mTask); + break; + case LOEvent.TOUCH: + touch(event.mTouchType, event.mDocumentCoordinate); + break; + case LOEvent.KEY_EVENT: + keyEvent(event.mKeyEvent); + break; + case LOEvent.TILE_REEVALUATION_REQUEST: + tileReevaluationRequest(event.mComposedTileLayer); + break; + case LOEvent.CHANGE_HANDLE_POSITION: + changeHandlePosition(event.mHandleType, event.mDocumentCoordinate); + break; + case LOEvent.SWIPE_LEFT: + if (null != mTileProvider) onSwipeLeft(); + break; + case LOEvent.SWIPE_RIGHT: + if (null != mTileProvider) onSwipeRight(); + break; + case LOEvent.NAVIGATION_CLICK: + mInvalidationHandler.changeStateTo(InvalidationHandler.OverlayState.NONE); + break; + case LOEvent.UNO_COMMAND: + if (null == mTileProvider) + Log.e(LOGTAG, "no mTileProvider when trying to process "+event.mValue+" from UNO_COMMAND "+event.mString); + else + mTileProvider.postUnoCommand(event.mString, event.mValue); + break; + case LOEvent.UPDATE_PART_PAGE_RECT: + updatePartPageRectangles(); + break; + case LOEvent.UPDATE_ZOOM_CONSTRAINTS: + updateZoomConstraints(); + break; + case LOEvent.UPDATE_CALC_HEADERS: + updateCalcHeaders(); + break; + case LOEvent.UNO_COMMAND_NOTIFY: + if (null == mTileProvider) + Log.e(LOGTAG, "no mTileProvider when trying to process "+event.mValue+" from UNO_COMMAND "+event.mString); + else + mTileProvider.postUnoCommand(event.mString, event.mValue, event.mNotify); + break; + case LOEvent.REFRESH: + refresh(false); + break; + case LOEvent.PAGE_SIZE_CHANGED: + updatePageSize(event.mPageWidth, event.mPageHeight); + break; + } + } + + private void updateCalcHeaders() { + if (null == mTileProvider) return; + LOKitTileProvider tileProvider = (LOKitTileProvider)mTileProvider; + String values = tileProvider.getCalcHeaders(); + mContext.getCalcHeadersController().setHeaders(values); + } + + /** + * Request a change of the handle position. + */ + private void changeHandlePosition(SelectionHandle.HandleType handleType, PointF documentCoordinate) { + switch (handleType) { + case MIDDLE: + mTileProvider.setTextSelectionReset(documentCoordinate); + break; + case START: + mTileProvider.setTextSelectionStart(documentCoordinate); + break; + case END: + mTileProvider.setTextSelectionEnd(documentCoordinate); + break; + } + } + + /** + * Processes key events. + */ + private void keyEvent(KeyEvent keyEvent) { + if (!LOKitShell.isEditingEnabled()) { + return; + } + if (mTileProvider == null) { + return; + } + mInvalidationHandler.keyEvent(); + mTileProvider.sendKeyEvent(keyEvent); + } + + /** + * Process swipe left event. + */ + private void onSwipeLeft() { + mTileProvider.onSwipeLeft(); + } + + /** + * Process swipe right event. + */ + private void onSwipeRight() { + mTileProvider.onSwipeRight(); + } + + /** + * Processes touch events. + */ + private void touch(String touchType, PointF documentCoordinate) { + if (mTileProvider == null || mViewportMetrics == null) { + return; + } + + // to handle hyperlinks, enable single tap even in the Viewer + boolean editing = LOKitShell.isEditingEnabled(); + float zoomFactor = mViewportMetrics.getZoomFactor(); + + if (touchType.equals("LongPress")) { + mInvalidationHandler.changeStateTo(InvalidationHandler.OverlayState.TRANSITION); + mTileProvider.mouseButtonDown(documentCoordinate, 1, zoomFactor); + mTileProvider.mouseButtonUp(documentCoordinate, 1, zoomFactor); + mTileProvider.mouseButtonDown(documentCoordinate, 2, zoomFactor); + mTileProvider.mouseButtonUp(documentCoordinate, 2, zoomFactor); + } else if (touchType.equals("SingleTap")) { + mInvalidationHandler.changeStateTo(InvalidationHandler.OverlayState.TRANSITION); + mTileProvider.mouseButtonDown(documentCoordinate, 1, zoomFactor); + mTileProvider.mouseButtonUp(documentCoordinate, 1, zoomFactor); + } else if (touchType.equals("GraphicSelectionStart") && editing) { + mTileProvider.setGraphicSelectionStart(documentCoordinate); + } else if (touchType.equals("GraphicSelectionEnd") && editing) { + mTileProvider.setGraphicSelectionEnd(documentCoordinate); + } + } + + /** + * Create thumbnail for the requested document task. + */ + private void createThumbnail(final ThumbnailCreator.ThumbnailCreationTask task) { + final Bitmap bitmap = task.getThumbnail(mTileProvider); + task.applyBitmap(bitmap); + } + + /** + * Queue an event. + */ + public void queueEvent(LOEvent event) { + mEventQueue.add(event); + } + + /** + * Clear all events in the queue (used when document is closed). + */ + public void clearQueue() { + mEventQueue.clear(); + } +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/LOKitTileProvider.java b/android/source/src/java/org/libreoffice/LOKitTileProvider.java new file mode 100644 index 0000000000..5d1cf12209 --- /dev/null +++ b/android/source/src/java/org/libreoffice/LOKitTileProvider.java @@ -0,0 +1,815 @@ +/* -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.libreoffice; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.PointF; +import android.os.Build; +import android.print.PrintAttributes; +import android.print.PrintDocumentAdapter; +import android.print.PrintManager; +import android.util.Log; +import android.view.KeyEvent; +import android.widget.Toast; + +import org.json.JSONException; +import org.json.JSONObject; +import org.libreoffice.kit.DirectBufferAllocator; +import org.libreoffice.kit.Document; +import org.libreoffice.kit.LibreOfficeKit; +import org.libreoffice.kit.Office; +import org.mozilla.gecko.gfx.BufferedCairoImage; +import org.mozilla.gecko.gfx.CairoImage; +import org.mozilla.gecko.gfx.IntSize; + +import java.io.File; +import java.nio.ByteBuffer; + +/** + * LOKit implementation of TileProvider. + */ +class LOKitTileProvider implements TileProvider { + private static final String LOGTAG = LOKitTileProvider.class.getSimpleName(); + private static final int TILE_SIZE = 256; + private final float mTileWidth; + private final float mTileHeight; + private String mInputFile; + private Office mOffice; + private Document mDocument; + private final boolean mIsReady; + private final LibreOfficeMainActivity mContext; + + private final float mDPI; + private float mWidthTwip; + private float mHeightTwip; + + private final Document.MessageCallback mMessageCallback; + + private final long objectCreationTime = System.currentTimeMillis(); + + /** + * Initialize LOKit and load the document. + * @param messageCallback - callback for messages retrieved from LOKit + * @param input - input path of the document + */ + LOKitTileProvider(LibreOfficeMainActivity context, InvalidationHandler messageCallback, String input) { + mContext = context; + mMessageCallback = messageCallback; + + LibreOfficeKit.putenv("SAL_LOG=+WARN+INFO"); + LibreOfficeKit.init(mContext); + + mOffice = new Office(LibreOfficeKit.getLibreOfficeKitHandle()); + mOffice.setMessageCallback(messageCallback); + mOffice.setOptionalFeatures(Document.LOK_FEATURE_DOCUMENT_PASSWORD); + mContext.setTileProvider(this); + mInputFile = input; + + Log.i(LOGTAG, "====> Loading file '" + input + "'"); + + File fileToBeEncoded = new File(input); + String encodedFileName = android.net.Uri.encode(fileToBeEncoded.getName()); + + mDocument = mOffice.documentLoad( + (new File(fileToBeEncoded.getParent(),encodedFileName)).getPath() + ); + + if (mDocument == null && !mContext.isPasswordProtected()) { + Log.i(LOGTAG, "====> mOffice.documentLoad() returned null, trying to restart 'Office' and loading again"); + mOffice.destroy(); + Log.i(LOGTAG, "====> mOffice.destroy() done"); + ByteBuffer handle = LibreOfficeKit.getLibreOfficeKitHandle(); + Log.i(LOGTAG, "====> getLibreOfficeKitHandle() = " + handle); + mOffice = new Office(handle); + Log.i(LOGTAG, "====> new Office created"); + mOffice.setMessageCallback(messageCallback); + mOffice.setOptionalFeatures(Document.LOK_FEATURE_DOCUMENT_PASSWORD); + Log.i(LOGTAG, "====> setup Lokit callback and optional features (password support)"); + mDocument = mOffice.documentLoad( + (new File(fileToBeEncoded.getParent(),encodedFileName)).getPath() + ); + } + + Log.i(LOGTAG, "====> mDocument = " + mDocument); + + mDPI = LOKitShell.getDpi(mContext); + mTileWidth = pixelToTwip(TILE_SIZE, mDPI); + mTileHeight = pixelToTwip(TILE_SIZE, mDPI); + + if (mDocument != null) + mDocument.initializeForRendering(); + + if (checkDocument()) { + postLoad(); + mIsReady = true; + } else { + mIsReady = false; + } + } + + /** + * Triggered after the document is loaded. + */ + private void postLoad() { + mDocument.setMessageCallback(mMessageCallback); + + resetParts(); + // Writer documents always have one part, so hide the navigation drawer. + if (mDocument.getDocumentType() == Document.DOCTYPE_TEXT) { + mContext.disableNavigationDrawer(); + mContext.getToolbarController().hideItem(R.id.action_parts); + } + + // Enable headers for Calc documents + if (mDocument.getDocumentType() == Document.DOCTYPE_SPREADSHEET) { + mContext.initializeCalcHeaders(); + } + + mDocument.setPart(0); + + setupDocumentFonts(); + + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + mContext.getDocumentPartViewListAdapter().notifyDataSetChanged(); + } + }); + } + + public void addPart(){ + int parts = mDocument.getParts(); + if(mDocument.getDocumentType() == Document.DOCTYPE_SPREADSHEET){ + try{ + JSONObject jsonObject = new JSONObject(); + JSONObject values = new JSONObject(); + JSONObject values2 = new JSONObject(); + values.put("type", "long"); + values.put("value", 0); //add to the last + values2.put("type", "string"); + values2.put("value", ""); + jsonObject.put("Name", values2); + jsonObject.put("Index", values); + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:Insert", jsonObject.toString())); + }catch (JSONException e) { + e.printStackTrace(); + } + } else if (mDocument.getDocumentType() == Document.DOCTYPE_PRESENTATION){ + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:InsertPage")); + } + + String partName = mDocument.getPartName(parts); + if (partName.isEmpty()) { + partName = getGenericPartName(parts); + } + mDocument.setPart(parts); + resetDocumentSize(); + final DocumentPartView partView = new DocumentPartView(parts, partName); + mContext.getDocumentPartView().add(partView); + } + + public void resetParts(){ + mContext.getDocumentPartView().clear(); + if (mDocument.getDocumentType() != Document.DOCTYPE_TEXT) { + int parts = mDocument.getParts(); + for (int i = 0; i < parts; i++) { + String partName = mDocument.getPartName(i); + + if (partName.isEmpty()) { + partName = getGenericPartName(i); + } + Log.i(LOGTAG, "resetParts: " + partName); + mDocument.setPart(i); + resetDocumentSize(); + final DocumentPartView partView = new DocumentPartView(i, partName); + mContext.getDocumentPartView().add(partView); + } + } + } + + public void renamePart(String partName) { + try{ + for(int i=0; i<mDocument.getParts(); i++){ + if(mContext.getDocumentPartView().get(i).partName.equals(partName)){ + //part name must be unique + Toast.makeText(mContext, mContext.getString(R.string.name_already_used), Toast.LENGTH_SHORT).show(); + return; + } + } + JSONObject parameter = new JSONObject(); + JSONObject name = new JSONObject(); + name.put("type", "string"); + name.put("value", partName); + parameter.put("Name", name); + if(isPresentation()){ + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND_NOTIFY, ".uno:RenamePage", parameter.toString(),true)); + }else { + JSONObject index = new JSONObject(); + index.put("type","long"); + index.put("value", getCurrentPartNumber()+1); + parameter.put("Index", index); + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND_NOTIFY, ".uno:Name", parameter.toString(),true)); + } + }catch (JSONException e){ + e.printStackTrace(); + } + } + + public void removePart() { + try{ + if (!isSpreadsheet() && !isPresentation()) { + //document must be spreadsheet or presentation + return; + } + + if(isPresentation()){ + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND_NOTIFY, ".uno:DeletePage", true)); + return; + } + + if(getPartsCount() < 2){ + return; + } + + JSONObject parameter = new JSONObject(); + JSONObject index = new JSONObject(); + index.put("type","long"); + index.put("value", getCurrentPartNumber()+1); + parameter.put("Index", index); + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND_NOTIFY, ".uno:Remove", parameter.toString(),true)); + }catch (JSONException e){ + e.printStackTrace(); + } + } + + @Override + public boolean saveDocumentAs(final String filePath, String format, boolean takeOwnership) { + String options = ""; + if (takeOwnership) { + options = "TakeOwnership"; + } + + final String newFilePath = "file://" + filePath; + Log.d("saveFilePathURL", newFilePath); + LOKitShell.showProgressSpinner(mContext); + mDocument.saveAs(newFilePath, format, options); + final boolean ok; + if (!mOffice.getError().isEmpty()){ + ok = true; + Log.e("Save Error", mOffice.getError()); + if (format.equals("svg")) { + // error in creating temp slideshow svg file + Log.d(LOGTAG, "Error in creating temp slideshow svg file"); + } else if(format.equals("pdf")){ + Log.d(LOGTAG, "Error in creating pdf file"); + } else { + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + // There was some error + mContext.showCustomStatusMessage(mContext.getString(R.string.unable_to_save)); + } + }); + } + } else { + ok = false; + if (format.equals("svg")) { + // successfully created temp slideshow svg file + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + mContext.startPresentation(newFilePath); + } + }); + } else if (takeOwnership) { + mInputFile = filePath; + } + } + LOKitShell.hideProgressSpinner(mContext); + return ok; + } + + @Override + public boolean saveDocumentAs(final String filePath, boolean takeOwnership) { + final int docType = mDocument.getDocumentType(); + if (docType == Document.DOCTYPE_TEXT) + return saveDocumentAs(filePath, "odt", takeOwnership); + else if (docType == Document.DOCTYPE_SPREADSHEET) + return saveDocumentAs(filePath, "ods", takeOwnership); + else if (docType == Document.DOCTYPE_PRESENTATION) + return saveDocumentAs(filePath, "odp", takeOwnership); + else if (docType == Document.DOCTYPE_DRAWING) + return saveDocumentAs(filePath, "odg", takeOwnership); + + Log.w(LOGTAG, "Cannot determine file format from document. Not saving."); + return false; + } + + public void printDocument() { + String mInputFileName = (new File(mInputFile)).getName(); + String file = mInputFileName.substring(0,(mInputFileName.length()-3))+"pdf"; + String cacheFile = mContext.getExternalCacheDir().getAbsolutePath() + "/" + file; + mDocument.saveAs("file://"+cacheFile,"pdf",""); + try { + PrintManager printManager = (PrintManager) mContext.getSystemService(Context.PRINT_SERVICE); + PrintDocumentAdapter printAdapter = new PDFDocumentAdapter(mContext, cacheFile); + printManager.print("Document", printAdapter, new PrintAttributes.Builder().build()); + + } catch (Exception e) { + e.printStackTrace(); + } + } + + public void saveDocument(){ + mContext.saveDocument(); + } + + private void setupDocumentFonts() { + String values = mDocument.getCommandValues(".uno:CharFontName"); + if (values == null || values.isEmpty()) + return; + + mContext.getFontController().parseJson(values); + mContext.getFontController().setupFontViews(); + } + + private String getGenericPartName(int i) { + if (mDocument == null) { + return ""; + } + switch (mDocument.getDocumentType()) { + case Document.DOCTYPE_DRAWING: + case Document.DOCTYPE_TEXT: + return mContext.getString(R.string.page) + " " + (i + 1); + case Document.DOCTYPE_SPREADSHEET: + return mContext.getString(R.string.sheet) + " " + (i + 1); + case Document.DOCTYPE_PRESENTATION: + return mContext.getString(R.string.slide) + " " + (i + 1); + case Document.DOCTYPE_OTHER: + default: + return mContext.getString(R.string.part) + " " + (i + 1); + } + } + + static float twipToPixel(float input, float dpi) { + return input / 1440.0f * dpi; + } + + private static float pixelToTwip(float input, float dpi) { + return (input / dpi) * 1440.0f; + } + + + /** + * @see TileProvider#getPartsCount() + */ + @Override + public int getPartsCount() { + return mDocument.getParts(); + } + + /** + * Wrapper for getPartPageRectangles() JNI function. + */ + public String getPartPageRectangles() { + return mDocument.getPartPageRectangles(); + } + + /** + * Fetch Calc header information. + */ + public String getCalcHeaders() { + long nX = 0; + long nY = 0; + long nWidth = mDocument.getDocumentWidth(); + long nHeight = mDocument.getDocumentHeight(); + return mDocument.getCommandValues(".uno:ViewRowColumnHeaders?x=" + nX + "&y=" + nY + + "&width=" + nWidth + "&height=" + nHeight); + } + + /** + * @see TileProvider#onSwipeLeft() + */ + @Override + public void onSwipeLeft() { + if (mDocument.getDocumentType() == Document.DOCTYPE_PRESENTATION && + getCurrentPartNumber() < getPartsCount()-1) { + LOKitShell.sendChangePartEvent(getCurrentPartNumber()+1); + } + } + + /** + * @see TileProvider#onSwipeRight() + */ + @Override + public void onSwipeRight() { + if (mDocument.getDocumentType() == Document.DOCTYPE_PRESENTATION && + getCurrentPartNumber() > 0) { + LOKitShell.sendChangePartEvent(getCurrentPartNumber()-1); + } + } + + private boolean checkDocument() { + String error = null; + boolean ret; + + if (mDocument == null || !mOffice.getError().isEmpty()) { + error = "Cannot open " + mInputFile + ": " + mOffice.getError(); + ret = false; + } else { + ret = resetDocumentSize(); + if (!ret) { + error = "Document returned an invalid size or the document is empty."; + } + } + + if (!ret && !mContext.isPasswordProtected()) { + final String message = error; + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + mContext.showAlertDialog(message); + } + }); + } else if (!ret && mContext.isPasswordProtected()) { + mContext.finish(); + } + + return ret; + } + + private boolean resetDocumentSize() { + mWidthTwip = mDocument.getDocumentWidth(); + mHeightTwip = mDocument.getDocumentHeight(); + + if (mWidthTwip == 0 || mHeightTwip == 0) { + Log.e(LOGTAG, "Document size zero - last error: " + mOffice.getError()); + return false; + } else { + Log.i(LOGTAG, "Reset document size: " + mDocument.getDocumentWidth() + " x " + mDocument.getDocumentHeight()); + } + + return true; + } + + @Override + public void setDocumentSize(int pageWidth, int pageHeight){ + mWidthTwip = pageWidth; + mHeightTwip = pageHeight; + } + + /** + * @see TileProvider#getPageWidth() + */ + @Override + public int getPageWidth() { + return (int) twipToPixel(mWidthTwip, mDPI); + } + + /** + * @see TileProvider#getPageHeight() + */ + @Override + public int getPageHeight() { + return (int) twipToPixel(mHeightTwip, mDPI); + } + + /** + * @see TileProvider#isReady() + */ + @Override + public boolean isReady() { + return mIsReady; + } + + /** + * @see TileProvider#createTile(float, float, org.mozilla.gecko.gfx.IntSize, float) + */ + @Override + public CairoImage createTile(float x, float y, IntSize tileSize, float zoom) { + ByteBuffer buffer = DirectBufferAllocator.guardedAllocate(tileSize.width * tileSize.height * 4); + if (buffer == null) + return null; + + CairoImage image = new BufferedCairoImage(buffer, tileSize.width, tileSize.height, CairoImage.FORMAT_ARGB32); + rerenderTile(image, x, y, tileSize, zoom); + return image; + } + + /** + * @see TileProvider#rerenderTile(org.mozilla.gecko.gfx.CairoImage, float, float, org.mozilla.gecko.gfx.IntSize, float) + */ + @Override + public void rerenderTile(CairoImage image, float x, float y, IntSize tileSize, float zoom) { + if (mDocument != null && image.getBuffer() != null) { + float twipX = pixelToTwip(x, mDPI) / zoom; + float twipY = pixelToTwip(y, mDPI) / zoom; + float twipWidth = mTileWidth / zoom; + float twipHeight = mTileHeight / zoom; + long start = System.currentTimeMillis() - objectCreationTime; + + //Log.i(LOGTAG, "paintTile >> @" + start + " (" + tileSize.width + " " + tileSize.height + " " + (int) twipX + " " + (int) twipY + " " + (int) twipWidth + " " + (int) twipHeight + ")"); + mDocument.paintTile(image.getBuffer(), tileSize.width, tileSize.height, (int) twipX, (int) twipY, (int) twipWidth, (int) twipHeight); + + long stop = System.currentTimeMillis() - objectCreationTime; + //Log.i(LOGTAG, "paintTile << @" + stop + " elapsed: " + (stop - start)); + } else { + if (mDocument == null) { + Log.e(LOGTAG, "Document is null!!"); + } + } + } + + /** + * @see TileProvider#thumbnail(int) + */ + @Override + public Bitmap thumbnail(int size) { + int widthPixel = getPageWidth(); + int heightPixel = getPageHeight(); + + if (widthPixel > heightPixel) { + double ratio = heightPixel / (double) widthPixel; + widthPixel = size; + heightPixel = (int) (widthPixel * ratio); + } else { + double ratio = widthPixel / (double) heightPixel; + heightPixel = size; + widthPixel = (int) (heightPixel * ratio); + } + + Log.w(LOGTAG, "Thumbnail size: " + getPageWidth() + " " + getPageHeight() + " " + widthPixel + " " + heightPixel); + + ByteBuffer buffer = ByteBuffer.allocateDirect(widthPixel * heightPixel * 4); + if (mDocument != null) + mDocument.paintTile(buffer, widthPixel, heightPixel, 0, 0, (int) mWidthTwip, (int) mHeightTwip); + + Bitmap bitmap = null; + try { + bitmap = Bitmap.createBitmap(widthPixel, heightPixel, Bitmap.Config.ARGB_8888); + bitmap.copyPixelsFromBuffer(buffer); + } catch (IllegalArgumentException e) { + Log.e(LOGTAG, "width (" + widthPixel + ") and height (" + heightPixel + ") must not be 0! (ToDo: likely timing issue)"); + } + if (bitmap == null) { + Log.w(LOGTAG, "Thumbnail not created!"); + } + return bitmap; + } + + /** + * @see TileProvider#close() + */ + @Override + public void close() { + Log.i(LOGTAG, "Document destroyed: " + mInputFile); + if (mDocument != null) { + mDocument.destroy(); + mDocument = null; + } + } + + /** + * @see TileProvider#isDrawing() + */ + @Override + public boolean isDrawing() { + return mDocument != null && mDocument.getDocumentType() == Document.DOCTYPE_DRAWING; + } + + /** + * @see TileProvider#isTextDocument() + */ + @Override + public boolean isTextDocument() { + return mDocument != null && mDocument.getDocumentType() == Document.DOCTYPE_TEXT; + } + + /** + * @see TileProvider#isSpreadsheet() + */ + @Override + public boolean isSpreadsheet() { + return mDocument != null && mDocument.getDocumentType() == Document.DOCTYPE_SPREADSHEET; + } + + /** + * @see TileProvider#isPresentation() + */ + @Override + public boolean isPresentation(){ + return mDocument != null && mDocument.getDocumentType() == Document.DOCTYPE_PRESENTATION; + } + + /** + * Returns the Unicode character generated by this event or 0. + */ + private int getCharCode(KeyEvent keyEvent) { + switch (keyEvent.getKeyCode()) + { + case KeyEvent.KEYCODE_DEL: + case KeyEvent.KEYCODE_ENTER: + return 0; + } + return keyEvent.getUnicodeChar(); + } + + /** + * Returns the integer code representing the key of the event (non-zero for + * control keys). + */ + private int getKeyCode(KeyEvent keyEvent) { + switch (keyEvent.getKeyCode()) { + case KeyEvent.KEYCODE_DEL: + return com.sun.star.awt.Key.BACKSPACE; + case KeyEvent.KEYCODE_ENTER: + return com.sun.star.awt.Key.RETURN; + } + return 0; + } + + /** + * @see TileProvider#sendKeyEvent(android.view.KeyEvent) + */ + @Override + public void sendKeyEvent(KeyEvent keyEvent) { + switch (keyEvent.getAction()) { + case KeyEvent.ACTION_MULTIPLE: + String keyString = keyEvent.getCharacters(); + for (int i = 0; i < keyString.length(); i++) { + int codePoint = keyString.codePointAt(i); + mDocument.postKeyEvent(Document.KEY_EVENT_PRESS, codePoint, getKeyCode(keyEvent)); + } + break; + case KeyEvent.ACTION_DOWN: + mDocument.postKeyEvent(Document.KEY_EVENT_PRESS, getCharCode(keyEvent), getKeyCode(keyEvent)); + break; + case KeyEvent.ACTION_UP: + mDocument.postKeyEvent(Document.KEY_EVENT_RELEASE, getCharCode(keyEvent), getKeyCode(keyEvent)); + break; + } + } + + private void mouseButton(int type, PointF inDocument, int numberOfClicks, float zoomFactor) { + int x = (int) pixelToTwip(inDocument.x, mDPI); + int y = (int) pixelToTwip(inDocument.y, mDPI); + + mDocument.setClientZoom(TILE_SIZE, TILE_SIZE, (int) (mTileWidth / zoomFactor), (int) (mTileHeight / zoomFactor)); + mDocument.postMouseEvent(type, x, y, numberOfClicks, Document.MOUSE_BUTTON_LEFT, Document.KEYBOARD_MODIFIER_NONE); + } + + /** + * @see TileProvider#mouseButtonDown(android.graphics.PointF, int, float) + */ + @Override + public void mouseButtonDown(PointF documentCoordinate, int numberOfClicks, float zoomFactor) { + mouseButton(Document.MOUSE_EVENT_BUTTON_DOWN, documentCoordinate, numberOfClicks, zoomFactor); + } + + /** + * @see TileProvider#mouseButtonUp(android.graphics.PointF, int, float) + */ + @Override + public void mouseButtonUp(PointF documentCoordinate, int numberOfClicks, float zoomFactor) { + mouseButton(Document.MOUSE_EVENT_BUTTON_UP, documentCoordinate, numberOfClicks, zoomFactor); + } + + /** + * @param command UNO command string + * @param arguments Arguments to UNO command + */ + @Override + public void postUnoCommand(String command, String arguments) { + postUnoCommand(command, arguments, false); + } + + /** + * @param command + * @param arguments + * @param notifyWhenFinished + */ + @Override + public void postUnoCommand(String command, String arguments, boolean notifyWhenFinished) { + mDocument.postUnoCommand(command, arguments, notifyWhenFinished); + } + + private void setTextSelection(int type, PointF documentCoordinate) { + int x = (int) pixelToTwip(documentCoordinate.x, mDPI); + int y = (int) pixelToTwip(documentCoordinate.y, mDPI); + mDocument.setTextSelection(type, x, y); + } + + /** + * @see TileProvider#setTextSelectionStart(android.graphics.PointF) + */ + @Override + public void setTextSelectionStart(PointF documentCoordinate) { + setTextSelection(Document.SET_TEXT_SELECTION_START, documentCoordinate); + } + + /** + * @see TileProvider#setTextSelectionEnd(android.graphics.PointF) + */ + @Override + public void setTextSelectionEnd(PointF documentCoordinate) { + setTextSelection(Document.SET_TEXT_SELECTION_END, documentCoordinate); + } + + /** + * @see TileProvider#setTextSelectionReset(android.graphics.PointF) + */ + @Override + public void setTextSelectionReset(PointF documentCoordinate) { + setTextSelection(Document.SET_TEXT_SELECTION_RESET, documentCoordinate); + } + + /** + * @param mimeType + * @return + */ + @Override + public String getTextSelection(String mimeType) { + return mDocument.getTextSelection(mimeType); + } + + /** + * paste + * @param mimeType + * @param data + * @return + */ + @Override + public boolean paste(String mimeType, String data) { + return mDocument.paste(mimeType, data); + } + + + /** + * @see org.libreoffice.TileProvider#setGraphicSelectionStart(android.graphics.PointF) + */ + @Override + public void setGraphicSelectionStart(PointF documentCoordinate) { + setGraphicSelection(Document.SET_GRAPHIC_SELECTION_START, documentCoordinate); + } + + /** + * @see org.libreoffice.TileProvider#setGraphicSelectionEnd(android.graphics.PointF) + */ + @Override + public void setGraphicSelectionEnd(PointF documentCoordinate) { + setGraphicSelection(Document.SET_GRAPHIC_SELECTION_END, documentCoordinate); + } + + private void setGraphicSelection(int type, PointF documentCoordinate) { + int x = (int) pixelToTwip(documentCoordinate.x, mDPI); + int y = (int) pixelToTwip(documentCoordinate.y, mDPI); + LibreOfficeMainActivity.setDocumentChanged(true); + mDocument.setGraphicSelection(type, x, y); + } + + @Override + protected void finalize() throws Throwable { + close(); + super.finalize(); + } + + /** + * @see TileProvider#changePart(int) + */ + @Override + public void changePart(int partIndex) { + if (mDocument == null) + return; + + mDocument.setPart(partIndex); + resetDocumentSize(); + } + + /** + * @see TileProvider#getCurrentPartNumber() + */ + @Override + public int getCurrentPartNumber() { + if (mDocument == null) + return 0; + + return mDocument.getPart(); + } + + public void setDocumentPassword(String url, String password) { + mOffice.setDocumentPassword(url, password); + } + + public Document.MessageCallback getMessageCallback() { + return mMessageCallback; + } +} + +// vim:set shiftwidth=4 softtabstop=4 expandtab: diff --git a/android/source/src/java/org/libreoffice/LibreOfficeApplication.java b/android/source/src/java/org/libreoffice/LibreOfficeApplication.java new file mode 100644 index 0000000000..ebe54cf27c --- /dev/null +++ b/android/source/src/java/org/libreoffice/LibreOfficeApplication.java @@ -0,0 +1,33 @@ +/* + * + * * This file is part of the LibreOffice project. + * * + * * This Source Code Form is subject to the terms of the Mozilla Public + * * License, v. 2.0. If a copy of the MPL was not distributed with this + * * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + */ + +package org.libreoffice; + +import android.content.Context; +import android.os.Handler; +import androidx.multidex.MultiDexApplication; + +public class LibreOfficeApplication extends MultiDexApplication { + + private static Handler mainHandler; + + public LibreOfficeApplication() { + mainHandler = new Handler(); + } + + public static Handler getMainHandler() { + return mainHandler; + } + + @Override + protected void attachBaseContext(Context base) { + super.attachBaseContext(LocaleHelper.onAttach(base)); + } +} diff --git a/android/source/src/java/org/libreoffice/LibreOfficeMainActivity.java b/android/source/src/java/org/libreoffice/LibreOfficeMainActivity.java new file mode 100644 index 0000000000..23bf8d27b6 --- /dev/null +++ b/android/source/src/java/org/libreoffice/LibreOfficeMainActivity.java @@ -0,0 +1,1113 @@ +package org.libreoffice; + +import android.app.AlertDialog; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.ContentResolver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.AssetManager; +import android.graphics.RectF; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.provider.DocumentsContract; +import com.google.android.material.bottomsheet.BottomSheetBehavior; +import com.google.android.material.snackbar.Snackbar; +import androidx.drawerlayout.widget.DrawerLayout; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import android.text.InputType; +import android.util.Log; +import android.view.KeyEvent; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.widget.AdapterView; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.TabHost; +import android.widget.Toast; + +import org.libreoffice.overlay.CalcHeadersController; +import org.libreoffice.overlay.DocumentOverlay; +import org.libreoffice.ui.FileUtilities; +import org.libreoffice.ui.LibreOfficeUIActivity; +import org.mozilla.gecko.gfx.GeckoLayerClient; +import org.mozilla.gecko.gfx.LayerView; + +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.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.channels.ReadableByteChannel; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Main activity of the LibreOffice App. It is started in the UI thread. + */ +public class LibreOfficeMainActivity extends AppCompatActivity implements SettingsListenerModel.OnSettingsPreferenceChangedListener { + + private static final String LOGTAG = "LibreOfficeMainActivity"; + public static final String ENABLE_EXPERIMENTAL_PREFS_KEY = "ENABLE_EXPERIMENTAL"; + private static final String ASSETS_EXTRACTED_PREFS_KEY = "ASSETS_EXTRACTED"; + private static final String ENABLE_DEVELOPER_PREFS_KEY = "ENABLE_DEVELOPER"; + private static final int REQUEST_CODE_SAVEAS = 12345; + private static final int REQUEST_CODE_EXPORT_TO_PDF = 12346; + + //TODO "public static" is a temporary workaround + public static LOKitThread loKitThread; + + private GeckoLayerClient mLayerClient; + + private static boolean mIsExperimentalMode; + private static boolean mIsDeveloperMode; + private static boolean mbISReadOnlyMode; + + private DrawerLayout mDrawerLayout; + Toolbar toolbarTop; + + private ListView mDrawerList; + private final List<DocumentPartView> mDocumentPartView = new ArrayList<DocumentPartView>(); + private DocumentPartViewListAdapter mDocumentPartViewListAdapter; + private DocumentOverlay mDocumentOverlay; + /** URI to save the document to. */ + private Uri mDocumentUri; + /** Temporary local copy of the document. */ + private File mTempFile = null; + private File mTempSlideShowFile = null; + + BottomSheetBehavior bottomToolbarSheetBehavior; + BottomSheetBehavior toolbarColorPickerBottomSheetBehavior; + BottomSheetBehavior toolbarBackColorPickerBottomSheetBehavior; + private FormattingController mFormattingController; + private ToolbarController mToolbarController; + private FontController mFontController; + private SearchController mSearchController; + private UNOCommandsController mUNOCommandsController; + private CalcHeadersController mCalcHeadersController; + private LOKitTileProvider mTileProvider; + private String mPassword; + private boolean mPasswordProtected; + private boolean mbSkipNextRefresh; + + public GeckoLayerClient getLayerClient() { + return mLayerClient; + } + + public static boolean isExperimentalMode() { + return mIsExperimentalMode; + } + + public static boolean isDeveloperMode() { + return mIsDeveloperMode; + } + + private boolean isKeyboardOpen = false; + private boolean isFormattingToolbarOpen = false; + private boolean isSearchToolbarOpen = false; + private static boolean isDocumentChanged = false; + private boolean isUNOCommandsToolbarOpen = false; + + @Override + public void onCreate(Bundle savedInstanceState) { + Log.w(LOGTAG, "onCreate.."); + super.onCreate(savedInstanceState); + + SettingsListenerModel.getInstance().setListener(this); + updatePreferences(); + + setContentView(R.layout.activity_main); + + toolbarTop = findViewById(R.id.toolbar); + hideBottomToolbar(); + + mToolbarController = new ToolbarController(this, toolbarTop); + mFormattingController = new FormattingController(this); + toolbarTop.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + LOKitShell.sendNavigationClickEvent(); + } + }); + + mFontController = new FontController(this); + mSearchController = new SearchController(this); + mUNOCommandsController = new UNOCommandsController(this); + + loKitThread = new LOKitThread(this); + loKitThread.start(); + + mLayerClient = new GeckoLayerClient(this); + LayerView layerView = findViewById(R.id.layer_view); + mLayerClient.setView(layerView); + layerView.setInputConnectionHandler(new LOKitInputConnectionHandler()); + mLayerClient.notifyReady(); + + layerView.setOnKeyListener(new View.OnKeyListener() { + @Override + public boolean onKey(View view, int i, KeyEvent keyEvent) { + if(!isReadOnlyMode() && keyEvent.getKeyCode() != KeyEvent.KEYCODE_BACK){ + setDocumentChanged(true); + } + return false; + } + }); + + // create TextCursorLayer + mDocumentOverlay = new DocumentOverlay(this, layerView); + + mbISReadOnlyMode = !isExperimentalMode(); + + final Uri docUri = getIntent().getData(); + if (docUri != null) { + if (docUri.getScheme().equals(ContentResolver.SCHEME_CONTENT) + || docUri.getScheme().equals(ContentResolver.SCHEME_ANDROID_RESOURCE)) { + final boolean isReadOnlyDoc = (getIntent().getFlags() & Intent.FLAG_GRANT_WRITE_URI_PERMISSION) == 0; + mbISReadOnlyMode = !isExperimentalMode() || isReadOnlyDoc; + Log.d(LOGTAG, "SCHEME_CONTENT: getPath(): " + docUri.getPath()); + + String displayName = FileUtilities.retrieveDisplayNameForDocumentUri(getContentResolver(), docUri); + toolbarTop.setTitle(displayName); + + } else if (docUri.getScheme().equals(ContentResolver.SCHEME_FILE)) { + mbISReadOnlyMode = true; + Log.d(LOGTAG, "SCHEME_FILE: getPath(): " + docUri.getPath()); + toolbarTop.setTitle(docUri.getLastPathSegment()); + } + // create a temporary local copy to work with + boolean copyOK = copyFileToTemp(docUri) && mTempFile != null; + if (!copyOK) { + // TODO: can't open the file + Log.e(LOGTAG, "couldn't create temporary file from " + docUri); + return; + } + + // if input doc is a template, a new doc is created and a proper URI to save to + // will only be available after a "Save As" + if (isTemplate(docUri)) { + toolbarTop.setTitle(R.string.default_document_name); + } else { + mDocumentUri = docUri; + } + + LOKitShell.sendLoadEvent(mTempFile.getPath()); + } else if (getIntent().getStringExtra(LibreOfficeUIActivity.NEW_DOC_TYPE_KEY) != null) { + // New document type string is not null, meaning we want to open a new document + String newDocumentType = getIntent().getStringExtra(LibreOfficeUIActivity.NEW_DOC_TYPE_KEY); + // create a temporary local file, will be copied to the actual URI when saving + loadNewDocument(newDocumentType); + toolbarTop.setTitle(getString(R.string.default_document_name)); + } else { + Log.e(LOGTAG, "No document specified. This should never happen."); + return; + } + // the loadDocument/loadNewDocument event already triggers a refresh as well, + // so there's no need to do another refresh in 'onStart' + mbSkipNextRefresh = true; + + mDrawerLayout = findViewById(R.id.drawer_layout); + + if (mDocumentPartViewListAdapter == null) { + mDrawerList = findViewById(R.id.left_drawer); + + mDocumentPartViewListAdapter = new DocumentPartViewListAdapter(this, R.layout.document_part_list_layout, mDocumentPartView); + mDrawerList.setAdapter(mDocumentPartViewListAdapter); + mDrawerList.setOnItemClickListener(new DocumentPartClickListener()); + } + + mToolbarController.setupToolbars(); + + TabHost host = findViewById(R.id.toolbarTabHost); + host.setup(); + + TabHost.TabSpec spec = host.newTabSpec(getString(R.string.tabhost_character)); + spec.setContent(R.id.tab_character); + spec.setIndicator(getString(R.string.tabhost_character)); + host.addTab(spec); + + spec = host.newTabSpec(getString(R.string.tabhost_paragraph)); + spec.setContent(R.id.tab_paragraph); + spec.setIndicator(getString(R.string.tabhost_paragraph)); + host.addTab(spec); + + spec = host.newTabSpec(getString(R.string.tabhost_insert)); + spec.setContent(R.id.tab_insert); + spec.setIndicator(getString(R.string.tabhost_insert)); + host.addTab(spec); + + spec = host.newTabSpec(getString(R.string.tabhost_style)); + spec.setContent(R.id.tab_style); + spec.setIndicator(getString(R.string.tabhost_style)); + host.addTab(spec); + + LinearLayout bottomToolbarLayout = findViewById(R.id.toolbar_bottom); + LinearLayout toolbarColorPickerLayout = findViewById(R.id.toolbar_color_picker); + LinearLayout toolbarBackColorPickerLayout = findViewById(R.id.toolbar_back_color_picker); + bottomToolbarSheetBehavior = BottomSheetBehavior.from(bottomToolbarLayout); + toolbarColorPickerBottomSheetBehavior = BottomSheetBehavior.from(toolbarColorPickerLayout); + toolbarBackColorPickerBottomSheetBehavior = BottomSheetBehavior.from(toolbarBackColorPickerLayout); + bottomToolbarSheetBehavior.setHideable(true); + toolbarColorPickerBottomSheetBehavior.setHideable(true); + toolbarBackColorPickerBottomSheetBehavior.setHideable(true); + } + + private void updatePreferences() { + SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + mIsExperimentalMode = BuildConfig.ALLOW_EDITING + && sPrefs.getBoolean(ENABLE_EXPERIMENTAL_PREFS_KEY, false); + mIsDeveloperMode = mIsExperimentalMode + && sPrefs.getBoolean(ENABLE_DEVELOPER_PREFS_KEY, false); + if (sPrefs.getInt(ASSETS_EXTRACTED_PREFS_KEY, 0) != BuildConfig.VERSION_CODE) { + if(copyFromAssets(getAssets(), "unpack", getApplicationInfo().dataDir)) { + sPrefs.edit().putInt(ASSETS_EXTRACTED_PREFS_KEY, BuildConfig.VERSION_CODE).apply(); + } + } + } + + // Loads a new Document and saves it to a temporary file + private void loadNewDocument(String newDocumentType) { + String tempFileName = "LibreOffice_" + UUID.randomUUID().toString(); + mTempFile = new File(this.getCacheDir(), tempFileName); + LOKitShell.sendNewDocumentLoadEvent(mTempFile.getPath(), newDocumentType); + } + + public RectF getCurrentCursorPosition() { + return mDocumentOverlay.getCurrentCursorPosition(); + } + + private boolean copyFileToTemp(Uri documentUri) { + // CSV files need a .csv suffix to be opened in Calc. + String suffix = null; + String intentType = getIntent().getType(); + // K-9 mail uses the first, GMail uses the second variant. + if ("text/comma-separated-values".equals(intentType) || "text/csv".equals(intentType)) + suffix = ".csv"; + + try { + mTempFile = File.createTempFile("LibreOffice", suffix, this.getCacheDir()); + final FileOutputStream outputStream = new FileOutputStream(mTempFile); + return copyUriToStream(documentUri, outputStream); + } catch (FileNotFoundException e) { + return false; + } catch (IOException e) { + return false; + } + } + + /** + * Save the document. + */ + public void saveDocument() { + Toast.makeText(this, R.string.message_saving, Toast.LENGTH_SHORT).show(); + // local save + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND_NOTIFY, ".uno:Save", true)); + } + + /** + * Open file chooser and save the document to the URI + * selected there. + */ + public void saveDocumentAs() { + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + String mimeType = getODFMimeTypeForDocument(); + intent.setType(mimeType); + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, mDocumentUri); + + startActivityForResult(intent, REQUEST_CODE_SAVEAS); + } + + /** + * Saves the document under the given URI using ODF format + * and uses that URI from now on for all operations. + * @param newUri URI to save the document and use from now on. + */ + private void saveDocumentAs(Uri newUri) { + mDocumentUri = newUri; + // save in ODF format + mTileProvider.saveDocumentAs(mTempFile.getPath(), true); + saveFileToOriginalSource(); + + String displayName = FileUtilities.retrieveDisplayNameForDocumentUri(getContentResolver(), mDocumentUri); + toolbarTop.setTitle(displayName); + mbISReadOnlyMode = !isExperimentalMode(); + getToolbarController().setupToolbars(); + } + + public void exportToPDF() { + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType(FileUtilities.MIMETYPE_PDF); + // suggest directory and file name based on the doc + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, mDocumentUri); + final String displayName = toolbarTop.getTitle().toString(); + final String suggestedFileName = FileUtilities.stripExtensionFromFileName(displayName) + ".pdf"; + intent.putExtra(Intent.EXTRA_TITLE, suggestedFileName); + + startActivityForResult(intent, REQUEST_CODE_EXPORT_TO_PDF); + } + + private void exportToPDF(final Uri uri) { + boolean exportOK = false; + File tempFile = null; + try { + tempFile = File.createTempFile("LibreOffice_", ".pdf"); + mTileProvider.saveDocumentAs(tempFile.getAbsolutePath(),"pdf", false); + + try { + FileInputStream inputStream = new FileInputStream(tempFile); + exportOK = copyStreamToUri(inputStream, uri); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } + + } catch (IOException e) { + e.printStackTrace(); + } finally { + if (tempFile != null && tempFile.exists()) { + tempFile.delete(); + } + } + + final int msgId = exportOK ? R.string.pdf_export_finished : R.string.unable_to_export_pdf; + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + showCustomStatusMessage(getString(msgId)); + } + }); + } + + /** + * Returns the ODF MIME type that can be used for the current document, + * regardless of whether the document is an ODF Document or not + * (e.g. returns FileUtilities.MIMETYPE_OPENDOCUMENT_TEXT for a DOCX file). + * @return MIME type, or empty string, if no appropriate MIME type could be found. + */ + private String getODFMimeTypeForDocument() { + if (mTileProvider.isTextDocument()) + return FileUtilities.MIMETYPE_OPENDOCUMENT_TEXT; + else if (mTileProvider.isSpreadsheet()) + return FileUtilities.MIMETYPE_OPENDOCUMENT_SPREADSHEET; + else if (mTileProvider.isPresentation()) + return FileUtilities.MIMETYPE_OPENDOCUMENT_PRESENTATION; + else if (mTileProvider.isDrawing()) + return FileUtilities.MIMETYPE_OPENDOCUMENT_GRAPHICS; + else { + Log.w(LOGTAG, "Cannot determine MIME type to use."); + return ""; + } + } + + /** + * Returns whether the MIME type for the URI is considered one for a document template. + */ + private boolean isTemplate(final Uri documentUri) { + final String mimeType = getContentResolver().getType(documentUri); + return FileUtilities.isTemplateMimeType(mimeType); + } + + public void saveFileToOriginalSource() { + if (mTempFile == null || mDocumentUri == null || !mDocumentUri.getScheme().equals(ContentResolver.SCHEME_CONTENT)) + return; + + boolean copyOK = false; + try { + final FileInputStream inputStream = new FileInputStream(mTempFile); + copyOK = copyStreamToUri(inputStream, mDocumentUri); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } + if (copyOK) { + runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(LibreOfficeMainActivity.this, R.string.message_saved, + Toast.LENGTH_SHORT).show(); + } + }); + setDocumentChanged(false); + } else { + runOnUiThread(new Runnable() { + @Override + public void run() { + Toast.makeText(LibreOfficeMainActivity.this, R.string.message_saving_failed, + Toast.LENGTH_SHORT).show(); + } + }); + } + } + + @Override + protected void onResume() { + super.onResume(); + Log.i(LOGTAG, "onResume.."); + // check for config change + updatePreferences(); + if (mToolbarController.getEditModeStatus() && isExperimentalMode()) { + mToolbarController.switchToEditMode(); + } else { + mToolbarController.switchToViewMode(); + } + } + + @Override + protected void onPause() { + Log.i(LOGTAG, "onPause.."); + super.onPause(); + } + + @Override + protected void onStart() { + Log.i(LOGTAG, "onStart.."); + super.onStart(); + if (!mbSkipNextRefresh) { + LOKitShell.sendEvent(new LOEvent(LOEvent.REFRESH)); + } + mbSkipNextRefresh = false; + } + + @Override + protected void onStop() { + Log.i(LOGTAG, "onStop.."); + hideSoftKeyboardDirect(); + super.onStop(); + } + + @Override + protected void onDestroy() { + Log.i(LOGTAG, "onDestroy.."); + LOKitShell.sendCloseEvent(); + mLayerClient.destroy(); + super.onDestroy(); + + if (isFinishing()) { // Not an orientation change + if (mTempFile != null) { + // noinspection ResultOfMethodCallIgnored + mTempFile.delete(); + } + if (mTempSlideShowFile != null && mTempSlideShowFile.exists()) { + // noinspection ResultOfMethodCallIgnored + mTempSlideShowFile.delete(); + } + } + } + @Override + public void onBackPressed() { + if (!isDocumentChanged) { + super.onBackPressed(); + return; + } + + + DialogInterface.OnClickListener dialogClickListener = new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + switch (which){ + case DialogInterface.BUTTON_POSITIVE: + mTileProvider.saveDocument(); + isDocumentChanged=false; + onBackPressed(); + break; + case DialogInterface.BUTTON_NEGATIVE: + //CANCEL + break; + case DialogInterface.BUTTON_NEUTRAL: + //NO + isDocumentChanged=false; + onBackPressed(); + break; + } + } + }; + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setMessage(R.string.save_alert_dialog_title) + .setPositiveButton(R.string.save_document, dialogClickListener) + .setNegativeButton(R.string.action_cancel, dialogClickListener) + .setNeutralButton(R.string.no_save_document, dialogClickListener) + .show(); + + } + + public List<DocumentPartView> getDocumentPartView() { + return mDocumentPartView; + } + + public void disableNavigationDrawer() { + // Only the original thread that created mDrawerLayout should touch its views. + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + mDrawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, mDrawerList); + } + }); + } + + public DocumentPartViewListAdapter getDocumentPartViewListAdapter() { + return mDocumentPartViewListAdapter; + } + + /** + * Show software keyboard. + * Force the request on main thread. + */ + public void showSoftKeyboard() { + + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + if(!isKeyboardOpen) showSoftKeyboardDirect(); + else hideSoftKeyboardDirect(); + } + }); + + } + + private void showSoftKeyboardDirect() { + LayerView layerView = findViewById(R.id.layer_view); + + if (layerView.requestFocus()) { + InputMethodManager inputMethodManager = (InputMethodManager) getApplicationContext().getSystemService(Context.INPUT_METHOD_SERVICE); + inputMethodManager.showSoftInput(layerView, InputMethodManager.SHOW_FORCED); + } + isKeyboardOpen=true; + isSearchToolbarOpen=false; + isFormattingToolbarOpen=false; + isUNOCommandsToolbarOpen=false; + hideBottomToolbar(); + } + + public void showSoftKeyboardOrFormattingToolbar() { + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + if (findViewById(R.id.toolbar_bottom).getVisibility() != View.VISIBLE + && findViewById(R.id.toolbar_color_picker).getVisibility() != View.VISIBLE) { + showSoftKeyboardDirect(); + } + } + }); + } + + /** + * Hides software keyboard on UI thread. + */ + public void hideSoftKeyboard() { + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + hideSoftKeyboardDirect(); + } + }); + } + + /** + * Hides software keyboard. + */ + private void hideSoftKeyboardDirect() { + if (getCurrentFocus() != null) { + InputMethodManager inputMethodManager = (InputMethodManager) getApplicationContext().getSystemService(Context.INPUT_METHOD_SERVICE); + inputMethodManager.hideSoftInputFromWindow(getCurrentFocus().getWindowToken(), 0); + isKeyboardOpen=false; + } + } + + public void showBottomToolbar() { + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + bottomToolbarSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); + } + }); + } + + public void hideBottomToolbar() { + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + bottomToolbarSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + toolbarColorPickerBottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + toolbarBackColorPickerBottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + findViewById(R.id.search_toolbar).setVisibility(View.GONE); + findViewById(R.id.UNO_commands_toolbar).setVisibility(View.GONE); + isFormattingToolbarOpen=false; + isSearchToolbarOpen=false; + isUNOCommandsToolbarOpen=false; + } + }); + } + + public void showFormattingToolbar() { + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + if (isFormattingToolbarOpen) { + hideFormattingToolbar(); + } else { + showBottomToolbar(); + findViewById(R.id.search_toolbar).setVisibility(View.GONE); + findViewById(R.id.formatting_toolbar).setVisibility(View.VISIBLE); + findViewById(R.id.search_toolbar).setVisibility(View.GONE); + findViewById(R.id.UNO_commands_toolbar).setVisibility(View.GONE); + hideSoftKeyboardDirect(); + isSearchToolbarOpen=false; + isFormattingToolbarOpen=true; + isUNOCommandsToolbarOpen=false; + } + + } + }); + } + + public void hideFormattingToolbar() { + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + hideBottomToolbar(); + } + }); + } + + public void showSearchToolbar() { + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + if (isSearchToolbarOpen) { + hideSearchToolbar(); + } else { + showBottomToolbar(); + findViewById(R.id.formatting_toolbar).setVisibility(View.GONE); + toolbarColorPickerBottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + toolbarBackColorPickerBottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + findViewById(R.id.search_toolbar).setVisibility(View.VISIBLE); + findViewById(R.id.UNO_commands_toolbar).setVisibility(View.GONE); + hideSoftKeyboardDirect(); + isFormattingToolbarOpen=false; + isSearchToolbarOpen=true; + isUNOCommandsToolbarOpen=false; + } + } + }); + } + + public void hideSearchToolbar() { + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + hideBottomToolbar(); + } + }); + } + + public void showUNOCommandsToolbar() { + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + if(isUNOCommandsToolbarOpen){ + hideUNOCommandsToolbar(); + }else{ + showBottomToolbar(); + findViewById(R.id.formatting_toolbar).setVisibility(View.GONE); + findViewById(R.id.search_toolbar).setVisibility(View.GONE); + findViewById(R.id.UNO_commands_toolbar).setVisibility(View.VISIBLE); + hideSoftKeyboardDirect(); + isFormattingToolbarOpen=false; + isSearchToolbarOpen=false; + isUNOCommandsToolbarOpen=true; + } + } + }); + } + + public void hideUNOCommandsToolbar() { + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + hideBottomToolbar(); + } + }); + } + + public void showProgressSpinner() { + findViewById(R.id.loadingPanel).setVisibility(View.VISIBLE); + } + + public void hideProgressSpinner() { + findViewById(R.id.loadingPanel).setVisibility(View.GONE); + } + + public void showAlertDialog(String message) { + + AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(LibreOfficeMainActivity.this); + + alertDialogBuilder.setTitle(R.string.error); + alertDialogBuilder.setMessage(message); + alertDialogBuilder.setNeutralButton(R.string.alert_ok, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + finish(); + } + }); + + AlertDialog alertDialog = alertDialogBuilder.create(); + alertDialog.show(); + } + + public DocumentOverlay getDocumentOverlay() { + return mDocumentOverlay; + } + + public CalcHeadersController getCalcHeadersController() { + return mCalcHeadersController; + } + + public ToolbarController getToolbarController() { + return mToolbarController; + } + + public FontController getFontController() { + return mFontController; + } + + public FormattingController getFormattingController() { + return mFormattingController; + } + + public void openDrawer() { + mDrawerLayout.openDrawer(mDrawerList); + hideBottomToolbar(); + } + + public void showAbout() { + AboutDialogFragment aboutDialogFragment = new AboutDialogFragment(); + aboutDialogFragment.show(getSupportFragmentManager(), "AboutDialogFragment"); + } + + public void addPart(){ + mTileProvider.addPart(); + mDocumentPartViewListAdapter.notifyDataSetChanged(); + setDocumentChanged(true); + } + + public void renamePart(){ + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.enter_part_name); + final EditText input = new EditText(this); + input.setInputType(InputType.TYPE_CLASS_TEXT); + builder.setView(input); + + builder.setPositiveButton(R.string.alert_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + mTileProvider.renamePart( input.getText().toString()); + } + }); + builder.setNegativeButton(R.string.alert_cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.cancel(); + } + }); + + builder.show(); + } + + public void deletePart() { + mTileProvider.removePart(); + } + + public void showSettings() { + startActivity(new Intent(getApplicationContext(), SettingsActivity.class)); + } + + public boolean isDrawerEnabled() { + boolean isDrawerOpen = mDrawerLayout.isDrawerOpen(mDrawerList); + boolean isDrawerLocked = mDrawerLayout.getDrawerLockMode(mDrawerList) != DrawerLayout.LOCK_MODE_UNLOCKED; + return !isDrawerOpen && !isDrawerLocked; + } + + @Override + public void settingsPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if (key.matches(ENABLE_EXPERIMENTAL_PREFS_KEY)) { + Log.d(LOGTAG, "Editing Preference Changed"); + mIsExperimentalMode = sharedPreferences.getBoolean(ENABLE_EXPERIMENTAL_PREFS_KEY, false); + } + } + + public void promptForPassword() { + PasswordDialogFragment passwordDialogFragment = new PasswordDialogFragment(); + passwordDialogFragment.setLOMainActivity(this); + passwordDialogFragment.show(getSupportFragmentManager(), "PasswordDialogFragment"); + } + + // this function can only be called in InvalidationHandler.java + public void setPassword() { + mTileProvider.setDocumentPassword("file://" + mTempFile.getPath(), mPassword); + } + + // setTileProvider is meant to let main activity have a handle of LOKit when dealing with password + public void setTileProvider(LOKitTileProvider loKitTileProvider) { + mTileProvider = loKitTileProvider; + } + + public LOKitTileProvider getTileProvider() { + return mTileProvider; + } + + public void savePassword(String pwd) { + mPassword = pwd; + synchronized (mTileProvider.getMessageCallback()) { + mTileProvider.getMessageCallback().notifyAll(); + } + } + + public void setPasswordProtected(boolean b) { + mPasswordProtected = b; + } + + public boolean isPasswordProtected() { + return mPasswordProtected; + } + + public void initializeCalcHeaders() { + mCalcHeadersController = new CalcHeadersController(this, mLayerClient.getView()); + mCalcHeadersController.setupHeaderPopupView(); + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + findViewById(R.id.calc_header_top_left).setVisibility(View.VISIBLE); + findViewById(R.id.calc_header_row).setVisibility(View.VISIBLE); + findViewById(R.id.calc_header_column).setVisibility(View.VISIBLE); + findViewById(R.id.calc_address).setVisibility(View.VISIBLE); + findViewById(R.id.calc_formula).setVisibility(View.VISIBLE); + } + }); + } + + public static boolean isReadOnlyMode() { + return mbISReadOnlyMode; + } + + public boolean hasLocationForSave() { + return mDocumentUri != null; + } + + public static void setDocumentChanged (boolean changed) { + isDocumentChanged = changed; + } + + private class DocumentPartClickListener implements android.widget.AdapterView.OnItemClickListener { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + DocumentPartView partView = mDocumentPartViewListAdapter.getItem(position); + LOKitShell.sendChangePartEvent(partView.partIndex); + mDrawerLayout.closeDrawer(mDrawerList); + } + } + + private static boolean copyFromAssets(AssetManager assetManager, + String fromAssetPath, String targetDir) { + try { + String[] files = assetManager.list(fromAssetPath); + + boolean res = true; + for (String file : files) { + String[] dirOrFile = assetManager.list(fromAssetPath + "/" + file); + if ( dirOrFile.length == 0) { + // noinspection ResultOfMethodCallIgnored + new File(targetDir).mkdirs(); + res &= copyAsset(assetManager, + fromAssetPath + "/" + file, + targetDir + "/" + file); + } else + res &= copyFromAssets(assetManager, + fromAssetPath + "/" + file, + targetDir + "/" + file); + } + return res; + } catch (Exception e) { + e.printStackTrace(); + Log.e(LOGTAG, "copyFromAssets failed: " + e.getMessage()); + return false; + } + } + + private static boolean copyAsset(AssetManager assetManager, String fromAssetPath, String toPath) { + ReadableByteChannel source = null; + FileChannel dest = null; + try { + try { + source = Channels.newChannel(assetManager.open(fromAssetPath)); + dest = new FileOutputStream(toPath).getChannel(); + long bytesTransferred = 0; + // might not copy all at once, so make sure everything gets copied... + ByteBuffer buffer = ByteBuffer.allocate(4096); + while (source.read(buffer) > 0) { + buffer.flip(); + bytesTransferred += dest.write(buffer); + buffer.clear(); + } + Log.v(LOGTAG, "Success copying " + fromAssetPath + " to " + toPath + " bytes: " + bytesTransferred); + return true; + } finally { + if (dest != null) dest.close(); + if (source != null) source.close(); + } + } catch (FileNotFoundException e) { + Log.e(LOGTAG, "file " + fromAssetPath + " not found! " + e.getMessage()); + return false; + } catch (IOException e) { + Log.e(LOGTAG, "failed to copy file " + fromAssetPath + " from assets to " + toPath + " - " + e.getMessage()); + return false; + } + } + + /** + * Copies everything from the given input stream to the given output stream + * and closes both streams in the end. + * @return Whether copy operation was successful. + */ + private boolean copyStream(InputStream inputStream, OutputStream outputStream) { + try { + byte[] buffer = new byte[4096]; + int readBytes = inputStream.read(buffer); + while (readBytes != -1) { + outputStream.write(buffer, 0, readBytes); + readBytes = inputStream.read(buffer); + } + return true; + } catch (IOException e) { + e.printStackTrace(); + return false; + } finally { + try { + inputStream.close(); + outputStream.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + /** + * Copies everything from the given Uri to the given OutputStream + * and closes the OutputStream in the end. + * The copy operation runs in a separate thread, but the method only returns + * after the thread has finished its execution. + * This can be used to copy in a blocking way when network access is involved, + * which is not allowed from the main thread, but that may happen when an underlying + * DocumentsProvider (like the NextCloud one) does network access. + */ + private boolean copyUriToStream(final Uri inputUri, final OutputStream outputStream) { + class CopyThread extends Thread { + /** Whether copy operation was successful. */ + private boolean result = false; + + @Override + public void run() { + final ContentResolver contentResolver = getContentResolver(); + try { + InputStream inputStream = contentResolver.openInputStream(inputUri); + result = copyStream(inputStream, outputStream); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } + } + } + CopyThread copyThread = new CopyThread(); + copyThread.start(); + try { + // wait for copy operation to finish + // NOTE: might be useful to add some indicator in UI for long copy operations involving network... + copyThread.join(); + } catch(InterruptedException e) { + e.printStackTrace(); + } + return copyThread.result; + } + + /** + * Copies everything from the given InputStream to the given URI and closes the + * InputStream in the end. + * @see LibreOfficeMainActivity#copyUriToStream(Uri, OutputStream) + * which does the same thing the other way around. + */ + private boolean copyStreamToUri(final InputStream inputStream, final Uri outputUri) { + class CopyThread extends Thread { + /** Whether copy operation was successful. */ + private boolean result = false; + + @Override + public void run() { + final ContentResolver contentResolver = getContentResolver(); + try { + OutputStream outputStream = contentResolver.openOutputStream(outputUri); + result = copyStream(inputStream, outputStream); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } + } + } + CopyThread copyThread = new CopyThread(); + copyThread.start(); + try { + // wait for copy operation to finish + // NOTE: might be useful to add some indicator in UI for long copy operations involving network... + copyThread.join(); + } catch(InterruptedException e) { + e.printStackTrace(); + } + return copyThread.result; + } + + public void showCustomStatusMessage(String message){ + Snackbar.make(mDrawerLayout, message, Snackbar.LENGTH_LONG).show(); + } + + public void preparePresentation() { + if (getExternalCacheDir() != null) { + String tempPath = getExternalCacheDir().getPath() + "/" + mTempFile.getName() + ".svg"; + mTempSlideShowFile = new File(tempPath); + if (mTempSlideShowFile.exists() && !isDocumentChanged) { + startPresentation("file://" + tempPath); + } else { + LOKitShell.sendSaveCopyAsEvent(tempPath, "svg"); + } + } + } + + public void startPresentation(String tempPath) { + Intent intent = new Intent(this, PresentationActivity.class); + intent.setData(Uri.parse(tempPath)); + startActivity(intent); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_CODE_SAVEAS && resultCode == RESULT_OK) { + final Uri fileUri = data.getData(); + saveDocumentAs(fileUri); + } else if (requestCode == REQUEST_CODE_EXPORT_TO_PDF && resultCode == RESULT_OK) { + final Uri fileUri = data.getData(); + exportToPDF(fileUri); + } else { + mFormattingController.handleActivityResult(requestCode, resultCode, data); + hideBottomToolbar(); + } + } +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/LocaleHelper.java b/android/source/src/java/org/libreoffice/LocaleHelper.java new file mode 100644 index 0000000000..a87c63f099 --- /dev/null +++ b/android/source/src/java/org/libreoffice/LocaleHelper.java @@ -0,0 +1,57 @@ +package org.libreoffice; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.os.Build; +import android.preference.PreferenceManager; + +import java.util.Locale; + +public class LocaleHelper { + + private static final String SELECTED_LANG = "org.libreoffice.selected.lang"; + // value for language that indicates that system's default language should be used + public static final String SYSTEM_DEFAULT_LANGUAGE = "SYSTEM_DEFAULT_LANGUAGE"; + + public static Context onAttach(Context context){ + String lang = getPersistedData(context, Locale.getDefault().getLanguage()); + return setLocale(context, lang); + } + + public static Context setLocale(Context context, String lang) { + persist(context, lang); + return updateResources(context, lang); + } + + @SuppressWarnings("deprecation") + private static Context updateResources(Context context, String lang) { + Locale locale; + if (lang.equals(SYSTEM_DEFAULT_LANGUAGE)) { + locale = Locale.getDefault(); + } else { + locale = new Locale(lang); + } + Locale.setDefault(locale); + + Resources res = context.getResources(); + Configuration cfg = res.getConfiguration(); + cfg.locale = locale; + cfg.setLayoutDirection(locale); + + res.updateConfiguration(cfg, res.getDisplayMetrics()); + return context; + } + + private static void persist(Context context, String lang) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + preferences.edit().putString(SELECTED_LANG, lang); + preferences.edit().apply(); + } + + private static String getPersistedData(Context context, String lang) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + return preferences.getString(SELECTED_LANG, lang); + } +} diff --git a/android/source/src/java/org/libreoffice/PDFDocumentAdapter.java b/android/source/src/java/org/libreoffice/PDFDocumentAdapter.java new file mode 100644 index 0000000000..2ce167ce3a --- /dev/null +++ b/android/source/src/java/org/libreoffice/PDFDocumentAdapter.java @@ -0,0 +1,86 @@ +package org.libreoffice; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.ParcelFileDescriptor; +import android.print.PageRange; +import android.print.PrintAttributes; +import android.print.PrintDocumentAdapter; +import android.print.PrintDocumentInfo; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +@TargetApi(19) +public class PDFDocumentAdapter extends PrintDocumentAdapter{ + Context mContext; + String pdfFile; + + public PDFDocumentAdapter(Context mContext, String pdfFile) { + this.mContext = mContext; + this.pdfFile = pdfFile; + } + + @Override + public void onLayout(PrintAttributes oldAttributes, PrintAttributes newAttributes, CancellationSignal cancellationSignal, LayoutResultCallback callback, Bundle extras) { + if (cancellationSignal.isCanceled()) { + callback.onLayoutCancelled(); + } + else { + File f = new File(pdfFile); + PrintDocumentInfo.Builder builder= + new PrintDocumentInfo.Builder(f.getName()); + builder.setContentType(PrintDocumentInfo.CONTENT_TYPE_DOCUMENT) + .setPageCount(PrintDocumentInfo.PAGE_COUNT_UNKNOWN) + .build(); + callback.onLayoutFinished(builder.build(), + !newAttributes.equals(oldAttributes)); + } + } + + @Override + public void onWrite(PageRange[] pages, ParcelFileDescriptor destination, CancellationSignal cancellationSignal, WriteResultCallback callback) { + InputStream in=null; + OutputStream out=null; + try { + File file = new File(pdfFile); + in = new FileInputStream(file); + out=new FileOutputStream(destination.getFileDescriptor()); + + byte[] buf=new byte[in.available()]; + int size; + + while ((size=in.read(buf)) >= 0 + && !cancellationSignal.isCanceled()) { + out.write(buf, 0, size); + } + + if (cancellationSignal.isCanceled()) { + callback.onWriteCancelled(); + } + else { + callback.onWriteFinished(new PageRange[] { PageRange.ALL_PAGES }); + } + } + catch (Exception e) { + callback.onWriteFailed(e.getMessage()); + e.printStackTrace(); + } + finally { + try { + in.close(); + out.close(); + } + catch (IOException e) { + e.printStackTrace(); + } + } + + } +}
\ No newline at end of file diff --git a/android/source/src/java/org/libreoffice/PasswordDialogFragment.java b/android/source/src/java/org/libreoffice/PasswordDialogFragment.java new file mode 100644 index 0000000000..08bc7f5968 --- /dev/null +++ b/android/source/src/java/org/libreoffice/PasswordDialogFragment.java @@ -0,0 +1,56 @@ +package org.libreoffice; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; + +public class PasswordDialogFragment extends DialogFragment { + + private LibreOfficeMainActivity mContext; + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + LayoutInflater inflater = getActivity().getLayoutInflater(); + + final View dialogView = inflater.inflate(R.layout.password_dialog, null); + + builder.setView(dialogView) + .setPositiveButton(R.string.action_pwd_dialog_OK, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + String pwd = ((EditText)dialogView.findViewById(R.id.password)).getText().toString(); + mContext.savePassword(pwd); + } + }) + .setNegativeButton(R.string.action_pwd_dialog_cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + mContext.savePassword(null); + } + }).setTitle(R.string.action_pwd_dialog_title); + + return builder.create(); + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + getDialog().setCanceledOnTouchOutside(false); + setCancelable(false); + return super.onCreateView(inflater, container, savedInstanceState); + } + + public void setLOMainActivity(LibreOfficeMainActivity context) { + mContext = context; + } +} diff --git a/android/source/src/java/org/libreoffice/PresentationActivity.java b/android/source/src/java/org/libreoffice/PresentationActivity.java new file mode 100644 index 0000000000..ede7c0c401 --- /dev/null +++ b/android/source/src/java/org/libreoffice/PresentationActivity.java @@ -0,0 +1,177 @@ +package org.libreoffice; + +import android.content.Intent; +import android.os.Bundle; +import androidx.annotation.Nullable; +import androidx.core.view.GestureDetectorCompat; +import androidx.appcompat.app.AppCompatActivity; +import android.view.GestureDetector; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.webkit.WebView; +import android.widget.Button; +import android.widget.ImageButton; + +public class PresentationActivity extends AppCompatActivity { + + private static final String LOGTAG = PresentationActivity.class.getSimpleName(); + WebView mWebView; + View mGestureView; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + View decorView = getWindow().getDecorView(); + // Hide the status bar. + int uiOptions = View.SYSTEM_UI_FLAG_FULLSCREEN; + decorView.setSystemUiVisibility(uiOptions); + + setContentView(R.layout.presentation_mode); + + // get intent and url + Intent intent = getIntent(); + String filePath = intent.getDataString(); + + // set up WebView + mWebView = findViewById(R.id.presentation_view); + mWebView.getSettings().setJavaScriptEnabled(true); + mWebView.setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + return true; + } + }); + + // set up buttons within presentation_gesture_view + ImageButton prevButton = findViewById(R.id.slide_show_nav_prev); + ImageButton nextButton = findViewById(R.id.slide_show_nav_next); + Button backButton = findViewById(R.id.slide_show_nav_back); + + prevButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + pageLeft(); + } + }); + nextButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + pageRight(); + } + }); + backButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onBackPressed(); + } + }); + + // set up presentation_gesture_view + mGestureView = findViewById(R.id.presentation_gesture_view); + final GestureDetectorCompat gestureDetector = + new GestureDetectorCompat(this, new PresentationGestureViewListener()); + mGestureView.setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + return gestureDetector.onTouchEvent(event); + } + }); + + // load url + mWebView.loadUrl(filePath); + } + + private class PresentationGestureViewListener extends GestureDetector.SimpleOnGestureListener { + private static final int SWIPE_VELOCITY_THRESHOLD = 100; + private static final int SCROLL_THRESHOLD = 10; // if scrollCounter is larger than this, a page switch is triggered + private int scrollCounter = 0; // a counter for measuring scrolling distance + + @Override + public boolean onDown(MotionEvent e) { + return true; + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + boolean result = false; + try { + float diffY = e2.getY() - e1.getY(); + float diffX = e2.getX() - e1.getX(); + if (Math.abs(diffX) > Math.abs(diffY)) { + if (Math.abs(velocityX) > SWIPE_VELOCITY_THRESHOLD) { + if (diffX > 0) { + pageRight(); + } else { + pageLeft(); + } + result = true; + } + } + } catch (Exception exception) { + exception.printStackTrace(); + } + return result; + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + boolean result = false; + try { + float diffY = e2.getY() - e1.getY(); + float diffX = e2.getX() - e1.getX(); + if (Math.abs(diffX) < Math.abs(diffY)) { + if (distanceY > 0) { + scrollCounter++; + if (scrollCounter >= SCROLL_THRESHOLD) { + pageRight(); + scrollCounter = 0; + } + } else { + scrollCounter--; + if (scrollCounter <= -SCROLL_THRESHOLD) { + pageLeft(); + scrollCounter = 0; + } + } + result = true; + } + } catch (Exception exception) { + exception.printStackTrace(); + } + return result; + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + if (e.getX() < mGestureView.getWidth()/3) { + pageLeft(); + } else if (e.getX() < mGestureView.getWidth()*2/3) { + hideControlButtons(); + } else { + pageRight(); + } + return true; + } + } + + private void hideControlButtons() { + View[] views= {findViewById(R.id.slide_show_nav_prev),findViewById(R.id.slide_show_nav_next),findViewById(R.id.slide_show_nav_back)} ; + for (View view : views) { + if (view.getVisibility() == View.GONE) { + view.setVisibility(View.VISIBLE); + } else if (view.getVisibility() == View.VISIBLE) { + view.setVisibility(View.GONE); + } + } + } + + private void pageLeft() { + mWebView.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT)); + } + + private void pageRight() { + mWebView.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT)); + } +} diff --git a/android/source/src/java/org/libreoffice/SearchController.java b/android/source/src/java/org/libreoffice/SearchController.java new file mode 100644 index 0000000000..6095e1fd2a --- /dev/null +++ b/android/source/src/java/org/libreoffice/SearchController.java @@ -0,0 +1,82 @@ +package org.libreoffice; + +import android.view.KeyEvent; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.TextView; + +import org.json.JSONException; +import org.json.JSONObject; + +public class SearchController implements View.OnClickListener { + private final LibreOfficeMainActivity mActivity; + + private enum SearchDirection { + UP, DOWN + } + + SearchController(LibreOfficeMainActivity activity) { + mActivity = activity; + + activity.findViewById(R.id.button_search_up).setOnClickListener(this); + activity.findViewById(R.id.button_search_down).setOnClickListener(this); + + ((EditText) mActivity.findViewById(R.id.search_string)).setOnEditorActionListener(new TextView.OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_SEARCH) { + // search downward when the "search button" on keyboard is pressed, + SearchDirection direction = SearchDirection.DOWN; + String searchText = ((EditText) mActivity.findViewById(R.id.search_string)).getText().toString(); + float x = mActivity.getCurrentCursorPosition().centerX(); + float y = mActivity.getCurrentCursorPosition().centerY(); + search(searchText, direction, x, y); + return true; + } + return false; + } + }); + } + + private void search(String searchString, SearchDirection direction, float x, float y) { + try { + JSONObject rootJson = new JSONObject(); + + addProperty(rootJson, "SearchItem.SearchString", "string", searchString); + addProperty(rootJson, "SearchItem.Backward", "boolean", direction == SearchDirection.UP ? "true" : "false"); + addProperty(rootJson, "SearchItem.SearchStartPointX", "long", String.valueOf((long) UnitConverter.pixelToTwip(x, LOKitShell.getDpi(mActivity)))); + addProperty(rootJson, "SearchItem.SearchStartPointY", "long", String.valueOf((long) UnitConverter.pixelToTwip(y, LOKitShell.getDpi(mActivity)))); + addProperty(rootJson, "SearchItem.Command", "long", String.valueOf(0)); // search all == 1 + + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:ExecuteSearch", rootJson.toString())); + + } catch (JSONException e) { + e.printStackTrace(); + } + } + + public static void addProperty(JSONObject json, String parentValue, String type, String value) throws JSONException { + JSONObject child = new JSONObject(); + child.put("type", type); + child.put("value", value); + json.put(parentValue, child); + } + + @Override + public void onClick(View view) { + ImageButton button = (ImageButton) view; + + SearchDirection direction = SearchDirection.DOWN; + if (button.getId() == R.id.button_search_up) { + direction = SearchDirection.UP; + } + + String searchText = ((EditText) mActivity.findViewById(R.id.search_string)).getText().toString(); + + float x = mActivity.getCurrentCursorPosition().centerX(); + float y = mActivity.getCurrentCursorPosition().centerY(); + search(searchText, direction, x, y); + } +} diff --git a/android/source/src/java/org/libreoffice/SettingsActivity.java b/android/source/src/java/org/libreoffice/SettingsActivity.java new file mode 100644 index 0000000000..5623abc2e5 --- /dev/null +++ b/android/source/src/java/org/libreoffice/SettingsActivity.java @@ -0,0 +1,63 @@ +/* -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.libreoffice; + +import android.app.Activity; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.preference.PreferenceFragment; +import android.preference.PreferenceGroup; + +public class SettingsActivity extends Activity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Display the fragment as the main content. + getFragmentManager().beginTransaction() + .replace(android.R.id.content, new SettingsFragment()) + .commit(); + } + + public static class SettingsFragment extends PreferenceFragment implements SharedPreferences.OnSharedPreferenceChangeListener { + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + addPreferencesFromResource(R.xml.libreoffice_preferences); + if(!BuildConfig.ALLOW_EDITING) { + PreferenceGroup generalGroup = (PreferenceGroup) findPreference("PREF_CATEGORY_GENERAL"); + generalGroup.removePreference(generalGroup.findPreference("ENABLE_EXPERIMENTAL")); + generalGroup.removePreference(generalGroup.findPreference("ENABLE_DEVELOPER")); + } + } + + @Override + public void onResume() { + super.onResume(); + getPreferenceScreen().getSharedPreferences() + .registerOnSharedPreferenceChangeListener(this); + } + + @Override + public void onPause() { + super.onPause(); + getPreferenceScreen().getSharedPreferences() + .unregisterOnSharedPreferenceChangeListener(this); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + SettingsListenerModel.getInstance().changePreferenceState(sharedPreferences, key); + if(key.equals("DISPLAY_LANGUAGE")){ + getActivity().recreate(); + } + } + } +} +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/SettingsListenerModel.java b/android/source/src/java/org/libreoffice/SettingsListenerModel.java new file mode 100644 index 0000000000..1b5a909e1e --- /dev/null +++ b/android/source/src/java/org/libreoffice/SettingsListenerModel.java @@ -0,0 +1,56 @@ +/* + * + * * This file is part of the LibreOffice project. + * * This Source Code Form is subject to the terms of the Mozilla Public + * * License, v. 2.0. If a copy of the MPL was not distributed with this + * * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + */ +package org.libreoffice; + +import android.content.SharedPreferences; + +public class SettingsListenerModel { + + public interface OnSettingsPreferenceChangedListener { + void settingsPreferenceChanged(SharedPreferences sharedPreferences, String key); + } + + private static SettingsListenerModel mInstance; + private OnSettingsPreferenceChangedListener mListener; + private SharedPreferences sharedPreferences; + private String key; + + private SettingsListenerModel() {} + + public static SettingsListenerModel getInstance() { + if(mInstance == null) { + mInstance = new SettingsListenerModel(); + } + return mInstance; + } + + public void setListener(OnSettingsPreferenceChangedListener listener) { + mListener = listener; + } + + public void changePreferenceState(SharedPreferences sharedPreferences, String key) { + if(mListener != null) { + this.sharedPreferences = sharedPreferences; + this.key = key; + notifyPreferenceChange(sharedPreferences, key); + } + } + + public SharedPreferences getSharedPreferences() { + return sharedPreferences; + } + + public String getKey(){ + return key; + } + + private void notifyPreferenceChange(SharedPreferences preferences, String key) { + mListener.settingsPreferenceChanged(preferences, key); + } +} diff --git a/android/source/src/java/org/libreoffice/ThumbnailCreator.java b/android/source/src/java/org/libreoffice/ThumbnailCreator.java new file mode 100644 index 0000000000..c0c097747c --- /dev/null +++ b/android/source/src/java/org/libreoffice/ThumbnailCreator.java @@ -0,0 +1,121 @@ +/* -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.libreoffice; + +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.widget.ImageView; + +import java.lang.ref.WeakReference; + +/** + * Create thumbnails for the parts of the document. + */ +public class ThumbnailCreator { + private static final String LOG_TAG = ThumbnailCreator.class.getSimpleName(); + private static final int THUMBNAIL_SIZE = 256; + + private static boolean needsThumbnailCreation(int partNumber, ImageView imageView) { + ThumbnailCreationTask thumbnailCreationTask = currentThumbnailCreationTask(imageView); + + if (thumbnailCreationTask == null) { + return true; + } + + if (thumbnailCreationTask.partNumber != partNumber) { + thumbnailCreationTask.cancel(); + return true; + } else { + return false; + } + } + + private static ThumbnailCreationTask currentThumbnailCreationTask(ImageView imageView) { + if (imageView == null) { + return null; + } + Drawable drawable = imageView.getDrawable(); + if (drawable instanceof ThumbnailDrawable) { + return ((ThumbnailDrawable) drawable).thumbnailCreationTask.get(); + } else { + return null; + } + } + + public void createThumbnail(int partNumber, ImageView imageView) { + if (needsThumbnailCreation(partNumber, imageView)) { + ThumbnailCreationTask task = new ThumbnailCreationTask(imageView, partNumber); + ThumbnailDrawable thumbnailDrawable = new ThumbnailDrawable(task); + imageView.setImageDrawable(thumbnailDrawable); + imageView.setMinimumHeight(THUMBNAIL_SIZE); + LOKitShell.sendThumbnailEvent(task); + } + } + + static class ThumbnailDrawable extends ColorDrawable { + public final WeakReference<ThumbnailCreationTask> thumbnailCreationTask; + + public ThumbnailDrawable(ThumbnailCreationTask thumbnailCreationTask) { + super(Color.WHITE); + this.thumbnailCreationTask = new WeakReference<ThumbnailCreationTask>(thumbnailCreationTask); + } + } + + class ThumbnailCreationTask{ + private final WeakReference<ImageView> imageViewReference; + private final int partNumber; + private boolean cancelled = false; + + public ThumbnailCreationTask(ImageView imageView, int partNumber) { + imageViewReference = new WeakReference<ImageView>(imageView); + this.partNumber = partNumber; + } + + public void cancel() { + cancelled = true; + } + + public Bitmap getThumbnail(TileProvider tileProvider) { + int currentPart = tileProvider.getCurrentPartNumber(); + tileProvider.changePart(partNumber); + final Bitmap bitmap = tileProvider.thumbnail(THUMBNAIL_SIZE); + tileProvider.changePart(currentPart); + return bitmap; + } + + private void changeBitmap(Bitmap bitmap) { + if (cancelled) { + bitmap = null; + } + + if (imageViewReference == null) { + return; + } + ImageView imageView = imageViewReference.get(); + ThumbnailCreationTask thumbnailCreationTask = currentThumbnailCreationTask(imageView); + if (this == thumbnailCreationTask) { + imageView.setImageBitmap(bitmap); + } + } + + public void applyBitmap(final Bitmap bitmap) { + // run on UI thread + LibreOfficeApplication.getMainHandler().post(new Runnable() { + @Override + public void run() { + changeBitmap(bitmap); + } + }); + } + } +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/TileIdentifier.java b/android/source/src/java/org/libreoffice/TileIdentifier.java new file mode 100644 index 0000000000..9f6fc5605a --- /dev/null +++ b/android/source/src/java/org/libreoffice/TileIdentifier.java @@ -0,0 +1,92 @@ +/* -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.libreoffice; + +import android.graphics.Rect; +import android.graphics.RectF; + +import org.mozilla.gecko.gfx.IntSize; + +/** + * Identifies the tile by its position (x and y coordinate on the document), zoom and tile size (currently static) + */ +public class TileIdentifier { + public final int x; + public final int y; + public final float zoom; + public final IntSize size; + + public TileIdentifier(int x, int y, float zoom, IntSize size) { + this.x = x; + this.y = y; + this.zoom = zoom; + this.size = size; + } + + /** + * Returns a rectangle of the tiles position in scaled coordinates. + */ + public RectF getRectF() { + return new RectF(x, y, x + size.width, y + size.height); + } + + /** + * Returns a rectangle of the tiles position in non-scaled coordinates (coordinates as the zoom would be 1). + */ + public RectF getCSSRectF() { + float cssX = x / zoom; + float cssY = y / zoom; + float cssSizeW = size.width / zoom; + float cssSizeH = size.height / zoom; + return new RectF(cssX, cssY, cssX + cssSizeW, cssY + cssSizeH); + } + + /** + * Returns an integer rectangle of the tiles position in non-scaled and rounded coordinates (coordinates as the zoom would be 1). + */ + public Rect getCSSRect() { + float cssX = x / zoom; + float cssY = y / zoom; + float sizeW = size.width / zoom; + float sizeH = size.height / zoom; + return new Rect( + (int) cssX, (int) cssY, + (int) (cssX + sizeW), + (int) (cssY + sizeH) ); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + TileIdentifier that = (TileIdentifier) o; + + if (x != that.x) return false; + if (y != that.y) return false; + if (Float.compare(that.zoom, zoom) != 0) return false; + + return true; + } + + @Override + public int hashCode() { + int result = x; + result = 31 * result + y; + result = 31 * result + (zoom != +0.0f ? Float.floatToIntBits(zoom) : 0); + return result; + } + + @Override + public String toString() { + return String.format("TileIdentifier (%d, %d) z=%f s=(%d, %d)", x, y, zoom, size.width, size.height); + } +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/TileProvider.java b/android/source/src/java/org/libreoffice/TileProvider.java new file mode 100644 index 0000000000..c979a9883c --- /dev/null +++ b/android/source/src/java/org/libreoffice/TileProvider.java @@ -0,0 +1,205 @@ +/* -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.libreoffice; + + +import android.graphics.Bitmap; +import android.graphics.PointF; +import android.view.KeyEvent; + +import org.mozilla.gecko.gfx.CairoImage; +import org.mozilla.gecko.gfx.IntSize; + +/** + * Provides the tiles and other document information. + */ +public interface TileProvider { + + /** + * Save the current document under the given path. + * @param takeOwnership Whether to take ownership of the new file, + * i.e. whether the current document is changed to the + * newly saved document (takeOwnership = true), + * as compared to just saving a copy of the current document + * or exporting to a different file format. + * Must be 'false' when using this method for export to e.g. PNG or PDF. + * @return Whether saving was successful. + */ + boolean saveDocumentAs(String filePath, String format, boolean takeOwnership); + + /** + * Saves the current document under the given path, + * using the default file format. + * @param takeOwnership (s. documentation for + * 'saveDocumentAs(String filePath, String format, boolean takeOwnership)') + */ + boolean saveDocumentAs(String filePath, boolean takeOwnership); + + /** + * Returns the page width in pixels. + */ + int getPageWidth(); + + /** + * Returns the page height in pixels. + */ + int getPageHeight(); + + boolean isReady(); + + CairoImage createTile(float x, float y, IntSize tileSize, float zoom); + + /** + * Rerender and overwrite tile's image buffer directly + */ + void rerenderTile(CairoImage image, float x, float y, IntSize tileSize, float zoom); + + /** + * Change the document part to the one specified by the partIndex input parameter. + * + * @param partIndex - part index to change to + */ + void changePart(int partIndex); + + /** + * Get the current document part number. + * + * @return + */ + int getCurrentPartNumber(); + + /** + * Get the total number of parts. + */ + int getPartsCount(); + + Bitmap thumbnail(int size); + + /** + * Closes the document. + */ + void close(); + + /** + * Returns true if the current open document is a drawing. + */ + boolean isDrawing(); + + /** + * Returns true if the current open document is a text document. + */ + boolean isTextDocument(); + + /** + * Returns true if the current open document is a spreadsheet. + */ + boolean isSpreadsheet(); + + /** + * Returns true if the current open document is a presentation + */ + boolean isPresentation(); + + /** + * Trigger a key event. + * + * @param keyEvent - contains information about key event + */ + void sendKeyEvent(KeyEvent keyEvent); + + /** + * Trigger a mouse button down event. + * + * @param documentCoordinate - coordinate relative to the document where the mouse button should be triggered + * @param numberOfClicks - number of clicks (1 - single click, 2 - double click) + */ + void mouseButtonDown(PointF documentCoordinate, int numberOfClicks, float zoomFactor); + + + /** + * Trigger a swipe left event. + */ + void onSwipeLeft(); + + /** + * Trigger a swipe left event. + */ + void onSwipeRight(); + + /** + * Trigger a mouse button up event. + * + * @param documentCoordinate - coordinate relative to the document where the mouse button should be triggered + * @param numberOfClicks - number of clicks (1 - single click, 2 - double click) + */ + void mouseButtonUp(PointF documentCoordinate, int numberOfClicks, float zoomFactor); + + /** + * Post a UNO command to LOK. + * + * @param command - the .uno: command, like ".uno:Bold" + */ + void postUnoCommand(String command, String arguments); + + /** + * This is the actual reference to the function in LOK, used for getting notified when uno:save event finishes + * @param command + * @param arguments + * @param notifyWhenFinished + */ + void postUnoCommand(String command, String arguments, boolean notifyWhenFinished); + + /** + * Send text selection start coordinate. + * @param documentCoordinate + */ + void setTextSelectionStart(PointF documentCoordinate); + + /** + * Send text selection end coordinate. + * @param documentCoordinate + */ + void setTextSelectionEnd(PointF documentCoordinate); + + /** + * get selected text + * @param mimeType + */ + String getTextSelection(String mimeType); + + /** + * copy + * @param mimeType + * @param data + * @return + */ + boolean paste(String mimeType, String data); + /** + * Send text selection reset coordinate. + * @param documentCoordinate + */ + void setTextSelectionReset(PointF documentCoordinate); + + /** + * Send a request to change start the change of graphic selection. + */ + void setGraphicSelectionStart(PointF documentCoordinate); + + /** + * Send a request to change end the change of graphic selection... + */ + void setGraphicSelectionEnd(PointF documentCoordinate); + + /** + * Set the new page size of the document when changed + */ + void setDocumentSize(int pageWidth, int pageHeight); +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/TileProviderFactory.java b/android/source/src/java/org/libreoffice/TileProviderFactory.java new file mode 100644 index 0000000000..3219ce2b4a --- /dev/null +++ b/android/source/src/java/org/libreoffice/TileProviderFactory.java @@ -0,0 +1,31 @@ +/* -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.libreoffice; + + +import org.libreoffice.kit.LibreOfficeKit; + +/** + * Create a desired instance of TileProvider. + */ +public class TileProviderFactory { + + private TileProviderFactory() { + } + + public static void initialize() { + LibreOfficeKit.initializeLibrary(); + } + + public static TileProvider create(LibreOfficeMainActivity context, InvalidationHandler invalidationHandler, String filename) { + return new LOKitTileProvider(context, invalidationHandler, filename); + } +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/ToolbarController.java b/android/source/src/java/org/libreoffice/ToolbarController.java new file mode 100644 index 0000000000..603d225816 --- /dev/null +++ b/android/source/src/java/org/libreoffice/ToolbarController.java @@ -0,0 +1,276 @@ +/* -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.libreoffice; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import androidx.appcompat.widget.Toolbar; +import android.util.Log; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.widget.Toast; + +/** + * Controls the changes to the toolbar. + */ +public class ToolbarController implements Toolbar.OnMenuItemClickListener { + private static final String LOGTAG = ToolbarController.class.getSimpleName(); + private final Toolbar mToolbarTop; + + private final LibreOfficeMainActivity mContext; + private final Menu mMainMenu; + + private boolean isEditModeOn = false; + private String clipboardText = null; + ClipboardManager clipboardManager; + ClipData clipData; + + public ToolbarController(LibreOfficeMainActivity context, Toolbar toolbarTop) { + mToolbarTop = toolbarTop; + mContext = context; + + mToolbarTop.inflateMenu(R.menu.main); + mToolbarTop.setOnMenuItemClickListener(this); + switchToViewMode(); + + mMainMenu = mToolbarTop.getMenu(); + clipboardManager = (ClipboardManager)mContext.getSystemService(Context.CLIPBOARD_SERVICE); + } + + private void enableMenuItem(final int menuItemId, final boolean enabled) { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + MenuItem menuItem = mMainMenu.findItem(menuItemId); + if (menuItem != null) { + menuItem.setEnabled(enabled); + } else { + Log.e(LOGTAG, "MenuItem not found."); + } + } + }); + } + + public void setEditModeOn(boolean enabled) { + isEditModeOn = enabled; + } + + public boolean getEditModeStatus() { + return isEditModeOn; + } + + /** + * Change the toolbar to edit mode. + */ + void switchToEditMode() { + if (!LOKitShell.isEditingEnabled()) + return; + + setEditModeOn(true); + // Ensure the change is done on UI thread + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + mMainMenu.setGroupVisible(R.id.group_edit_actions, true); + if (!LibreOfficeMainActivity.isDeveloperMode() && mMainMenu.findItem(R.id.action_UNO_commands) != null) { + mMainMenu.findItem(R.id.action_UNO_commands).setVisible(false); + } else { + mMainMenu.findItem(R.id.action_UNO_commands).setVisible(true); + } + if(mContext.getTileProvider() != null && mContext.getTileProvider().isSpreadsheet()){ + mMainMenu.setGroupVisible(R.id.group_spreadsheet_options, true); + } else if(mContext.getTileProvider() != null && mContext.getTileProvider().isPresentation()){ + mMainMenu.setGroupVisible(R.id.group_presentation_options, true); + } + mToolbarTop.setNavigationIcon(R.drawable.ic_check); + mToolbarTop.setLogo(null); + } + }); + } + + /** + * Show clipboard Actions on the toolbar + * */ + void showClipboardActions(final String value){ + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + if(value != null){ + mMainMenu.setGroupVisible(R.id.group_edit_actions, false); + mMainMenu.setGroupVisible(R.id.group_edit_clipboard, true); + if(getEditModeStatus()){ + showHideClipboardCutAndCopy(true); + } else { + mMainMenu.findItem(R.id.action_cut).setVisible(false); + mMainMenu.findItem(R.id.action_paste).setVisible(false); + } + clipboardText = value; + } + } + }); + } + + void hideClipboardActions(){ + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + mMainMenu.setGroupVisible(R.id.group_edit_actions, getEditModeStatus()); + mMainMenu.setGroupVisible(R.id.group_edit_clipboard, false); + } + }); + } + + void showHideClipboardCutAndCopy(final boolean option){ + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + mMainMenu.findItem(R.id.action_copy).setVisible(option); + mMainMenu.findItem(R.id.action_cut).setVisible(option); + } + }); + } + + /** + * Change the toolbar to view mode. + */ + void switchToViewMode() { + // Ensure the change is done on UI thread + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + mMainMenu.setGroupVisible(R.id.group_edit_actions, false); + mToolbarTop.setNavigationIcon(R.mipmap.ic_launcher); + mToolbarTop.setLogo(null); + setEditModeOn(false); + mContext.hideBottomToolbar(); + mContext.hideSoftKeyboard(); + if(mContext.getTileProvider() != null && mContext.getTileProvider().isSpreadsheet()){ + mMainMenu.setGroupVisible(R.id.group_spreadsheet_options, false); + } else if(mContext.getTileProvider() != null && mContext.getTileProvider().isPresentation()){ + mMainMenu.setGroupVisible(R.id.group_presentation_options, false); + } + } + }); + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + final int itemId = item.getItemId(); + if (itemId == R.id.action_keyboard) { + mContext.showSoftKeyboard(); + } else if (itemId == R.id.action_format) { + mContext.showFormattingToolbar(); + } else if (itemId == R.id.action_about) { + mContext.showAbout(); + return true; + } else if (itemId == R.id.action_save) { + mContext.getTileProvider().saveDocument(); + return true; + } else if (itemId == R.id.action_save_as) { + mContext.saveDocumentAs(); + return true; + } else if (itemId == R.id.action_parts) { + mContext.openDrawer(); + return true; + } else if (itemId == R.id.action_exportToPDF) { + mContext.exportToPDF(); + return true; + } else if (itemId == R.id.action_print) { + mContext.getTileProvider().printDocument(); + return true; + } else if (itemId == R.id.action_settings) { + mContext.showSettings(); + return true; + } else if (itemId == R.id.action_search) { + mContext.showSearchToolbar(); + return true; + } else if (itemId == R.id.action_undo) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:Undo")); + return true; + } else if (itemId == R.id.action_redo) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:Redo")); + return true; + } else if (itemId == R.id.action_presentation) { + mContext.preparePresentation(); + return true; + } else if (itemId == R.id.action_add_slide || itemId == R.id.action_add_worksheet) { + mContext.addPart(); + return true; + } else if (itemId == R.id.action_rename_worksheet || itemId == R.id.action_rename_slide) { + mContext.renamePart(); + return true; + } else if (itemId == R.id.action_delete_worksheet || itemId == R.id.action_delete_slide) { + mContext.deletePart(); + return true; + } else if (itemId == R.id.action_back) { + hideClipboardActions(); + return true; + } else if (itemId == R.id.action_copy) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:Copy")); + clipData = ClipData.newPlainText("clipboard data", clipboardText); + clipboardManager.setPrimaryClip(clipData); + Toast.makeText(mContext, mContext.getResources().getString(R.string.action_text_copied), Toast.LENGTH_SHORT).show(); + return true; + } else if (itemId == R.id.action_paste) { + clipData = clipboardManager.getPrimaryClip(); + ClipData.Item clipItem = clipData.getItemAt(0); + mContext.setDocumentChanged(true); + return mContext.getTileProvider().paste("text/plain;charset=utf-16", clipItem.getText().toString()); + } else if (itemId == R.id.action_cut) { + clipData = ClipData.newPlainText("clipboard data", clipboardText); + clipboardManager.setPrimaryClip(clipData); + LOKitShell.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)); + mContext.setDocumentChanged(true); + return true; + } else if (itemId == R.id.action_UNO_commands) { + mContext.showUNOCommandsToolbar(); + return true; + } + return false; + } + + void setupToolbars() { + if (LibreOfficeMainActivity.isExperimentalMode()) { + boolean enableSaveEntry = !LibreOfficeMainActivity.isReadOnlyMode() && mContext.hasLocationForSave(); + enableMenuItem(R.id.action_save, enableSaveEntry); + if (LibreOfficeMainActivity.isReadOnlyMode()) { + // show message in case experimental mode is enabled (i.e. editing is supported in general), + // but current document is readonly + Toast.makeText(mContext, mContext.getString(R.string.readonly_file), Toast.LENGTH_LONG).show(); + } + } else { + hideItem(R.id.action_save); + } + mMainMenu.findItem(R.id.action_parts).setVisible(mContext.isDrawerEnabled()); + } + + public void showItem(final int item){ + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + mMainMenu.findItem(item).setVisible(true); + + } + }); + } + + public void hideItem(final int item){ + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + mMainMenu.findItem(item).setVisible(false); + + } + }); + } + +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/UNOCommandsController.java b/android/source/src/java/org/libreoffice/UNOCommandsController.java new file mode 100644 index 0000000000..cba67732cc --- /dev/null +++ b/android/source/src/java/org/libreoffice/UNOCommandsController.java @@ -0,0 +1,85 @@ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.libreoffice; + +import android.content.DialogInterface; +import androidx.appcompat.app.AlertDialog; +import android.text.method.ScrollingMovementMethod; +import android.view.View; +import android.widget.EditText; +import android.widget.Scroller; +import android.widget.TextView; + +import org.json.JSONException; +import org.json.JSONObject; + +import static org.libreoffice.SearchController.addProperty; + +class UNOCommandsController implements View.OnClickListener { + private final LibreOfficeMainActivity mActivity; + private JSONObject mRootJSON = new JSONObject(); + + + UNOCommandsController(LibreOfficeMainActivity activity) { + mActivity = activity; + + activity.findViewById(R.id.button_send_UNO_commands).setOnClickListener(this); + activity.findViewById(R.id.button_send_UNO_commands_clear).setOnClickListener(this); + activity.findViewById(R.id.button_send_UNO_commands_show).setOnClickListener(this); + activity.findViewById(R.id.button_add_property).setOnClickListener(this); + } + + @Override + public void onClick(View view) { + if (view.getId() == R.id.button_send_UNO_commands) { + String cmdText = ((EditText) mActivity.findViewById(R.id.UNO_commands_string)).getText().toString(); + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:" + cmdText, mRootJSON.toString())); + } else if (view.getId() == R.id.button_add_property) { + String parentValue = ((EditText) mActivity.findViewById(R.id.UNO_commands_string_parent_value)).getText().toString(); + String type = ((EditText) mActivity.findViewById(R.id.UNO_commands_string_type)).getText().toString(); + String value = ((EditText) mActivity.findViewById(R.id.UNO_commands_string_value)).getText().toString(); + try { + addProperty(mRootJSON, parentValue, type, value); + } catch (JSONException e) { + e.printStackTrace(); + } + showCommandDialog(); + } else if (view.getId() == R.id.button_send_UNO_commands_clear) { + mRootJSON = new JSONObject(); + ((EditText) mActivity.findViewById(R.id.UNO_commands_string_parent_value)).setText(""); + ((EditText) mActivity.findViewById(R.id.UNO_commands_string_type)).setText(""); + ((EditText) mActivity.findViewById(R.id.UNO_commands_string_value)).setText(""); + showCommandDialog(); + } else if (view.getId() == R.id.button_send_UNO_commands_show) { + showCommandDialog(); + } + } + + private void showCommandDialog() { + try { + AlertDialog dialog = new AlertDialog.Builder(mActivity) + .setTitle(R.string.current_uno_command) + .setMessage(mRootJSON.toString(2)) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }) + .setIcon(android.R.drawable.ic_dialog_info) + .show(); + TextView textView = dialog.findViewById(android.R.id.message); + if (textView != null) { + textView.setScroller(new Scroller(mActivity)); + textView.setVerticalScrollBarEnabled(true); + textView.setMovementMethod(new ScrollingMovementMethod()); + } + } catch (JSONException e) { + e.printStackTrace(); + } + } +} diff --git a/android/source/src/java/org/libreoffice/UnitConverter.java b/android/source/src/java/org/libreoffice/UnitConverter.java new file mode 100644 index 0000000000..f668021b0c --- /dev/null +++ b/android/source/src/java/org/libreoffice/UnitConverter.java @@ -0,0 +1,16 @@ +package org.libreoffice; + + +public class UnitConverter { + public static float twipToPixel(float input, float dpi) { + return input / 1440.0f * dpi; + } + + public static float pixelToTwip(float input, float dpi) { + return (input / dpi) * 1440.0f; + } + + public static float twipsToHMM(float twips) { + return (twips * 127 + 36) / 72; + } +} diff --git a/android/source/src/java/org/libreoffice/canvas/AdjustLengthLine.java b/android/source/src/java/org/libreoffice/canvas/AdjustLengthLine.java new file mode 100644 index 0000000000..a6f8cb17c1 --- /dev/null +++ b/android/source/src/java/org/libreoffice/canvas/AdjustLengthLine.java @@ -0,0 +1,103 @@ +package org.libreoffice.canvas; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PointF; +import android.graphics.RectF; + +import org.json.JSONException; +import org.json.JSONObject; +import org.libreoffice.LOEvent; +import org.libreoffice.LOKitShell; +import org.libreoffice.LibreOfficeMainActivity; +import org.libreoffice.overlay.CalcHeadersView; +import org.mozilla.gecko.gfx.ImmutableViewportMetrics; + +import static org.libreoffice.SearchController.addProperty; +import static org.libreoffice.UnitConverter.pixelToTwip; +import static org.libreoffice.UnitConverter.twipsToHMM; + +public class AdjustLengthLine extends CommonCanvasElement { + + private static final float STROKE_WIDTH = 4f; + private static final float TOUCH_VICINITY_RADIUS = 24f; + + private LibreOfficeMainActivity mContext; + private CalcHeadersView mCalcHeadersView; + private boolean mIsRow; + private PointF mScreenPosition; + private float mWidth; + private float mHeight; + private Paint mPaint; + private PointF mStartScreenPosition; + private int mIndex; + + public AdjustLengthLine(LibreOfficeMainActivity context, CalcHeadersView view, boolean isRow, float width, float height) { + super(); + mContext = context; + mCalcHeadersView = view; + mIsRow = isRow; + mWidth = width; + mHeight = height; + mPaint = new Paint(); + mPaint.setColor(Color.BLACK); + mPaint.setStrokeWidth(STROKE_WIDTH); + } + + @Override + public boolean onHitTest(float x, float y) { + if (mIsRow) { + return mScreenPosition.y - TOUCH_VICINITY_RADIUS < y && + y < mScreenPosition.y + TOUCH_VICINITY_RADIUS; + } else { + return mScreenPosition.x - TOUCH_VICINITY_RADIUS < x && + x < mScreenPosition.x + TOUCH_VICINITY_RADIUS; + } + } + + @Override + public void onDraw(Canvas canvas) { + if (mIsRow) { + canvas.drawLine(0f, mScreenPosition.y, mWidth, mScreenPosition.y, mPaint); + } else { + canvas.drawLine(mScreenPosition.x, 0f, mScreenPosition.x, mHeight, mPaint); + } + } + + public void dragStart(PointF point) { + } + + public void dragging(PointF point) { + mScreenPosition = point; + } + + public void dragEnd(PointF point) { + ImmutableViewportMetrics viewportMetrics = mContext.getLayerClient().getViewportMetrics(); + float zoom = viewportMetrics.zoomFactor; + + PointF documentDistance = new PointF(pixelToTwip((point.x-mStartScreenPosition.x)/zoom, LOKitShell.getDpi(mContext)), + pixelToTwip((point.y-mStartScreenPosition.y)/zoom, LOKitShell.getDpi(mContext))); + + try { + JSONObject rootJson = new JSONObject(); + if (mIsRow) { + addProperty(rootJson, "Row", "long", String.valueOf(mIndex)); + addProperty(rootJson, "RowHeight", "unsigned short", String.valueOf(Math.round(documentDistance.y > 0 ? twipsToHMM(documentDistance.y) : 0))); + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:RowHeight", rootJson.toString())); + } else { + addProperty(rootJson, "Column", "long", String.valueOf(mIndex)); + addProperty(rootJson, "ColumnWidth", "unsigned short", String.valueOf(documentDistance.x > 0 ? twipsToHMM(documentDistance.x) : 0)); + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:ColumnWidth", rootJson.toString())); + } + } catch (JSONException e) { + e.printStackTrace(); + } + } + + public void setScreenRect(RectF position) { + mScreenPosition = new PointF(position.right, position.bottom); + mStartScreenPosition = new PointF(position.left, position.top); + mIndex = 1 + mCalcHeadersView.getIndexFromPointOfTouch(new PointF(position.centerX(), position.centerY())); + } +} diff --git a/android/source/src/java/org/libreoffice/canvas/BitmapHandle.java b/android/source/src/java/org/libreoffice/canvas/BitmapHandle.java new file mode 100644 index 0000000000..51f6f7cf86 --- /dev/null +++ b/android/source/src/java/org/libreoffice/canvas/BitmapHandle.java @@ -0,0 +1,63 @@ +package org.libreoffice.canvas; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import androidx.core.content.ContextCompat; + +/** + * Bitmap handle canvas element is used to show a handle on the screen. + * The handle visual comes from the bitmap, which must be provided in time + * of construction. + */ +public abstract class BitmapHandle extends CommonCanvasElement { + public final RectF mDocumentPosition; + private final Bitmap mBitmap; + final RectF mScreenPosition; + + BitmapHandle(Bitmap bitmap) { + mBitmap = bitmap; + mScreenPosition = new RectF(0, 0, mBitmap.getWidth(), mBitmap.getHeight()); + mDocumentPosition = new RectF(); + } + + /** + * Return a bitmap for a drawable id. + */ + static Bitmap getBitmapForDrawable(Context context, int drawableId) { + Drawable drawable = ContextCompat.getDrawable(context, drawableId); + + return ImageUtils.getBitmapForDrawable(drawable); + } + + /** + * Draw the bitmap handle to the canvas. + * @param canvas - the canvas + */ + @Override + public void onDraw(Canvas canvas) { + canvas.drawBitmap(mBitmap, mScreenPosition.left, mScreenPosition.top, null); + } + + /** + * Test if the bitmap has been hit. + * @param x - x coordinate + * @param y - y coordinate + * @return true if the bitmap has been hit + */ + @Override + public boolean onHitTest(float x, float y) { + return mScreenPosition.contains(x, y); + } + + /** + * Change the position of the handle. + * @param x - x coordinate + * @param y - y coordinate + */ + public void reposition(float x, float y) { + mScreenPosition.offsetTo(x, y); + } +} diff --git a/android/source/src/java/org/libreoffice/canvas/CalcHeaderCell.java b/android/source/src/java/org/libreoffice/canvas/CalcHeaderCell.java new file mode 100644 index 0000000000..a285234bc8 --- /dev/null +++ b/android/source/src/java/org/libreoffice/canvas/CalcHeaderCell.java @@ -0,0 +1,66 @@ +package org.libreoffice.canvas; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Paint.Style; +import android.graphics.Rect; +import android.graphics.RectF; +import android.text.TextPaint; + +public class CalcHeaderCell extends CommonCanvasElement { + private final TextPaint mTextPaint = new TextPaint(); + + private final Paint mFramePaint = new Paint(); + private final Paint mBgPaint = new Paint(); + private final RectF mBounds; + private final Rect mTextBounds = new Rect(); + private final String mText; + + public CalcHeaderCell(float left, float top, float width, float height, String text, boolean selected) { + mBounds = new RectF(left, top, left + width, top + height); + + mFramePaint.setStyle(Style.STROKE); + mFramePaint.setColor(Color.BLACK); + + mBgPaint.setStyle(Style.FILL); + mBgPaint.setColor(Color.GRAY); + // draw background more intensely when cell is selected + if (selected) { + mBgPaint.setAlpha(100); + } else { + mBgPaint.setAlpha(25); + } + + mTextPaint.setColor(Color.BLACK); + mTextPaint.setTextSize(24f); // hard coded for now + mTextPaint.setTextAlign(Paint.Align.CENTER); + mText = text; + + mTextPaint.getTextBounds(mText, 0, mText.length(), mTextBounds); + } + + /** + * Implement hit test here + * + * @param x - x coordinate of the + * @param y - y coordinate of the + */ + @Override + public boolean onHitTest(float x, float y) { + return false; + } + + /** + * Called inside draw if the element is visible. Override this method to + * draw the element on the canvas. + * + * @param canvas - the canvas + */ + @Override + public void onDraw(Canvas canvas) { + canvas.drawRect(mBounds, mBgPaint); + canvas.drawRect(mBounds, mFramePaint); + canvas.drawText(mText, mBounds.centerX(), mBounds.centerY() - mTextBounds.centerY(), mTextPaint); + } +} diff --git a/android/source/src/java/org/libreoffice/canvas/CalcSelectionBox.java b/android/source/src/java/org/libreoffice/canvas/CalcSelectionBox.java new file mode 100644 index 0000000000..af31d708d4 --- /dev/null +++ b/android/source/src/java/org/libreoffice/canvas/CalcSelectionBox.java @@ -0,0 +1,111 @@ +package org.libreoffice.canvas; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PointF; +import android.graphics.RectF; + +import org.libreoffice.LOKitShell; +import org.libreoffice.LibreOfficeMainActivity; +import org.mozilla.gecko.gfx.ImmutableViewportMetrics; + +/** + * CalcSelectionBox is the selection frame for the current highlighted area/cells + * in Calc. + */ + +public class CalcSelectionBox extends CommonCanvasElement { + private static final long MINIMUM_HANDLE_UPDATE_TIME = 50 * 1000000; + private static final float CIRCLE_HANDLE_RADIUS = 8f; + + public RectF mDocumentPosition; + + private LibreOfficeMainActivity mContext; + private RectF mScreenPosition; + private long mLastTime = 0; + private Paint mPaint; + private Paint mCirclePaint; + + public CalcSelectionBox(LibreOfficeMainActivity context) { + mContext = context; + mScreenPosition = new RectF(); + mDocumentPosition = new RectF(); + mPaint = new Paint(); + mPaint.setStyle(Paint.Style.STROKE); + mPaint.setColor(Color.BLACK); + mPaint.setStrokeWidth(2f); + mCirclePaint = new Paint(); + mCirclePaint.setColor(Color.BLACK); + mCirclePaint.setStyle(Paint.Style.FILL); + } + + /** + * Start of a touch and drag action on the box. + */ + public void dragStart(PointF point) {} + + /** + * End of a touch and drag action on the box. + */ + public void dragEnd(PointF point) {} + + /** + * Box has been dragged. + */ + public void dragging(PointF point) { + long currentTime = System.nanoTime(); + if (currentTime - mLastTime > MINIMUM_HANDLE_UPDATE_TIME) { + mLastTime = currentTime; + signalHandleMove(point.x, point.y); + } + } + + /** + * Signal to move the handle to a new position to LO. + */ + private void signalHandleMove(float newX, float newY) { + ImmutableViewportMetrics viewportMetrics = mContext.getLayerClient().getViewportMetrics(); + float zoom = viewportMetrics.zoomFactor; + PointF origin = viewportMetrics.getOrigin(); + + PointF documentPoint = new PointF((newX+origin.x)/zoom , (newY+origin.y)/zoom); + + if (documentPoint.x < mDocumentPosition.left || documentPoint.y < mDocumentPosition.top) { + LOKitShell.sendChangeHandlePositionEvent(SelectionHandle.HandleType.START, documentPoint); + } else if (documentPoint.x > mDocumentPosition.right || documentPoint.y > mDocumentPosition.bottom){ + LOKitShell.sendChangeHandlePositionEvent(SelectionHandle.HandleType.END, documentPoint); + } + } + + @Override + public boolean onHitTest(float x, float y) { + return mScreenPosition.contains(x, y); + } + + @Override + public void onDraw(Canvas canvas) { + canvas.drawRect(mScreenPosition, mPaint); + canvas.drawCircle(mScreenPosition.left, mScreenPosition.top, CIRCLE_HANDLE_RADIUS, mCirclePaint); + canvas.drawCircle(mScreenPosition.right, mScreenPosition.bottom, CIRCLE_HANDLE_RADIUS, mCirclePaint); + } + + public void reposition(RectF rect) { + mScreenPosition = rect; + } + + @Override + public boolean contains(float x, float y) { + // test if in range of the box or the circular handles + boolean inRange = new RectF(mScreenPosition.left - CIRCLE_HANDLE_RADIUS, + mScreenPosition.top - CIRCLE_HANDLE_RADIUS, + mScreenPosition.left + CIRCLE_HANDLE_RADIUS, + mScreenPosition.top + CIRCLE_HANDLE_RADIUS).contains(x, y) + || new RectF(mScreenPosition.right - CIRCLE_HANDLE_RADIUS, + mScreenPosition.bottom - CIRCLE_HANDLE_RADIUS, + mScreenPosition.right + CIRCLE_HANDLE_RADIUS, + mScreenPosition.bottom + CIRCLE_HANDLE_RADIUS).contains(x, y) + || onHitTest(x, y); + return inRange && isVisible(); + } +} diff --git a/android/source/src/java/org/libreoffice/canvas/CanvasElement.java b/android/source/src/java/org/libreoffice/canvas/CanvasElement.java new file mode 100644 index 0000000000..51e8801f6b --- /dev/null +++ b/android/source/src/java/org/libreoffice/canvas/CanvasElement.java @@ -0,0 +1,45 @@ +/* -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package org.libreoffice.canvas; + +import android.graphics.Canvas; + +/** + * Canvas element is an element (or part) that is drawn canvas and can + * potentially be interacted with. + */ +public interface CanvasElement { + /** + * Called when the element needs to be draw no the canvas. This method + * should call onDraw when conditions to draw are satisfied. + * + * @param canvas - the canvas + */ + void draw(Canvas canvas); + + /** + * Hit test - returns true if the object has been hit + * @param x - x coordinate of the + * @param y - y coordinate of the + */ + boolean contains(float x, float y); + + /** + * Return if element is visible. + */ + boolean isVisible(); + + /** + * Set element visibility. + * @param visible - is element visible + */ + void setVisible(boolean visible); +} +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */
\ No newline at end of file diff --git a/android/source/src/java/org/libreoffice/canvas/CanvasElementImplRequirement.java b/android/source/src/java/org/libreoffice/canvas/CanvasElementImplRequirement.java new file mode 100644 index 0000000000..26789e8d89 --- /dev/null +++ b/android/source/src/java/org/libreoffice/canvas/CanvasElementImplRequirement.java @@ -0,0 +1,25 @@ +package org.libreoffice.canvas; + +import android.graphics.Canvas; + +/** + * The interface defines a set of method that a typical CanvasElement + * implementation should implement. + */ +interface CanvasElementImplRequirement { + + /** + * Implement hit test here + * @param x - x coordinate of the + * @param y - y coordinate of the + */ + boolean onHitTest(float x, float y); + + /** + * Called inside draw if the element is visible. Override this method to + * draw the element on the canvas. + * + * @param canvas - the canvas + */ + void onDraw(Canvas canvas); +} diff --git a/android/source/src/java/org/libreoffice/canvas/CommonCanvasElement.java b/android/source/src/java/org/libreoffice/canvas/CommonCanvasElement.java new file mode 100644 index 0000000000..6b40ae4ba9 --- /dev/null +++ b/android/source/src/java/org/libreoffice/canvas/CommonCanvasElement.java @@ -0,0 +1,46 @@ +package org.libreoffice.canvas; + +import android.graphics.Canvas; + +/** + * Common implementation to canvas elements. + */ +public abstract class CommonCanvasElement implements CanvasElement, CanvasElementImplRequirement { + + private boolean mVisible = false; + + /** + * Is element visible? + */ + @Override + public boolean isVisible() { + return mVisible; + } + + /** + * Set element visibility. + */ + @Override + public void setVisible(boolean visible) { + mVisible = visible; + } + + /** + * Trigger drawing the element on the canvas. + */ + @Override + public void draw(Canvas canvas) { + if (isVisible()) { + onDraw(canvas); + } + } + + /** + * Hit test. Return true if the element was hit. Directly return false if + * the element is invisible. + */ + @Override + public boolean contains(float x, float y) { + return isVisible() && onHitTest(x, y); + } +} diff --git a/android/source/src/java/org/libreoffice/canvas/Cursor.java b/android/source/src/java/org/libreoffice/canvas/Cursor.java new file mode 100644 index 0000000000..1cd30edb75 --- /dev/null +++ b/android/source/src/java/org/libreoffice/canvas/Cursor.java @@ -0,0 +1,56 @@ +package org.libreoffice.canvas; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RectF; + +/** + * Handles the cursor drawing on the canvas. + */ +public class Cursor extends CommonCanvasElement { + private static final float CURSOR_WIDTH = 2f; + private final Paint mCursorPaint = new Paint(); + public RectF mPosition = new RectF(); + public RectF mScaledPosition = new RectF(); + public int mAlpha = 0; + + /** + * Construct the cursor and set the default values. + */ + public Cursor() { + mCursorPaint.setColor(Color.BLACK); + mCursorPaint.setAlpha(0xFF); + } + + /** + * Hit test for cursor, always false. + */ + @Override + public boolean onHitTest(float x, float y) { + return false; + } + + /** + * Draw the cursor. + */ + @Override + public void onDraw(Canvas canvas) { + canvas.drawRect(mScaledPosition, mCursorPaint); + } + + /** + * Reposition the cursor on screen. + */ + public void reposition(RectF rect) { + mScaledPosition = rect; + mScaledPosition.right = mScaledPosition.left + CURSOR_WIDTH; + } + + /** + * Cycle the alpha color of the cursor, makes the + */ + public void cycleAlpha() { + mCursorPaint.setAlpha(mCursorPaint.getAlpha() == 0 ? 0xFF : 0); + } +} diff --git a/android/source/src/java/org/libreoffice/canvas/GraphicSelection.java b/android/source/src/java/org/libreoffice/canvas/GraphicSelection.java new file mode 100644 index 0000000000..8d773b2ea2 --- /dev/null +++ b/android/source/src/java/org/libreoffice/canvas/GraphicSelection.java @@ -0,0 +1,295 @@ +/* -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.libreoffice.canvas; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PointF; +import android.graphics.RectF; + +import org.libreoffice.LOKitShell; +import org.libreoffice.LibreOfficeMainActivity; +import org.mozilla.gecko.gfx.LayerView; + +import static org.libreoffice.canvas.GraphicSelectionHandle.HandlePosition; + +/** + * This class is responsible to draw and reposition the selection + * rectangle. + */ +public class GraphicSelection extends CommonCanvasElement { + private final Paint mPaintStroke; + private final Paint mPaintFill; + public RectF mRectangle = new RectF(); + public RectF mScaledRectangle = new RectF(); + private RectF mDrawRectangle = new RectF(); + private DragType mType = DragType.NONE; + private PointF mStartDragPosition; + + private GraphicSelectionHandle mHandles[] = new GraphicSelectionHandle[8]; + private GraphicSelectionHandle mDragHandle = null; + private boolean mTriggerSinglePress = false; + private LibreOfficeMainActivity mContext; + + /** + * Construct the graphic selection. + */ + public GraphicSelection(LibreOfficeMainActivity context) { + mContext = context; + // Create the paint, which is needed at drawing + mPaintStroke = new Paint(); + mPaintStroke.setStyle(Paint.Style.STROKE); + mPaintStroke.setColor(Color.GRAY); + mPaintStroke.setStrokeWidth(2); + mPaintStroke.setAntiAlias(true); + + mPaintFill = new Paint(); + mPaintFill.setStyle(Paint.Style.FILL); + mPaintFill.setColor(Color.WHITE); + mPaintFill.setAlpha(200); + mPaintFill.setAntiAlias(true); + + // Create the handles of the selection + mHandles[0] = new GraphicSelectionHandle(HandlePosition.TOP_LEFT); + mHandles[1] = new GraphicSelectionHandle(HandlePosition.TOP); + mHandles[2] = new GraphicSelectionHandle(HandlePosition.TOP_RIGHT); + mHandles[3] = new GraphicSelectionHandle(HandlePosition.LEFT); + mHandles[4] = new GraphicSelectionHandle(HandlePosition.RIGHT); + mHandles[5] = new GraphicSelectionHandle(HandlePosition.BOTTOM_LEFT); + mHandles[6] = new GraphicSelectionHandle(HandlePosition.BOTTOM); + mHandles[7] = new GraphicSelectionHandle(HandlePosition.BOTTOM_RIGHT); + } + + /** + * Viewport has changed, reposition the selection to the new rectangle. + * @param scaledRectangle - rectangle of selection position on the document + */ + public void reposition(RectF scaledRectangle) { + mScaledRectangle = scaledRectangle; + mDrawRectangle = scaledRectangle; // rectangle that will be draw + + // reposition the handles too + mHandles[0].reposition(scaledRectangle.left, scaledRectangle.top); + mHandles[1].reposition(scaledRectangle.centerX(), scaledRectangle.top); + mHandles[2].reposition(scaledRectangle.right, scaledRectangle.top); + mHandles[3].reposition(scaledRectangle.left, scaledRectangle.centerY()); + mHandles[4].reposition(scaledRectangle.right, scaledRectangle.centerY()); + mHandles[5].reposition(scaledRectangle.left, scaledRectangle.bottom); + mHandles[6].reposition(scaledRectangle.centerX(), scaledRectangle.bottom); + mHandles[7].reposition(scaledRectangle.right, scaledRectangle.bottom); + } + + /** + * Hit test for the selection. + * @see org.libreoffice.canvas.CanvasElement#draw(android.graphics.Canvas) + */ + @Override + public boolean onHitTest(float x, float y) { + // Check if handle was hit + for (GraphicSelectionHandle handle : mHandles) { + if (handle.contains(x, y)) { + return true; + } + } + return mScaledRectangle.contains(x, y); + } + + /** + * Draw the selection on the canvas. + * @see org.libreoffice.canvas.CanvasElement#draw(android.graphics.Canvas) + */ + @Override + public void onDraw(Canvas canvas) { + canvas.drawRect(mDrawRectangle, mPaintStroke); + if (mType != DragType.NONE) { + canvas.drawRect(mDrawRectangle, mPaintFill); + } + for (GraphicSelectionHandle handle : mHandles) { + handle.draw(canvas); + } + } + + /** + * Dragging on the screen has started. + * @param position - position where the dragging started + */ + public void dragStart(PointF position) { + mDragHandle = null; + mType = DragType.NONE; + for (GraphicSelectionHandle handle : mHandles) { + if (handle.contains(position.x, position.y)) { + mDragHandle = handle; + mDragHandle.select(); + mType = DragType.EXTEND; + sendGraphicSelectionStart(handle.mPosition); + } + } + if (mDragHandle == null) { + mType = DragType.MOVE; + sendGraphicSelectionStart(position); + } + mStartDragPosition = position; + mTriggerSinglePress = true; + } + + /** + * Dragging is in process. + * @param position - position of the drag + */ + public void dragging(PointF position) { + if (mType == DragType.MOVE) { + float deltaX = position.x - mStartDragPosition.x; + float deltaY = position.y - mStartDragPosition.y; + + mDrawRectangle = new RectF(mScaledRectangle); + mDrawRectangle.offset(deltaX, deltaY); + } else if (mType == DragType.EXTEND) { + adaptDrawRectangle(position.x, position.y); + } + mTriggerSinglePress = false; + } + + /** + * Dragging has ended. + * @param position - last position of the drag + */ + public void dragEnd(PointF position) { + PointF point = new PointF(); + if (mDragHandle != null) { + point.x = mDragHandle.mPosition.x; + point.y = mDragHandle.mPosition.y; + mDragHandle.reset(); + mDragHandle = null; + } else { + point.x = mStartDragPosition.x; + point.y = mStartDragPosition.y; + } + float deltaX = position.x - mStartDragPosition.x; + float deltaY = position.y - mStartDragPosition.y; + point.offset(deltaX, deltaY); + + sendGraphicSelectionEnd(point); + + if (mTriggerSinglePress && mDragHandle == null) { + onSinglePress(point); + mTriggerSinglePress = false; + } + + mDrawRectangle = mScaledRectangle; + mType = DragType.NONE; + } + + /** + * Adapt the selection depending on which handle was dragged. + */ + private void adaptDrawRectangle(float x, float y) { + mDrawRectangle = new RectF(mScaledRectangle); + switch(mDragHandle.getHandlePosition()) { + case TOP_LEFT: + mDrawRectangle.left = x; + mDrawRectangle.top = y; + break; + case TOP: + mDrawRectangle.top = y; + break; + case TOP_RIGHT: + mDrawRectangle.right = x; + mDrawRectangle.top = y; + break; + case LEFT: + mDrawRectangle.left = x; + break; + case RIGHT: + mDrawRectangle.right = x; + break; + case BOTTOM_LEFT: + mDrawRectangle.left = x; + mDrawRectangle.bottom = y; + break; + case BOTTOM: + mDrawRectangle.bottom = y; + break; + case BOTTOM_RIGHT: + mDrawRectangle.right = x; + mDrawRectangle.bottom = y; + break; + } + } + + /** + * Send graphic selection start event to LOKitTread. + * @param screenPosition - screen position of the selection + */ + private void sendGraphicSelectionStart(PointF screenPosition) { + sendGraphicSelection("GraphicSelectionStart", screenPosition); + } + + /** + * Send graphic selection end event to LOKitTread. + * @param screenPosition - screen position of the selection + */ + private void sendGraphicSelectionEnd(PointF screenPosition) { + sendGraphicSelection("GraphicSelectionEnd", screenPosition); + } + + /** + * Send graphic selection event to LOKitTread. + * @param type - type of the graphic selection + * @param screenPosition - screen position of the selection + */ + private void sendGraphicSelection(String type, PointF screenPosition) + { + LayerView layerView = mContext.getLayerClient().getView(); + if (layerView != null) { + // Position is in screen coordinates. We need to convert them to + // document coordinates. + PointF documentPoint = layerView.getLayerClient().convertViewPointToLayerPoint(screenPosition); + LOKitShell.sendTouchEvent(type, documentPoint); + } + } + + /** + * When a single press (no dragging happened) was performed. + */ + private void onSinglePress(PointF screenPosition) { + sendGraphicSelection("LongPress", screenPosition); + } + + /** + * Set the visibility of the graphic selection. + */ + @Override + public void setVisible(boolean visible) { + super.setVisible(visible); + for (GraphicSelectionHandle handle: mHandles) { + handle.setVisible(visible); + } + } + + /** + * Reset the selection. + */ + public void reset() { + mDragHandle = null; + for (GraphicSelectionHandle handle : mHandles) { + handle.reset(); + } + } + + /** + * Type of the selection dragging. + */ + public enum DragType { + NONE, + MOVE, + EXTEND + } +} +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/canvas/GraphicSelectionHandle.java b/android/source/src/java/org/libreoffice/canvas/GraphicSelectionHandle.java new file mode 100644 index 0000000000..68b445af6f --- /dev/null +++ b/android/source/src/java/org/libreoffice/canvas/GraphicSelectionHandle.java @@ -0,0 +1,146 @@ +/* -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.libreoffice.canvas; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PointF; +import android.graphics.RectF; + +/** + * This class is responsible to draw the selection handles, track the handle + * position and perform a hit test to determine if the selection handle was + * touched. + */ +public class GraphicSelectionHandle extends CommonCanvasElement { + /** + * The factor used to inflate the hit area. + */ + private final float HIT_AREA_INFLATE_FACTOR = 1.75f; + + private final HandlePosition mHandlePosition; + public PointF mPosition = new PointF(); + private float mRadius = 20.0f; + private Paint mStrokePaint = new Paint(); + private Paint mFillPaint = new Paint(); + private Paint mSelectedFillPaint = new Paint(); + private RectF mHitRect = new RectF(); + private boolean mSelected = false; + + /** + * Construct the handle - set the handle position on the selection. + * @param position - the handle position on the selection + */ + public GraphicSelectionHandle(HandlePosition position) { + mHandlePosition = position; + + mStrokePaint.setStyle(Paint.Style.STROKE); + mStrokePaint.setColor(Color.GRAY); + mStrokePaint.setStrokeWidth(3); + mStrokePaint.setAntiAlias(true); + + mFillPaint.setStyle(Paint.Style.FILL); + mFillPaint.setColor(Color.WHITE); + mFillPaint.setAlpha(200); + mFillPaint.setAntiAlias(true); + + mSelectedFillPaint.setStyle(Paint.Style.FILL); + mSelectedFillPaint.setColor(Color.GRAY); + mSelectedFillPaint.setAlpha(200); + mSelectedFillPaint.setAntiAlias(true); + } + + /** + * The position of the handle. + * @return + */ + public HandlePosition getHandlePosition() { + return mHandlePosition; + } + + /** + * Draws the handle to the canvas. + * + * @see org.libreoffice.canvas.CanvasElement#draw(android.graphics.Canvas) + */ + @Override + public void onDraw(Canvas canvas) { + if (mSelected) { + drawFilledCircle(canvas, mPosition.x, mPosition.y, mRadius, mStrokePaint, mSelectedFillPaint); + } else { + drawFilledCircle(canvas, mPosition.x, mPosition.y, mRadius, mStrokePaint, mFillPaint); + } + } + + /** + * Draw a filled and stroked circle to the canvas. + */ + private void drawFilledCircle(Canvas canvas, float x, float y, float radius, Paint strokePaint, Paint fillPaint) { + canvas.drawCircle(x, y, radius, fillPaint); + canvas.drawCircle(x, y, radius, strokePaint); + } + + /** + * Viewport has changed, reposition the handle to the input coordinates. + */ + public void reposition(float x, float y) { + mPosition.x = x; + mPosition.y = y; + + // inflate the radius by HIT_AREA_INFLATE_FACTOR + float inflatedRadius = mRadius * HIT_AREA_INFLATE_FACTOR; + + // reposition the hit area rectangle + mHitRect.left = mPosition.x - inflatedRadius; + mHitRect.right = mPosition.x + inflatedRadius; + mHitRect.top = mPosition.y - inflatedRadius; + mHitRect.bottom = mPosition.y + inflatedRadius; + } + + /** + * Hit test for the handle. + * @see org.libreoffice.canvas.CanvasElement#draw(android.graphics.Canvas) + */ + @Override + public boolean onHitTest(float x, float y) { + return mHitRect.contains(x, y); + } + + /** + * Mark the handle as selected. + */ + public void select() { + mSelected = true; + } + + /** + * Reset the selection for the handle. + */ + public void reset() { + mSelected = false; + } + + /** + * All possible handle positions. The selection rectangle has 8 possible + * handles. + */ + public enum HandlePosition { + TOP_LEFT, + TOP, + TOP_RIGHT, + RIGHT, + BOTTOM_RIGHT, + BOTTOM, + BOTTOM_LEFT, + LEFT + } +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */
\ No newline at end of file diff --git a/android/source/src/java/org/libreoffice/canvas/ImageUtils.java b/android/source/src/java/org/libreoffice/canvas/ImageUtils.java new file mode 100644 index 0000000000..ecda9b77c5 --- /dev/null +++ b/android/source/src/java/org/libreoffice/canvas/ImageUtils.java @@ -0,0 +1,29 @@ +package org.libreoffice.canvas; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; + +class ImageUtils { + static Bitmap getBitmapForDrawable(Drawable drawable) { + drawable = drawable.mutate(); + + int width = !drawable.getBounds().isEmpty() ? + drawable.getBounds().width() : drawable.getIntrinsicWidth(); + + width = width <= 0 ? 1 : width; + + int height = !drawable.getBounds().isEmpty() ? + drawable.getBounds().height() : drawable.getIntrinsicHeight(); + + height = height <= 0 ? 1 : height; + + final Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + + return bitmap; + } +} +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/canvas/PageNumberRect.java b/android/source/src/java/org/libreoffice/canvas/PageNumberRect.java new file mode 100644 index 0000000000..62de88ea54 --- /dev/null +++ b/android/source/src/java/org/libreoffice/canvas/PageNumberRect.java @@ -0,0 +1,64 @@ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.libreoffice.canvas; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.text.TextPaint; + +/* + * A canvas element on DocumentOverlayView. Shows a rectangle with current page + * number and total page number inside of it. + */ +public class PageNumberRect extends CommonCanvasElement { + private String mPageNumberString; + private TextPaint mPageNumberRectPaint = new TextPaint(); + private Paint mBgPaint = new Paint(); + private Rect mTextBounds = new Rect(); + private float mBgMargin = 5f; + + public PageNumberRect() { + mBgPaint.setColor(Color.BLACK); + mBgPaint.setAlpha(100); + mPageNumberRectPaint.setColor(Color.WHITE); + } + + /** + * Implement hit test here + * + * @param x - x coordinate of the + * @param y - y coordinate of the + */ + @Override + public boolean onHitTest(float x, float y) { + return false; + } + + /** + * Called inside draw if the element is visible. Override this method to + * draw the element on the canvas. + * + * @param canvas - the canvas + */ + @Override + public void onDraw(Canvas canvas) { + canvas.drawRect(canvas.getWidth()*0.1f - mBgMargin, + canvas.getHeight()*0.1f - mTextBounds.height() - mBgMargin, + mTextBounds.width() + canvas.getWidth()*0.1f + mBgMargin, + canvas.getHeight()*0.1f + mBgMargin, + mBgPaint); + canvas.drawText(mPageNumberString, canvas.getWidth()*0.1f, canvas.getHeight()*0.1f, mPageNumberRectPaint); + } + + public void setPageNumberString (String pageNumberString) { + mPageNumberString = pageNumberString; + mPageNumberRectPaint.getTextBounds(mPageNumberString, 0, mPageNumberString.length(), mTextBounds); + } +} diff --git a/android/source/src/java/org/libreoffice/canvas/SelectionHandle.java b/android/source/src/java/org/libreoffice/canvas/SelectionHandle.java new file mode 100644 index 0000000000..ddd16fe5eb --- /dev/null +++ b/android/source/src/java/org/libreoffice/canvas/SelectionHandle.java @@ -0,0 +1,73 @@ +package org.libreoffice.canvas; + +import android.graphics.Bitmap; +import android.graphics.PointF; + +import org.libreoffice.LOKitShell; +import org.libreoffice.LibreOfficeMainActivity; +import org.mozilla.gecko.gfx.ImmutableViewportMetrics; + +/** + * Selection handle is a common class for "start", "middle" and "end" types + * of selection handles. + */ +public abstract class SelectionHandle extends BitmapHandle { + private static final long MINIMUM_HANDLE_UPDATE_TIME = 50 * 1000000; + + private final PointF mDragStartPoint = new PointF(); + private final PointF mDragDocumentPosition = new PointF(); + private long mLastTime = 0; + + private LibreOfficeMainActivity mContext; + + public SelectionHandle(LibreOfficeMainActivity context, Bitmap bitmap) { + super(bitmap); + mContext = context; + } + + /** + * Start of a touch and drag action on the handle. + */ + public void dragStart(PointF point) { + mDragStartPoint.x = point.x; + mDragStartPoint.y = point.y; + mDragDocumentPosition.x = mDocumentPosition.left; + mDragDocumentPosition.y = mDocumentPosition.top; + } + + /** + * End of a touch and drag action on the handle. + */ + public void dragEnd(PointF point) { + } + + /** + * Handle has been dragged. + */ + public void dragging(PointF point) { + long currentTime = System.nanoTime(); + if (currentTime - mLastTime > MINIMUM_HANDLE_UPDATE_TIME) { + mLastTime = currentTime; + signalHandleMove(point.x, point.y); + } + } + + /** + * Signal to move the handle to a new position to LO. + */ + private void signalHandleMove(float newX, float newY) { + ImmutableViewportMetrics viewportMetrics = mContext.getLayerClient().getViewportMetrics(); + float zoom = viewportMetrics.zoomFactor; + + float deltaX = (newX - mDragStartPoint.x) / zoom; + float deltaY = (newY - mDragStartPoint.y) / zoom; + + PointF documentPoint = new PointF(mDragDocumentPosition.x + deltaX, mDragDocumentPosition.y + deltaY); + + LOKitShell.sendChangeHandlePositionEvent(getHandleType(), documentPoint); + } + + public abstract HandleType getHandleType(); + + public enum HandleType { START, MIDDLE, END } +} diff --git a/android/source/src/java/org/libreoffice/canvas/SelectionHandleEnd.java b/android/source/src/java/org/libreoffice/canvas/SelectionHandleEnd.java new file mode 100644 index 0000000000..b85b80fc95 --- /dev/null +++ b/android/source/src/java/org/libreoffice/canvas/SelectionHandleEnd.java @@ -0,0 +1,22 @@ +package org.libreoffice.canvas; + +import org.libreoffice.LibreOfficeMainActivity; + +import org.libreoffice.R; + +/** + * Selection handle for showing and manipulating the end of a selection. + */ +public class SelectionHandleEnd extends SelectionHandle { + public SelectionHandleEnd(LibreOfficeMainActivity context) { + super(context, getBitmapForDrawable(context, R.drawable.handle_alias_end)); + } + + /** + * Define the type of the handle. + */ + @Override + public HandleType getHandleType() { + return HandleType.END; + } +} diff --git a/android/source/src/java/org/libreoffice/canvas/SelectionHandleMiddle.java b/android/source/src/java/org/libreoffice/canvas/SelectionHandleMiddle.java new file mode 100644 index 0000000000..76bdf9110a --- /dev/null +++ b/android/source/src/java/org/libreoffice/canvas/SelectionHandleMiddle.java @@ -0,0 +1,34 @@ +package org.libreoffice.canvas; + +import org.libreoffice.LibreOfficeMainActivity; + +import org.libreoffice.R; + +/** + * Selection handle that is used to manipulate the cursor. + */ +public class SelectionHandleMiddle extends SelectionHandle { + public SelectionHandleMiddle(LibreOfficeMainActivity context) { + super(context, getBitmapForDrawable(context, R.drawable.handle_alias_middle)); + } + + /** + * Change the position of the handle on the screen. Take into account the + * handle alignment to the center. + */ + @Override + public void reposition(float x, float y) { + super.reposition(x, y); + // align to the center + float offset = mScreenPosition.width() / 2.0f; + mScreenPosition.offset(-offset, 0); + } + + /** + * Define the type of the handle. + */ + @Override + public HandleType getHandleType() { + return HandleType.MIDDLE; + } +} diff --git a/android/source/src/java/org/libreoffice/canvas/SelectionHandleStart.java b/android/source/src/java/org/libreoffice/canvas/SelectionHandleStart.java new file mode 100644 index 0000000000..ad28826f64 --- /dev/null +++ b/android/source/src/java/org/libreoffice/canvas/SelectionHandleStart.java @@ -0,0 +1,34 @@ +package org.libreoffice.canvas; + +import org.libreoffice.LibreOfficeMainActivity; + +import org.libreoffice.R; + +/** + * Selection handle for showing and manipulating the start of a selection. + */ +public class SelectionHandleStart extends SelectionHandle { + public SelectionHandleStart(LibreOfficeMainActivity context) { + super(context, getBitmapForDrawable(context, R.drawable.handle_alias_start)); + } + + /** + * Change the position of the handle on the screen. Take into account the + * handle alignment to the right. + */ + @Override + public void reposition(float x, float y) { + super.reposition(x, y); + // align to the right + float offset = mScreenPosition.width(); + mScreenPosition.offset(-offset, 0); + } + + /** + * Define the type of the handle. + */ + @Override + public HandleType getHandleType() { + return HandleType.START; + } +}
\ No newline at end of file diff --git a/android/source/src/java/org/libreoffice/overlay/CalcHeadersController.java b/android/source/src/java/org/libreoffice/overlay/CalcHeadersController.java new file mode 100644 index 0000000000..8b99c292cb --- /dev/null +++ b/android/source/src/java/org/libreoffice/overlay/CalcHeadersController.java @@ -0,0 +1,281 @@ +package org.libreoffice.overlay; + +import android.content.Context; +import android.graphics.PointF; +import android.graphics.RectF; +import android.graphics.drawable.ColorDrawable; +import com.google.android.material.snackbar.Snackbar; +import android.util.Log; +import android.view.KeyEvent; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup.LayoutParams; +import android.view.inputmethod.EditorInfo; +import android.widget.EditText; +import android.widget.Button; +import android.widget.PopupWindow; +import android.widget.TextView; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.libreoffice.LOEvent; +import org.libreoffice.LOKitShell; +import org.libreoffice.LibreOfficeMainActivity; +import org.libreoffice.R; +import org.mozilla.gecko.gfx.LayerView; + +import java.math.BigDecimal; +import java.util.ArrayList; + +import static org.libreoffice.SearchController.addProperty; + +public class CalcHeadersController { + private static final String LOGTAG = CalcHeadersController.class.getSimpleName(); + + private final CalcHeadersView mCalcRowHeadersView; + private final CalcHeadersView mCalcColumnHeadersView; + + private LibreOfficeMainActivity mContext; + + public CalcHeadersController(LibreOfficeMainActivity context, final LayerView layerView) { + mContext = context; + mContext.getDocumentOverlay().setCalcHeadersController(this); + mCalcRowHeadersView = context.findViewById(R.id.calc_header_row); + mCalcColumnHeadersView = context.findViewById(R.id.calc_header_column); + if (mCalcColumnHeadersView == null || mCalcRowHeadersView == null) { + Log.e(LOGTAG, "Failed to initialize Calc headers - View is null"); + } else { + mCalcRowHeadersView.initialize(layerView, true); + mCalcColumnHeadersView.initialize(layerView, false); + } + LOKitShell.sendEvent(new LOEvent(LOEvent.UPDATE_CALC_HEADERS)); + context.findViewById(R.id.calc_header_top_left).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:SelectAll")); + if (mCalcColumnHeadersView == null) return; + mCalcColumnHeadersView.showHeaderPopup(new PointF()); + } + }); + ((EditText)context.findViewById(R.id.calc_address)).setOnEditorActionListener(new TextView.OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_DONE || actionId == EditorInfo.IME_ACTION_GO) { + String text = v.getText().toString(); + JSONObject rootJson = new JSONObject(); + try { + addProperty(rootJson, "ToPoint", "string", text); + } catch (JSONException e) { + e.printStackTrace(); + } + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:GoToCell", rootJson.toString())); + mContext.hideSoftKeyboard(); + layerView.requestFocus(); + } + return true; + } + }); + ((EditText)context.findViewById(R.id.calc_formula)).setOnEditorActionListener(new TextView.OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_DONE || actionId == EditorInfo.IME_ACTION_GO) { + String text = v.getText().toString(); + JSONObject rootJson = new JSONObject(); + try { + addProperty(rootJson, "StringName", "string", text); + addProperty(rootJson, "DontCommit", "boolean", String.valueOf(false)); + } catch (JSONException e) { + e.printStackTrace(); + } + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:EnterString", rootJson.toString())); + mContext.hideSoftKeyboard(); + layerView.requestFocus(); + mContext.setDocumentChanged(true); + } + return true; + } + }); + // manually select A1 for address bar and formula bar to update when calc first opens + JSONObject rootJson = new JSONObject(); + try { + addProperty(rootJson, "ToPoint", "string", "A1"); + } catch (JSONException e) { + e.printStackTrace(); + } + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:GoToCell", rootJson.toString())); + } + + public void setupHeaderPopupView() { + LayoutInflater inflater = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + String[] rowOrColumn = {"Row","Column"}; + CalcHeadersView[] headersViews= {mCalcRowHeadersView, mCalcColumnHeadersView}; + for (int i = 0; i < rowOrColumn.length; i++) { + // create popup window + final String tempName = rowOrColumn[i]; + final CalcHeadersView tempView = headersViews[i]; + final View headerPopupView = inflater.inflate(R.layout.calc_header_popup, null); + final PopupWindow popupWindow = new PopupWindow(headerPopupView, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + popupWindow.setOnDismissListener(new PopupWindow.OnDismissListener() { + @Override + public void onDismiss() { + headerPopupView.findViewById(R.id.calc_header_popup_optimal_length_dialog).setVisibility(View.GONE); + popupWindow.setFocusable(false); + } + }); + popupWindow.setOutsideTouchable(true); + popupWindow.setBackgroundDrawable(new ColorDrawable()); + popupWindow.setAnimationStyle(android.R.style.Animation_Dialog); + tempView.setHeaderPopupWindow(popupWindow); + // set up child views in the popup window + headerPopupView.findViewById(R.id.calc_header_popup_insert).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:Insert"+tempName+"s")); + tempView.dismissPopupWindow(); + mContext.setDocumentChanged(true); + } + }); + headerPopupView.findViewById(R.id.calc_header_popup_delete).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:Delete"+tempName+"s")); + tempView.dismissPopupWindow(); + mContext.setDocumentChanged(true); + } + }); + headerPopupView.findViewById(R.id.calc_header_popup_hide).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:Hide"+tempName)); + tempView.dismissPopupWindow(); + mContext.setDocumentChanged(true); + } + }); + headerPopupView.findViewById(R.id.calc_header_popup_show).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:Show"+tempName)); + tempView.dismissPopupWindow(); + mContext.setDocumentChanged(true); + } + }); + headerPopupView.findViewById(R.id.calc_header_popup_optimal_length).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + View view = headerPopupView.findViewById(R.id.calc_header_popup_optimal_length_dialog); + if (view.getVisibility() == View.VISIBLE) { + view.setVisibility(View.GONE); + popupWindow.setFocusable(false); + popupWindow.update(); + } else { + popupWindow.dismiss(); + view.setVisibility(View.VISIBLE); + popupWindow.setFocusable(true); + popupWindow.showAtLocation(tempView, Gravity.CENTER, 0, 0); + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + Snackbar.make(tempView, R.string.calc_alert_double_click_optimal_length, Snackbar.LENGTH_LONG).show(); + } + }); + } + } + }); + headerPopupView.findViewById(R.id.calc_header_popup_optimal_length_button).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + String text = ((EditText)headerPopupView.findViewById(R.id.calc_header_popup_optimal_length_text)).getText().toString(); + tempView.sendOptimalLengthRequest(text); + tempView.dismissPopupWindow(); + mContext.setDocumentChanged(true); + } + }); + headerPopupView.findViewById(R.id.calc_header_popup_adjust_length).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + mContext.getDocumentOverlay().showAdjustLengthLine(tempView == mCalcRowHeadersView, tempView); + tempView.dismissPopupWindow(); + mContext.setDocumentChanged(true); + } + }); + ((Button)headerPopupView.findViewById(R.id.calc_header_popup_adjust_length)) + .setText(tempView == mCalcRowHeadersView ? R.string.calc_adjust_height : R.string.calc_adjust_width); + ((Button)headerPopupView.findViewById(R.id.calc_header_popup_optimal_length)) + .setText(tempView == mCalcRowHeadersView ? R.string.calc_optimal_height : R.string.calc_optimal_width); + + } + } + + public void setHeaders(String headers) { + HeaderInfo parsedHeaders = parseHeaderInfo(headers); + if (parsedHeaders != null) { + mCalcRowHeadersView.setHeaders(parsedHeaders.rowLabels, parsedHeaders.rowDimens); + mCalcColumnHeadersView.setHeaders(parsedHeaders.columnLabels, parsedHeaders.columnDimens); + showHeaders(); + } else { + Log.e(LOGTAG, "Parse header info JSON failed."); + } + } + + public void showHeaders() { + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + mCalcColumnHeadersView.invalidate(); + mCalcRowHeadersView.invalidate(); + } + }); + } + + private HeaderInfo parseHeaderInfo(String headers) { + HeaderInfo headerInfo = new HeaderInfo(); + try { + JSONObject collectiveResult = new JSONObject(headers); + JSONArray rowResult = collectiveResult.getJSONArray("rows"); + for (int i = 0; i < rowResult.length(); i++) { + headerInfo.rowLabels.add(rowResult.getJSONObject(i).getString("text")); + headerInfo.rowDimens.add(BigDecimal.valueOf(rowResult.getJSONObject(i).getLong("size")).floatValue()); + } + JSONArray columnResult = collectiveResult.getJSONArray("columns"); + for (int i = 0; i < columnResult.length(); i++) { + headerInfo.columnLabels.add(columnResult.getJSONObject(i).getString("text")); + headerInfo.columnDimens.add(BigDecimal.valueOf(columnResult.getJSONObject(i).getLong("size")).floatValue()); + } + return headerInfo; + } catch (JSONException e) { + e.printStackTrace(); + } + return null; + } + + public void showHeaderSelection(RectF cellCursorRect) { + mCalcRowHeadersView.setHeaderSelection(cellCursorRect); + mCalcColumnHeadersView.setHeaderSelection(cellCursorRect); + showHeaders(); + } + + public void setPendingRowOrColumnSelectionToShowUp(boolean b) { + mCalcRowHeadersView.setPendingRowOrColumnSelectionToShowUp(b); + mCalcColumnHeadersView.setPendingRowOrColumnSelectionToShowUp(b); + } + + public boolean pendingRowOrColumnSelectionToShowUp() { + return mCalcColumnHeadersView.pendingRowOrColumnSelectionToShowUp() + || mCalcRowHeadersView.pendingRowOrColumnSelectionToShowUp(); + } + + private class HeaderInfo { + ArrayList<String> rowLabels; + ArrayList<Float> rowDimens; + ArrayList<String> columnLabels; + ArrayList<Float> columnDimens; + private HeaderInfo() { + rowLabels = new ArrayList<String>(); + rowDimens = new ArrayList<Float>(); + columnDimens = new ArrayList<Float>(); + columnLabels = new ArrayList<String>(); + } + } +} diff --git a/android/source/src/java/org/libreoffice/overlay/CalcHeadersView.java b/android/source/src/java/org/libreoffice/overlay/CalcHeadersView.java new file mode 100644 index 0000000000..98af7a9554 --- /dev/null +++ b/android/source/src/java/org/libreoffice/overlay/CalcHeadersView.java @@ -0,0 +1,278 @@ +package org.libreoffice.overlay; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.PointF; +import android.graphics.RectF; +import androidx.core.view.GestureDetectorCompat; +import android.util.AttributeSet; +import android.view.GestureDetector.SimpleOnGestureListener; +import android.view.MotionEvent; +import android.view.View; +import android.widget.PopupWindow; + +import org.json.JSONException; +import org.json.JSONObject; +import org.libreoffice.LOEvent; +import org.libreoffice.LOKitShell; +import org.libreoffice.LibreOfficeMainActivity; +import org.libreoffice.canvas.CalcHeaderCell; +import org.libreoffice.kit.Document; +import org.mozilla.gecko.gfx.ImmutableViewportMetrics; +import org.mozilla.gecko.gfx.LayerView; + +import java.util.ArrayList; +import java.util.Collections; + +import static org.libreoffice.SearchController.addProperty; + +public class CalcHeadersView extends View { + private static final String LOGTAG = CalcHeadersView.class.getSimpleName(); + + private boolean mInitialized; + private LayerView mLayerView; + private boolean mIsRow; // true if this is for row headers, false for column + private ArrayList<String> mLabels; + private ArrayList<Float> mDimens; + private RectF mCellCursorRect; + private boolean mPendingRowOrColumnSelectionToShowUp; + private GestureDetectorCompat mDetector; + private PopupWindow mPopupWindow; + private int mPrevScrollIndex = -1; + + public CalcHeadersView(Context context) { + super(context); + } + + public CalcHeadersView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public CalcHeadersView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public void initialize(LayerView layerView, boolean isRow) { + if (!mInitialized) { + mLayerView = layerView; + mIsRow = isRow; + + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + mDetector = new GestureDetectorCompat(getContext(), new HeaderGestureListener()); + } + }); + + setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + if (event.getActionMasked() == MotionEvent.ACTION_UP) { + mPrevScrollIndex = -1; // clear mPrevScrollIndex to default + } + return mDetector.onTouchEvent(event); + } + }); + + mInitialized = true; + } + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + if (mInitialized && mDimens != null && mLabels != null) { + updateHeaders(canvas); + } + } + + private void updateHeaders(Canvas canvas) { + ImmutableViewportMetrics metrics = mLayerView.getViewportMetrics(); + float zoom = metrics.getZoomFactor(); + PointF origin = metrics.getOrigin(); + + // Draw headers + boolean inRangeOfVisibleHeaders = false; // a helper variable for skipping unnecessary onDraw()'s + float top,bottom,left,right; + for (int i = 1; i < mLabels.size(); i++) { + if (mDimens.get(i).equals(mDimens.get(i-1))) continue; + if (mIsRow) { + top = -origin.y + zoom*mDimens.get(i-1); + bottom = -origin.y + zoom*mDimens.get(i); + if (top <= getHeight() && bottom >= 0) { + inRangeOfVisibleHeaders = true; + boolean isSelected = mCellCursorRect != null && bottom > mCellCursorRect.top - origin.y && top < mCellCursorRect.bottom - origin.y; + new CalcHeaderCell(0f, top, getWidth(), bottom - top, mLabels.get(i), isSelected).onDraw(canvas); + } else { + if (inRangeOfVisibleHeaders) { + break; + } + } + } else { + left = -origin.x + zoom*mDimens.get(i-1); + right = -origin.x + zoom*mDimens.get(i); + if (left <= getWidth() && right >= 0) { + boolean isSelected = mCellCursorRect != null && right > mCellCursorRect.left - origin.x && left < mCellCursorRect.right - origin.x; + new CalcHeaderCell(left, 0f, right - left, getHeight(), mLabels.get(i), isSelected).onDraw(canvas); + } else { + if (inRangeOfVisibleHeaders) { + break; + } + } + } + } + } + + /** + * Handle a single tap event on a header cell. + * Selects whole row/column. + */ + private void highlightRowOrColumn(PointF point, boolean shift) { + int index = getIndexFromPointOfTouch(point); + try { + JSONObject rootJson = new JSONObject(); + if (shift) { + addProperty(rootJson, "Modifier", "unsigned short", + String.valueOf(Document.KEYBOARD_MODIFIER_SHIFT)); + } else { + addProperty(rootJson, "Modifier", "unsigned short", "0"); + } + if (mIsRow) { + addProperty(rootJson, "Row", "unsigned short", String.valueOf(index)); + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:SelectRow", rootJson.toString())); + } else { + addProperty(rootJson, "Col", "unsigned short", String.valueOf(index)); + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:SelectColumn", rootJson.toString())); + } + } catch (JSONException e) { + e.printStackTrace(); + } + // At this point, InvalidationHandler.java will have received two callbacks. + // One is for text selection (first) and the other for cell selection (second). + // The second will override the first on headers which is not wanted. + // setPendingRowOrColumnSelectionToShowUp(true) will skip the second call. + setPendingRowOrColumnSelectionToShowUp(true); + } + + public int getIndexFromPointOfTouch(PointF point) { + int searchedIndex, index; + ImmutableViewportMetrics metrics = mLayerView.getViewportMetrics(); + float zoom = metrics.getZoomFactor(); + PointF origin = metrics.getOrigin(); + if (mIsRow) { + searchedIndex = Collections.binarySearch(mDimens, (point.y+origin.y)/zoom); + } else { + searchedIndex = Collections.binarySearch(mDimens, (point.x+origin.x)/zoom); + } + // converting searched index to real index on headers + if (searchedIndex < 0) { + index = - searchedIndex - 2; + } else { + index = searchedIndex; + } + return index; + } + + public void setPendingRowOrColumnSelectionToShowUp(boolean b) { + mPendingRowOrColumnSelectionToShowUp = b; + } + + public boolean pendingRowOrColumnSelectionToShowUp() { + return mPendingRowOrColumnSelectionToShowUp; + } + + public void setHeaders(ArrayList<String> labels, ArrayList<Float> dimens) { + mLabels = labels; + mDimens = dimens; + } + + public void setHeaderSelection(RectF cellCursorRect) { + mCellCursorRect = cellCursorRect; + } + + public void showHeaderPopup(PointF point) { + if (mPopupWindow == null || + !LibreOfficeMainActivity.isExperimentalMode()) return; + if (mIsRow) { + mPopupWindow.showAsDropDown(this, getWidth()*3/2, -getHeight()+(int)point.y); + } else { + mPopupWindow.showAsDropDown(this, (int)point.x, getHeight()/2); + } + } + + public void dismissPopupWindow() { + if (mPopupWindow == null) return; + mPopupWindow.dismiss(); + } + + public void setHeaderPopupWindow(PopupWindow popupWindow) { + if (mPopupWindow != null) return; + mPopupWindow = popupWindow; + } + + public void sendOptimalLengthRequest(String text) { + JSONObject rootJson = new JSONObject(); + if (mIsRow) { + try { + addProperty(rootJson, "aExtraHeight", "unsigned short", text); + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:SetOptimalRowHeight", rootJson.toString())); + } catch (JSONException ex) { + ex.printStackTrace(); + } + } else { + try { + addProperty(rootJson, "aExtraWidth", "unsigned short", text); + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND, ".uno:SetOptimalColumnWidth", rootJson.toString())); + } catch (JSONException ex) { + ex.printStackTrace(); + } + } + } + + private class HeaderGestureListener extends SimpleOnGestureListener { + + @Override + public boolean onDown(MotionEvent e) { + return true; + } + + @Override + public boolean onSingleTapConfirmed(MotionEvent e) { + PointF pointOfTouch = new PointF(e.getX(), e.getY()); + highlightRowOrColumn(pointOfTouch, false); + showHeaderPopup(pointOfTouch); + return true; + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + PointF point2 = new PointF(e2.getX(), e2.getY()); + if (mPrevScrollIndex != getIndexFromPointOfTouch(point2)) { + mPrevScrollIndex = getIndexFromPointOfTouch(point2); + highlightRowOrColumn(point2, true); + dismissPopupWindow(); + showHeaderPopup(point2); + } + return true; + } + + @Override + public boolean onDoubleTap(MotionEvent e) { + PointF pointOfTouch = new PointF(e.getX(), e.getY()); + highlightRowOrColumn(pointOfTouch, false); + if (mIsRow) { + JSONObject rootJson = new JSONObject(); + try { + addProperty(rootJson, "aExtraHeight", "unsigned short", String.valueOf(0)); + } catch (JSONException ex) { + ex.printStackTrace(); + } + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND,".uno:SetOptimalRowHeight", rootJson.toString())); + } else { + LOKitShell.sendEvent(new LOEvent(LOEvent.UNO_COMMAND,".uno:SetOptimalColumnWidthDirect")); + } + showHeaderPopup(pointOfTouch); + return true; + } + } +} diff --git a/android/source/src/java/org/libreoffice/overlay/DocumentOverlay.java b/android/source/src/java/org/libreoffice/overlay/DocumentOverlay.java new file mode 100644 index 0000000000..f977866a28 --- /dev/null +++ b/android/source/src/java/org/libreoffice/overlay/DocumentOverlay.java @@ -0,0 +1,271 @@ +/* -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.libreoffice.overlay; + +import android.graphics.RectF; +import android.util.Log; + +import org.libreoffice.LOKitShell; +import org.libreoffice.LibreOfficeMainActivity; +import org.libreoffice.R; +import org.libreoffice.canvas.SelectionHandle; +import org.mozilla.gecko.gfx.Layer; +import org.mozilla.gecko.gfx.LayerView; +import org.mozilla.gecko.util.FloatUtils; + +import java.util.List; + +/** + * The DocumentOverlay is an overlay over the document. This class is responsible + * to setup the document overlay view, report visibility and position of its elements + * when they change and report any changes to the viewport. + */ +public class DocumentOverlay { + private static final String LOGTAG = DocumentOverlay.class.getSimpleName(); + + private final DocumentOverlayView mDocumentOverlayView; + private final DocumentOverlayLayer mDocumentOverlayLayer; + + private final long hidePageNumberRectDelayInMilliseconds = 500; + + /** + * DocumentOverlayLayer responsibility is to get the changes to the viewport + * and report them to DocumentOverlayView. + */ + private class DocumentOverlayLayer extends Layer { + private float mViewLeft; + private float mViewTop; + private float mViewZoom; + + /** + * @see Layer#draw(org.mozilla.gecko.gfx.Layer.RenderContext) + */ + @Override + public void draw(final RenderContext context) { + if (FloatUtils.fuzzyEquals(mViewLeft, context.viewport.left) + && FloatUtils.fuzzyEquals(mViewTop, context.viewport.top) + && FloatUtils.fuzzyEquals(mViewZoom, context.zoomFactor)) { + return; + } + + mViewLeft = context.viewport.left; + mViewTop = context.viewport.top; + mViewZoom = context.zoomFactor; + + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + mDocumentOverlayView.repositionWithViewport(mViewLeft, mViewTop, mViewZoom); + } + }); + } + } + + public DocumentOverlay(LibreOfficeMainActivity context, LayerView layerView) { + mDocumentOverlayView = context.findViewById(R.id.text_cursor_view); + mDocumentOverlayLayer = new DocumentOverlayLayer(); + if (mDocumentOverlayView == null) { + Log.e(LOGTAG, "Failed to initialize TextCursorLayer - CursorView is null"); + } + layerView.addLayer(mDocumentOverlayLayer); + mDocumentOverlayView.initialize(layerView); + } + + public void setPartPageRectangles(List<RectF> rectangles) { + mDocumentOverlayView.setPartPageRectangles(rectangles); + } + + /** + * Show the cursor at the defined cursor position on the overlay. + */ + public void showCursor() { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + mDocumentOverlayView.showCursor(); + } + }); + } + + /** + * Hide the cursor at the defined cursor position on the overlay. + */ + public void hideCursor() { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + mDocumentOverlayView.hideCursor(); + } + }); + } + + /** + * Show the page number rectangle on the overlay. + */ + public void showPageNumberRect() { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + mDocumentOverlayView.showPageNumberRect(); + } + }); + } + + /** + * Hide the page number rectangle on the overlay. + */ + public void hidePageNumberRect() { + LOKitShell.getMainHandler().postDelayed(new Runnable() { + public void run() { + mDocumentOverlayView.hidePageNumberRect(); + } + }, hidePageNumberRectDelayInMilliseconds); + } + + /** + * Position the cursor to the input position on the overlay. + */ + public void positionCursor(final RectF position) { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + mDocumentOverlayView.changeCursorPosition(position); + } + }); + } + + /** + * Show selections on the overlay. + */ + public void showSelections() { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + mDocumentOverlayView.showSelections(); + } + }); + } + + /** + * Hide selections on the overlay. + */ + public void hideSelections() { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + mDocumentOverlayView.hideSelections(); + } + }); + } + + /** + * Change the list of selections. + */ + public void changeSelections(final List<RectF> selections) { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + mDocumentOverlayView.changeSelections(selections); + } + }); + } + + /** + * Show the graphic selection on the overlay. + */ + public void showGraphicSelection() { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + mDocumentOverlayView.showGraphicSelection(); + } + }); + } + + /** + * Hide the graphic selection. + */ + public void hideGraphicSelection() { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + mDocumentOverlayView.hideGraphicSelection(); + } + }); + } + + /** + * Change the graphic selection rectangle to the input rectangle. + */ + public void changeGraphicSelection(final RectF rectangle) { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + mDocumentOverlayView.changeGraphicSelection(rectangle); + } + }); + } + + /** + * Show the handle (of input type) on the overlay. + */ + public void showHandle(final SelectionHandle.HandleType type) { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + mDocumentOverlayView.showHandle(type); + } + }); + } + + /** + * Hide the handle (of input type). + */ + public void hideHandle(final SelectionHandle.HandleType type) { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + mDocumentOverlayView.hideHandle(type); + } + }); + } + + /** + * Position the handle (of input type) position to the input rectangle. + */ + public void positionHandle(final SelectionHandle.HandleType type, final RectF rectangle) { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + mDocumentOverlayView.positionHandle(type, rectangle); + } + }); + } + + public RectF getCurrentCursorPosition() { + return mDocumentOverlayView.getCurrentCursorPosition(); + } + + public void setCalcHeadersController(CalcHeadersController calcHeadersController) { + mDocumentOverlayView.setCalcHeadersController(calcHeadersController); + } + + public void showCellSelection(final RectF cellCursorRect) { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + mDocumentOverlayView.showCellSelection(cellCursorRect); + } + }); + } + + public void showHeaderSelection(final RectF cellCursorRect) { + LOKitShell.getMainHandler().post(new Runnable() { + public void run() { + mDocumentOverlayView.showHeaderSelection(cellCursorRect); + } + }); + } + + public void showAdjustLengthLine(final boolean isRow, final CalcHeadersView view) { + LOKitShell.getMainHandler().post(new Runnable() { + @Override + public void run() { + mDocumentOverlayView.showAdjustLengthLine(isRow, view); + } + }); + } +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/overlay/DocumentOverlayView.java b/android/source/src/java/org/libreoffice/overlay/DocumentOverlayView.java new file mode 100644 index 0000000000..086108cd90 --- /dev/null +++ b/android/source/src/java/org/libreoffice/overlay/DocumentOverlayView.java @@ -0,0 +1,552 @@ +/* -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.libreoffice.overlay; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PointF; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; + +import org.libreoffice.LibreOfficeMainActivity; +import org.libreoffice.R; +import org.libreoffice.canvas.AdjustLengthLine; +import org.libreoffice.canvas.CalcSelectionBox; +import org.libreoffice.canvas.Cursor; +import org.libreoffice.canvas.GraphicSelection; +import org.libreoffice.canvas.PageNumberRect; +import org.libreoffice.canvas.SelectionHandle; +import org.libreoffice.canvas.SelectionHandleEnd; +import org.libreoffice.canvas.SelectionHandleMiddle; +import org.libreoffice.canvas.SelectionHandleStart; +import org.mozilla.gecko.gfx.ImmutableViewportMetrics; +import org.mozilla.gecko.gfx.LayerView; +import org.mozilla.gecko.gfx.RectUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * Document overlay view is responsible for showing the client drawn overlay + * elements like cursor, selection and graphic selection, and manipulate them. + */ +public class DocumentOverlayView extends View implements View.OnTouchListener { + private static final String LOGTAG = DocumentOverlayView.class.getSimpleName(); + + private static final int CURSOR_BLINK_TIME = 500; + + private boolean mInitialized = false; + + private List<RectF> mSelections = new ArrayList<RectF>(); + private List<RectF> mScaledSelections = new ArrayList<RectF>(); + private Paint mSelectionPaint = new Paint(); + private boolean mSelectionsVisible; + + private GraphicSelection mGraphicSelection; + + private boolean mGraphicSelectionMove = false; + + private LayerView mLayerView; + + private SelectionHandle mHandleMiddle; + private SelectionHandle mHandleStart; + private SelectionHandle mHandleEnd; + + private Cursor mCursor; + + private SelectionHandle mDragHandle = null; + + private List<RectF> mPartPageRectangles; + private PageNumberRect mPageNumberRect; + private boolean mPageNumberAvailable = false; + private int previousIndex = 0; // previous page number, used to compare with the current + private CalcHeadersController mCalcHeadersController; + + private CalcSelectionBox mCalcSelectionBox; + private boolean mCalcSelectionBoxDragging; + private AdjustLengthLine mAdjustLengthLine; + private boolean mAdjustLengthLineDragging; + + public DocumentOverlayView(Context context) { + super(context); + } + + public DocumentOverlayView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public DocumentOverlayView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + /** + * Initialize the selection and cursor view. + */ + public void initialize(LayerView layerView) { + if (!mInitialized) { + setOnTouchListener(this); + mLayerView = layerView; + + mCursor = new Cursor(); + mCursor.setVisible(false); + + mSelectionPaint.setColor(Color.BLUE); + mSelectionPaint.setAlpha(50); + mSelectionsVisible = false; + + mGraphicSelection = new GraphicSelection((LibreOfficeMainActivity) getContext()); + mGraphicSelection.setVisible(false); + + postDelayed(cursorAnimation, CURSOR_BLINK_TIME); + + mHandleMiddle = new SelectionHandleMiddle((LibreOfficeMainActivity) getContext()); + mHandleStart = new SelectionHandleStart((LibreOfficeMainActivity) getContext()); + mHandleEnd = new SelectionHandleEnd((LibreOfficeMainActivity) getContext()); + + mInitialized = true; + } + } + + /** + * Change the cursor position. + * @param position - new position of the cursor + */ + public void changeCursorPosition(RectF position) { + if (RectUtils.fuzzyEquals(mCursor.mPosition, position)) { + return; + } + mCursor.mPosition = position; + + ImmutableViewportMetrics metrics = mLayerView.getViewportMetrics(); + repositionWithViewport(metrics.viewportRectLeft, metrics.viewportRectTop, metrics.zoomFactor); + } + + /** + * Change the text selection rectangles. + * @param selectionRects - list of text selection rectangles + */ + public void changeSelections(List<RectF> selectionRects) { + mSelections = selectionRects; + + ImmutableViewportMetrics metrics = mLayerView.getViewportMetrics(); + repositionWithViewport(metrics.viewportRectLeft, metrics.viewportRectTop, metrics.zoomFactor); + } + + /** + * Change the graphic selection rectangle. + * @param rectangle - new graphic selection rectangle + */ + public void changeGraphicSelection(RectF rectangle) { + if (RectUtils.fuzzyEquals(mGraphicSelection.mRectangle, rectangle)) { + return; + } + + mGraphicSelection.mRectangle = rectangle; + + ImmutableViewportMetrics metrics = mLayerView.getViewportMetrics(); + repositionWithViewport(metrics.viewportRectLeft, metrics.viewportRectTop, metrics.zoomFactor); + } + + public void repositionWithViewport(float x, float y, float zoom) { + RectF rect = convertToScreen(mCursor.mPosition, x, y, zoom); + mCursor.reposition(rect); + + rect = convertToScreen(mHandleMiddle.mDocumentPosition, x, y, zoom); + mHandleMiddle.reposition(rect.left, rect.bottom); + + rect = convertToScreen(mHandleStart.mDocumentPosition, x, y, zoom); + mHandleStart.reposition(rect.left, rect.bottom); + + rect = convertToScreen(mHandleEnd.mDocumentPosition, x, y, zoom); + mHandleEnd.reposition(rect.left, rect.bottom); + + mScaledSelections.clear(); + for (RectF selection : mSelections) { + RectF scaledSelection = convertToScreen(selection, x, y, zoom); + mScaledSelections.add(scaledSelection); + } + + if (mCalcSelectionBox != null) { + rect = convertToScreen(mCalcSelectionBox.mDocumentPosition, x, y, zoom); + mCalcSelectionBox.reposition(rect); + } + + if (mGraphicSelection != null && mGraphicSelection.mRectangle != null) { + RectF scaledGraphicSelection = convertToScreen(mGraphicSelection.mRectangle, x, y, zoom); + mGraphicSelection.reposition(scaledGraphicSelection); + } + + invalidate(); + } + + /** + * Convert the input rectangle from document to screen coordinates + * according to current viewport data (x, y, zoom). + */ + private static RectF convertToScreen(RectF inputRect, float x, float y, float zoom) { + RectF rect = RectUtils.scale(inputRect, zoom); + rect.offset(-x, -y); + return rect; + } + + /** + * Set part page rectangles and initialize a page number rectangle object + * (canvas element). + */ + public void setPartPageRectangles (List<RectF> rectangles) { + mPartPageRectangles = rectangles; + mPageNumberRect = new PageNumberRect(); + mPageNumberAvailable = true; + } + + /** + * Drawing on canvas. + */ + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + mCursor.draw(canvas); + + if (mPageNumberAvailable) { + mPageNumberRect.draw(canvas); + } + + mHandleMiddle.draw(canvas); + mHandleStart.draw(canvas); + mHandleEnd.draw(canvas); + + if (mSelectionsVisible) { + for (RectF selection : mScaledSelections) { + canvas.drawRect(selection, mSelectionPaint); + } + } + + if (mCalcSelectionBox != null) { + mCalcSelectionBox.draw(canvas); + } + + mGraphicSelection.draw(canvas); + + if (mCalcHeadersController != null) { + mCalcHeadersController.showHeaders(); + } + + if (mAdjustLengthLine != null) { + mAdjustLengthLine.draw(canvas); + } + } + + /** + * Cursor animation function. Switch the alpha between opaque and fully transparent. + */ + private Runnable cursorAnimation = new Runnable() { + public void run() { + if (mCursor.isVisible()) { + mCursor.cycleAlpha(); + invalidate(); + } + postDelayed(cursorAnimation, CURSOR_BLINK_TIME); + } + }; + + /** + * Show the cursor on the view. + */ + public void showCursor() { + if (!mCursor.isVisible()) { + mCursor.setVisible(true); + invalidate(); + } + } + + /** + * Hide the cursor. + */ + public void hideCursor() { + if (mCursor.isVisible()) { + mCursor.setVisible(false); + invalidate(); + } + } + + /** + * Calculate and show page number according to current viewport position. + * In particular, this function compares the middle point of the + * view port with page rectangles and finds out which page the user + * is currently on. It does not update the associated canvas element + * unless there is a change of page number. + */ + public void showPageNumberRect() { + if (null == mPartPageRectangles) return; + PointF midPoint = mLayerView.getLayerClient().convertViewPointToLayerPoint(new PointF(getWidth()/2f, getHeight()/2f)); + int index = previousIndex; + // search which page the user in currently on. can enhance the search algorithm to binary search if necessary + for (RectF page : mPartPageRectangles) { + if (page.top < midPoint.y && midPoint.y < page.bottom) { + index = mPartPageRectangles.indexOf(page) + 1; + break; + } + } + // index == 0 applies to non-text document, i.e. don't show page info on non-text docs + if (index == 0) { + return; + } + // if page rectangle canvas element is not visible or the page number is changed, show + if (!mPageNumberRect.isVisible() || index != previousIndex) { + previousIndex = index; + String pageNumberString = getContext().getString(R.string.page) + " " + index + "/" + mPartPageRectangles.size(); + mPageNumberRect.setPageNumberString(pageNumberString); + mPageNumberRect.setVisible(true); + invalidate(); + } + } + + /** + * Hide page number rectangle canvas element. + */ + public void hidePageNumberRect() { + if (null == mPageNumberRect) return; + if (mPageNumberRect.isVisible()) { + mPageNumberRect.setVisible(false); + invalidate(); + } + } + + /** + * Show text selection rectangles. + */ + public void showSelections() { + if (!mSelectionsVisible) { + mSelectionsVisible = true; + invalidate(); + } + } + + /** + * Hide text selection rectangles. + */ + public void hideSelections() { + if (mSelectionsVisible) { + mSelectionsVisible = false; + invalidate(); + } + } + + /** + * Show the graphic selection on the view. + */ + public void showGraphicSelection() { + if (!mGraphicSelection.isVisible()) { + mGraphicSelectionMove = false; + mGraphicSelection.reset(); + mGraphicSelection.setVisible(true); + invalidate(); + } + } + + /** + * Hide the graphic selection. + */ + public void hideGraphicSelection() { + if (mGraphicSelection.isVisible()) { + mGraphicSelection.setVisible(false); + invalidate(); + } + } + + /** + * Handle the triggered touch event. + */ + @Override + public boolean onTouch(View view, MotionEvent event) { + PointF point = new PointF(event.getX(), event.getY()); + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: { + if (mAdjustLengthLine != null && !mAdjustLengthLine.contains(point.x, point.y)) { + mAdjustLengthLine.setVisible(false); + invalidate(); + } + if (mGraphicSelection.isVisible()) { + // Check if inside graphic selection was hit + if (mGraphicSelection.contains(point.x, point.y)) { + mGraphicSelectionMove = true; + mGraphicSelection.dragStart(point); + invalidate(); + return true; + } + } else { + if (mHandleStart.contains(point.x, point.y)) { + mHandleStart.dragStart(point); + mDragHandle = mHandleStart; + return true; + } else if (mHandleEnd.contains(point.x, point.y)) { + mHandleEnd.dragStart(point); + mDragHandle = mHandleEnd; + return true; + } else if (mHandleMiddle.contains(point.x, point.y)) { + mHandleMiddle.dragStart(point); + mDragHandle = mHandleMiddle; + return true; + } else if (mCalcSelectionBox != null && + mCalcSelectionBox.contains(point.x, point.y) && + !mHandleStart.isVisible()) { + mCalcSelectionBox.dragStart(point); + mCalcSelectionBoxDragging = true; + return true; + } else if (mAdjustLengthLine != null && + mAdjustLengthLine.contains(point.x, point.y)) { + mAdjustLengthLine.dragStart(point); + mAdjustLengthLineDragging = true; + return true; + } + } + } + case MotionEvent.ACTION_UP: { + if (mGraphicSelection.isVisible() && mGraphicSelectionMove) { + mGraphicSelection.dragEnd(point); + mGraphicSelectionMove = false; + invalidate(); + return true; + } else if (mDragHandle != null) { + mDragHandle.dragEnd(point); + mDragHandle = null; + } else if (mCalcSelectionBoxDragging) { + mCalcSelectionBox.dragEnd(point); + mCalcSelectionBoxDragging = false; + } else if (mAdjustLengthLineDragging) { + mAdjustLengthLine.dragEnd(point); + mAdjustLengthLineDragging = false; + invalidate(); + } + } + case MotionEvent.ACTION_MOVE: { + if (mGraphicSelection.isVisible() && mGraphicSelectionMove) { + mGraphicSelection.dragging(point); + invalidate(); + return true; + } else if (mDragHandle != null) { + mDragHandle.dragging(point); + } else if (mCalcSelectionBoxDragging) { + mCalcSelectionBox.dragging(point); + } else if (mAdjustLengthLineDragging) { + mAdjustLengthLine.dragging(point); + invalidate(); + } + } + } + return false; + } + + /** + * Change the handle document position. + * @param type - the type of the handle + * @param position - the new document position + */ + public void positionHandle(SelectionHandle.HandleType type, RectF position) { + SelectionHandle handle = getHandleForType(type); + if (RectUtils.fuzzyEquals(handle.mDocumentPosition, position)) { + return; + } + + RectUtils.assign(handle.mDocumentPosition, position); + + ImmutableViewportMetrics metrics = mLayerView.getViewportMetrics(); + repositionWithViewport(metrics.viewportRectLeft, metrics.viewportRectTop, metrics.zoomFactor); + } + + /** + * Hide the handle. + * @param type - type of the handle + */ + public void hideHandle(SelectionHandle.HandleType type) { + SelectionHandle handle = getHandleForType(type); + if (handle.isVisible()) { + handle.setVisible(false); + invalidate(); + } + } + + /** + * Show the handle. + * @param type - type of the handle + */ + public void showHandle(SelectionHandle.HandleType type) { + SelectionHandle handle = getHandleForType(type); + if (!handle.isVisible()) { + handle.setVisible(true); + invalidate(); + } + } + + /** + * Returns the handle instance for the input type. + */ + private SelectionHandle getHandleForType(SelectionHandle.HandleType type) { + switch(type) { + case START: + return mHandleStart; + case END: + return mHandleEnd; + case MIDDLE: + return mHandleMiddle; + } + return null; + } + + public RectF getCurrentCursorPosition() { + return mCursor.mPosition; + } + + public void setCalcHeadersController(CalcHeadersController calcHeadersController) { + mCalcHeadersController = calcHeadersController; + mCalcSelectionBox = new CalcSelectionBox((LibreOfficeMainActivity) getContext()); + } + + public void showCellSelection(RectF cellCursorRect) { + if (mCalcHeadersController == null || mCalcSelectionBox == null) return; + if (RectUtils.fuzzyEquals(mCalcSelectionBox.mDocumentPosition, cellCursorRect)) { + return; + } + + // show selection on main GL view (i.e. in the document) + RectUtils.assign(mCalcSelectionBox.mDocumentPosition, cellCursorRect); + mCalcSelectionBox.setVisible(true); + + ImmutableViewportMetrics metrics = mLayerView.getViewportMetrics(); + repositionWithViewport(metrics.viewportRectLeft, metrics.viewportRectTop, metrics.zoomFactor); + + // show selection on headers + if (!mCalcHeadersController.pendingRowOrColumnSelectionToShowUp()) { + showHeaderSelection(cellCursorRect); + } else { + mCalcHeadersController.setPendingRowOrColumnSelectionToShowUp(false); + } + } + + public void showHeaderSelection(RectF rect) { + if (mCalcHeadersController == null) return; + mCalcHeadersController.showHeaderSelection(rect); + } + + public void showAdjustLengthLine(boolean isRow, final CalcHeadersView view) { + mAdjustLengthLine = new AdjustLengthLine((LibreOfficeMainActivity) getContext(), view, isRow, getWidth(), getHeight()); + ImmutableViewportMetrics metrics = mLayerView.getViewportMetrics(); + RectF position = convertToScreen(mCalcSelectionBox.mDocumentPosition, metrics.viewportRectLeft, metrics.viewportRectTop, metrics.zoomFactor); + mAdjustLengthLine.setScreenRect(position); + mAdjustLengthLine.setVisible(true); + invalidate(); + } +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/ui/FileUtilities.java b/android/source/src/java/org/libreoffice/ui/FileUtilities.java new file mode 100644 index 0000000000..7fc8c3c84e --- /dev/null +++ b/android/source/src/java/org/libreoffice/ui/FileUtilities.java @@ -0,0 +1,158 @@ +/* -*- tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.libreoffice.ui; + +import java.util.Map; +import java.util.HashMap; + +import android.content.ContentResolver; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.util.Log; + +public class FileUtilities { + + private static final String LOGTAG = FileUtilities.class.getSimpleName(); + + // These have to be in sync with the file_view_modes resource. + static final int DOC = 0; + static final int CALC = 1; + static final int IMPRESS = 2; + static final int DRAWING = 3; + + static final int UNKNOWN = 10; + + public static final String MIMETYPE_OPENDOCUMENT_TEXT = "application/vnd.oasis.opendocument.text"; + public static final String MIMETYPE_OPENDOCUMENT_SPREADSHEET = "application/vnd.oasis.opendocument.spreadsheet"; + public static final String MIMETYPE_OPENDOCUMENT_PRESENTATION = "application/vnd.oasis.opendocument.presentation"; + public static final String MIMETYPE_OPENDOCUMENT_GRAPHICS = "application/vnd.oasis.opendocument.graphics"; + public static final String MIMETYPE_PDF = "application/pdf"; + + private static final Map<String, Integer> mExtnMap = new HashMap<String, Integer>(); + static { + // Please keep this in sync with AndroidManifest.xml + // and 'SUPPORTED_MIME_TYPES' in LibreOfficeUIActivity.java + + // ODF + mExtnMap.put(".odt", DOC); + mExtnMap.put(".odg", DRAWING); + mExtnMap.put(".odp", IMPRESS); + mExtnMap.put(".ods", CALC); + mExtnMap.put(".fodt", DOC); + mExtnMap.put(".fodg", DRAWING); + mExtnMap.put(".fodp", IMPRESS); + mExtnMap.put(".fods", CALC); + + // ODF templates + mExtnMap.put(".ott", DOC); + mExtnMap.put(".otg", DRAWING); + mExtnMap.put(".otp", IMPRESS); + mExtnMap.put(".ots", CALC); + + // MS + mExtnMap.put(".rtf", DOC); + mExtnMap.put(".doc", DOC); + mExtnMap.put(".vsd", DRAWING); + mExtnMap.put(".vsdx", DRAWING); + mExtnMap.put(".pub", DRAWING); + mExtnMap.put(".ppt", IMPRESS); + mExtnMap.put(".pps", IMPRESS); + mExtnMap.put(".xls", CALC); + + // MS templates + mExtnMap.put(".dot", DOC); + mExtnMap.put(".pot", IMPRESS); + mExtnMap.put(".xlt", CALC); + + // OOXML + mExtnMap.put(".docx", DOC); + mExtnMap.put(".pptx", IMPRESS); + mExtnMap.put(".ppsx", IMPRESS); + mExtnMap.put(".xlsx", CALC); + + // OOXML templates + mExtnMap.put(".dotx", DOC); + mExtnMap.put(".potx", IMPRESS); + mExtnMap.put(".xltx", CALC); + + // Other + mExtnMap.put(".csv", CALC); + mExtnMap.put(".wps", DOC); + mExtnMap.put(".key", IMPRESS); + mExtnMap.put(".abw", DOC); + mExtnMap.put(".pmd", DRAWING); + mExtnMap.put(".emf", DRAWING); + mExtnMap.put(".svm", DRAWING); + mExtnMap.put(".wmf", DRAWING); + mExtnMap.put(".svg", DRAWING); + } + + public static String getExtension(String filename) { + if (filename == null) + return ""; + int nExt = filename.lastIndexOf('.'); + if (nExt < 0) + return ""; + return filename.substring(nExt); + } + + private static int lookupExtension(String filename) { + String extn = getExtension(filename); + if (!mExtnMap.containsKey(extn)) + return UNKNOWN; + return mExtnMap.get(extn); + } + + static int getType(String filename) { + int type = lookupExtension (filename); + Log.d(LOGTAG, "extn : " + filename + " -> " + type); + return type; + } + + /** + * Returns whether the passed MIME type is one for a document template. + */ + public static boolean isTemplateMimeType(final String mimeType) { + // this works for ODF and OOXML template MIME types + return mimeType != null && mimeType.endsWith("template"); + } + + public static String stripExtensionFromFileName(final String fileName) + { + return fileName.split("\\.[A-Za-z0-9]*$")[0]; + } + + /** + * Tries to retrieve the display (which should be the document name) + * for the given URI using the given resolver. + */ + public static String retrieveDisplayNameForDocumentUri(ContentResolver resolver, Uri docUri) { + String displayName = ""; + // try to retrieve original file name + Cursor cursor = null; + try { + String[] columns = {OpenableColumns.DISPLAY_NAME}; + cursor = resolver.query(docUri, columns, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + displayName = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)); + } + } catch (SecurityException e) { + // thrown e.g. when Uri has become invalid, e.g. corresponding file has been deleted + Log.i(LOGTAG, "SecurityException when trying to receive display name for Uri " + docUri); + } finally { + if (cursor != null) { + cursor.close(); + } + } + return displayName; + } +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/ui/LibreOfficeUIActivity.java b/android/source/src/java/org/libreoffice/ui/LibreOfficeUIActivity.java new file mode 100644 index 0000000000..bc5203d9c6 --- /dev/null +++ b/android/source/src/java/org/libreoffice/ui/LibreOfficeUIActivity.java @@ -0,0 +1,457 @@ +/* -*- tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package org.libreoffice.ui; + +import android.Manifest; +import android.content.ActivityNotFoundException; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.graphics.drawable.Icon; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.preference.PreferenceManager; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.core.view.ViewCompat; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.appcompat.widget.Toolbar; +import android.text.TextUtils; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.view.animation.OvershootInterpolator; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.libreoffice.AboutDialogFragment; +import org.libreoffice.BuildConfig; +import org.libreoffice.LibreOfficeMainActivity; +import org.libreoffice.LocaleHelper; +import org.libreoffice.R; +import org.libreoffice.SettingsActivity; +import org.libreoffice.SettingsListenerModel; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class LibreOfficeUIActivity extends AppCompatActivity implements SettingsListenerModel.OnSettingsPreferenceChangedListener, View.OnClickListener{ + public enum DocumentType { + WRITER, + CALC, + IMPRESS, + DRAW, + INVALID + } + + private static final String LOGTAG = LibreOfficeUIActivity.class.getSimpleName(); + + public static final String EXPLORER_PREFS_KEY = "EXPLORER_PREFS"; + private static final String RECENT_DOCUMENTS_KEY = "RECENT_DOCUMENT_URIS"; + // delimiter used for storing multiple URIs in a string + private static final String RECENT_DOCUMENTS_DELIMITER = " "; + private static final String DISPLAY_LANGUAGE = "DISPLAY_LANGUAGE"; + + public static final String NEW_DOC_TYPE_KEY = "NEW_DOC_TYPE_KEY"; + public static final String NEW_WRITER_STRING_KEY = "private:factory/swriter"; + public static final String NEW_IMPRESS_STRING_KEY = "private:factory/simpress"; + public static final String NEW_CALC_STRING_KEY = "private:factory/scalc"; + public static final String NEW_DRAW_STRING_KEY = "private:factory/sdraw"; + + // keep this in sync with 'AndroidManifext.xml' + private static final String[] SUPPORTED_MIME_TYPES = { + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.graphics", + "application/vnd.oasis.opendocument.presentation", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.text-flat-xml", + "application/vnd.oasis.opendocument.graphics-flat-xml", + "application/vnd.oasis.opendocument.presentation-flat-xml", + "application/vnd.oasis.opendocument.spreadsheet-flat-xml", + "application/vnd.oasis.opendocument.text-template", + "application/vnd.oasis.opendocument.spreadsheet-template", + "application/vnd.oasis.opendocument.graphics-template", + "application/vnd.oasis.opendocument.presentation-template", + "application/rtf", + "text/rtf", + "application/msword", + "application/vnd.ms-powerpoint", + "application/vnd.ms-excel", + "application/vnd.visio", + "application/vnd.visio.xml", + "application/x-mspublisher", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.presentationml.slideshow", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + "application/vnd.openxmlformats-officedocument.spreadsheetml.template", + "application/vnd.openxmlformats-officedocument.presentationml.template", + "text/csv", + "text/comma-separated-values", + "application/vnd.ms-works", + "application/vnd.apple.keynote", + "application/x-abiword", + "application/x-pagemaker", + "image/x-emf", + "image/x-svm", + "image/x-wmf", + "image/svg+xml", + }; + + private static final int REQUEST_CODE_OPEN_FILECHOOSER = 12345; + + private static final int PERMISSION_WRITE_EXTERNAL_STORAGE = 0; + + private Animation fabOpenAnimation; + private Animation fabCloseAnimation; + private boolean isFabMenuOpen = false; + private FloatingActionButton editFAB; + private FloatingActionButton writerFAB; + private FloatingActionButton drawFAB; + private FloatingActionButton impressFAB; + private FloatingActionButton calcFAB; + private LinearLayout drawLayout; + private LinearLayout writerLayout; + private LinearLayout impressLayout; + private LinearLayout calcLayout; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + readPreferences(); + SettingsListenerModel.getInstance().setListener(this); + + // init UI + createUI(); + fabOpenAnimation = AnimationUtils.loadAnimation(this, R.anim.fab_open); + fabCloseAnimation = AnimationUtils.loadAnimation(this, R.anim.fab_close); + } + + @Override + protected void onStart() { + super.onStart(); + if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + Log.i(LOGTAG, "no permission to read external storage - asking for permission"); + ActivityCompat.requestPermissions(this, + new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, + PERMISSION_WRITE_EXTERNAL_STORAGE); + } + } + + @Override + protected void attachBaseContext(Context newBase) { + super.attachBaseContext(LocaleHelper.onAttach(newBase)); + } + + public void createUI() { + setContentView(R.layout.activity_document_browser); + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + ActionBar actionBar = getSupportActionBar(); + + if (actionBar != null) { + actionBar.setIcon(R.mipmap.ic_launcher); + } + + editFAB = findViewById(R.id.editFAB); + editFAB.setOnClickListener(this); + // allow creating new docs only when experimental editing is enabled + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + final boolean bEditingEnabled = BuildConfig.ALLOW_EDITING && preferences.getBoolean(LibreOfficeMainActivity.ENABLE_EXPERIMENTAL_PREFS_KEY, false); + editFAB.setVisibility(bEditingEnabled ? View.VISIBLE : View.INVISIBLE); + + impressFAB = findViewById(R.id.newImpressFAB); + impressFAB.setOnClickListener(this); + writerFAB = findViewById(R.id.newWriterFAB); + writerFAB.setOnClickListener(this); + calcFAB = findViewById(R.id.newCalcFAB); + calcFAB.setOnClickListener(this); + drawFAB = findViewById(R.id.newDrawFAB); + drawFAB.setOnClickListener(this); + writerLayout = findViewById(R.id.writerLayout); + impressLayout = findViewById(R.id.impressLayout); + calcLayout = findViewById(R.id.calcLayout); + drawLayout = findViewById(R.id.drawLayout); + TextView openFileView = findViewById(R.id.open_file_button); + openFileView.setOnClickListener(this); + + + RecyclerView recentRecyclerView = findViewById(R.id.list_recent); + + SharedPreferences prefs = getSharedPreferences(EXPLORER_PREFS_KEY, MODE_PRIVATE); + String recentPref = prefs.getString(RECENT_DOCUMENTS_KEY, ""); + String[] recentFileStrings = recentPref.split(RECENT_DOCUMENTS_DELIMITER); + + final List<RecentFile> recentFiles = new ArrayList<>(); + for (String recentFileString : recentFileStrings) { + Uri uri = Uri.parse(recentFileString); + String filename = FileUtilities.retrieveDisplayNameForDocumentUri(getContentResolver(), uri); + if (!filename.isEmpty()) { + recentFiles.add(new RecentFile(uri, filename)); + } + } + + recentRecyclerView.setLayoutManager(new GridLayoutManager(this, 2)); + recentRecyclerView.setAdapter(new RecentFilesAdapter(this, recentFiles)); + } + + private void expandFabMenu() { + ViewCompat.animate(editFAB).rotation(45.0F).withLayer().setDuration(300).setInterpolator(new OvershootInterpolator(10.0F)).start(); + drawLayout.startAnimation(fabOpenAnimation); + impressLayout.startAnimation(fabOpenAnimation); + writerLayout.startAnimation(fabOpenAnimation); + calcLayout.startAnimation(fabOpenAnimation); + writerFAB.setClickable(true); + impressFAB.setClickable(true); + drawFAB.setClickable(true); + calcFAB.setClickable(true); + isFabMenuOpen = true; + } + + private void collapseFabMenu() { + ViewCompat.animate(editFAB).rotation(0.0F).withLayer().setDuration(300).setInterpolator(new OvershootInterpolator(10.0F)).start(); + writerLayout.startAnimation(fabCloseAnimation); + impressLayout.startAnimation(fabCloseAnimation); + drawLayout.startAnimation(fabCloseAnimation); + calcLayout.startAnimation(fabCloseAnimation); + writerFAB.setClickable(false); + impressFAB.setClickable(false); + drawFAB.setClickable(false); + calcFAB.setClickable(false); + isFabMenuOpen = false; + } + + @Override + public void onBackPressed() { + if (isFabMenuOpen) { + collapseFabMenu(); + } else { + super.onBackPressed(); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_CODE_OPEN_FILECHOOSER && resultCode == RESULT_OK) { + final Uri fileUri = data.getData(); + openDocument(fileUri); + } + } + + private void showSystemFilePickerAndOpenFile() { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.setType("*/*"); + intent.putExtra(Intent.EXTRA_MIME_TYPES, SUPPORTED_MIME_TYPES); + + try { + startActivityForResult(intent, REQUEST_CODE_OPEN_FILECHOOSER); + } catch (ActivityNotFoundException e) { + Log.w(LOGTAG, "No activity available that can handle the intent to open a document."); + } + } + + public void openDocument(final Uri documentUri) { + // "forward" to LibreOfficeMainActivity to open the file + Intent intent = new Intent(Intent.ACTION_VIEW, documentUri); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + + addDocumentToRecents(documentUri); + + String packageName = getApplicationContext().getPackageName(); + ComponentName componentName = new ComponentName(packageName, + LibreOfficeMainActivity.class.getName()); + intent.setComponent(componentName); + startActivity(intent); + } + + private void loadNewDocument(DocumentType docType) { + final String newDocumentType; + if (docType == DocumentType.WRITER) { + newDocumentType = NEW_WRITER_STRING_KEY; + } else if (docType == DocumentType.CALC) { + newDocumentType = NEW_CALC_STRING_KEY; + } else if (docType == DocumentType.IMPRESS) { + newDocumentType = NEW_IMPRESS_STRING_KEY; + } else if (docType == DocumentType.DRAW) { + newDocumentType = NEW_DRAW_STRING_KEY; + } else { + Log.w(LOGTAG, "invalid document type passed to loadNewDocument method. Ignoring request"); + return; + } + + Intent intent = new Intent(LibreOfficeUIActivity.this, LibreOfficeMainActivity.class); + intent.putExtra(NEW_DOC_TYPE_KEY, newDocumentType); + startActivity(intent); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.view_menu, menu); + + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + final int itemId = item.getItemId(); + if (itemId == R.id.action_about) { + AboutDialogFragment aboutDialogFragment = new AboutDialogFragment(); + aboutDialogFragment.show(getSupportFragmentManager(), "AboutDialogFragment"); + return true; + } + if (itemId == R.id.action_settings) { + startActivity(new Intent(getApplicationContext(), SettingsActivity.class)); + return true; + } + + return super.onOptionsItemSelected(item); + } + + public void readPreferences(){ + SharedPreferences defaultPrefs = PreferenceManager.getDefaultSharedPreferences(getBaseContext()); + final String displayLanguage = defaultPrefs.getString(DISPLAY_LANGUAGE, LocaleHelper.SYSTEM_DEFAULT_LANGUAGE); + LocaleHelper.setLocale(this, displayLanguage); + } + + @Override + public void settingsPreferenceChanged(SharedPreferences sharedPreferences, String key) { + readPreferences(); + } + + @Override + protected void onResume() { + super.onResume(); + Log.d(LOGTAG, "onResume"); + createUI(); + } + + private void addDocumentToRecents(Uri fileUri) { + SharedPreferences prefs = getSharedPreferences(EXPLORER_PREFS_KEY, MODE_PRIVATE); + + // preserve permissions across device reboots, + // s. https://developer.android.com/training/data-storage/shared/documents-files#persist-permissions + getContentResolver().takePersistableUriPermission(fileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + + String newRecent = fileUri.toString(); + List<String> recentsList = new ArrayList<>(Arrays.asList(prefs.getString(RECENT_DOCUMENTS_KEY, "").split(RECENT_DOCUMENTS_DELIMITER))); + + // remove string if present, so that it doesn't appear multiple times + recentsList.remove(newRecent); + + // put the new value in the first place + recentsList.add(0, newRecent); + + /* + * 4 because the number of recommended items in App Shortcuts is 4, and also + * because it's a good number of recent items in general + */ + final int RECENTS_SIZE = 4; + + while (recentsList.size() > RECENTS_SIZE) { + recentsList.remove(RECENTS_SIZE); + } + + // serialize to String that can be set for pref + String value = TextUtils.join(RECENT_DOCUMENTS_DELIMITER, recentsList); + prefs.edit().putString(RECENT_DOCUMENTS_KEY, value).apply(); + + //update app shortcuts (7.0 and above) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N_MR1) { + ShortcutManager shortcutManager = getSystemService(ShortcutManager.class); + + //Remove all shortcuts, and apply new ones. + shortcutManager.removeAllDynamicShortcuts(); + + ArrayList<ShortcutInfo> shortcuts = new ArrayList<>(); + for (String recentDoc : recentsList) { + Uri docUri = Uri.parse(recentDoc); + String filename = FileUtilities.retrieveDisplayNameForDocumentUri(getContentResolver(), docUri); + if (filename.isEmpty()) { + continue; + } + + //find the appropriate drawable + int drawable = 0; + switch (FileUtilities.getType(filename)) { + case FileUtilities.DOC: + drawable = R.drawable.writer; + break; + case FileUtilities.CALC: + drawable = R.drawable.calc; + break; + case FileUtilities.DRAWING: + drawable = R.drawable.draw; + break; + case FileUtilities.IMPRESS: + drawable = R.drawable.impress; + break; + } + + Intent intent = new Intent(Intent.ACTION_VIEW, docUri); + String packageName = this.getApplicationContext().getPackageName(); + ComponentName componentName = new ComponentName(packageName, LibreOfficeMainActivity.class.getName()); + intent.setComponent(componentName); + + ShortcutInfo shortcut = new ShortcutInfo.Builder(this, filename) + .setShortLabel(filename) + .setLongLabel(filename) + .setIcon(Icon.createWithResource(this, drawable)) + .setIntent(intent) + .build(); + + shortcuts.add(shortcut); + } + shortcutManager.setDynamicShortcuts(shortcuts); + } + } + + @Override + public void onClick(View v) { + int id = v.getId(); + if (id == R.id.editFAB) { + if (isFabMenuOpen) { + collapseFabMenu(); + } else { + expandFabMenu(); + } + } else if (id == R.id.open_file_button) { + showSystemFilePickerAndOpenFile(); + } else if (id == R.id.newWriterFAB) { + loadNewDocument(DocumentType.WRITER); + } else if (id == R.id.newImpressFAB) { + loadNewDocument(DocumentType.IMPRESS); + } else if (id == R.id.newCalcFAB) { + loadNewDocument(DocumentType.CALC); + } else if (id == R.id.newDrawFAB) { + loadNewDocument(DocumentType.DRAW); + } + } +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/ui/PageView.java b/android/source/src/java/org/libreoffice/ui/PageView.java new file mode 100644 index 0000000000..4c3f695622 --- /dev/null +++ b/android/source/src/java/org/libreoffice/ui/PageView.java @@ -0,0 +1,69 @@ +/* -*- tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ + +package org.libreoffice.ui; + +import org.libreoffice.R; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; + +public class PageView extends View{ + private Bitmap bmp; + private Paint mPaintBlack; + private static final String LOGTAG = "PageView"; + + public PageView(Context context ) { + super(context); + bmp = BitmapFactory.decodeResource(getResources(), R.drawable.dummy_page); + initialise(); + } + public PageView(Context context, AttributeSet attrs) { + super(context, attrs); + bmp = BitmapFactory.decodeResource(getResources(), R.drawable.dummy_page); + Log.d(LOGTAG, bmp.toString()); + initialise(); + } + public PageView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + bmp = BitmapFactory.decodeResource(getResources(), R.drawable.dummy_page);//load a "page" + initialise(); + } + + private void initialise(){ + mPaintBlack = new Paint(); + mPaintBlack.setARGB(255, 0, 0, 0); + Log.d(LOGTAG, " Doing some set-up"); + } + + public void setBitmap(Bitmap bmp){ + this.bmp = bmp; + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + Log.d(LOGTAG, "Draw"); + Log.d(LOGTAG, Integer.toString(bmp.getHeight())); + if( bmp != null ){ + int horizontalMargin = (int) (canvas.getWidth()*0.1); + //int verticalMargin = (int) (canvas.getHeight()*0.1); + int verticalMargin = horizontalMargin; + canvas.drawBitmap(bmp, new Rect(0, 0, bmp.getWidth(), bmp.getHeight()), + new Rect(horizontalMargin,verticalMargin,canvas.getWidth()-horizontalMargin, + canvas.getHeight()-verticalMargin), + mPaintBlack);// + } + if( bmp == null) + canvas.drawText(getContext().getString(R.string.bmp_null), 100, 100, new Paint()); + } + +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/libreoffice/ui/RecentFile.java b/android/source/src/java/org/libreoffice/ui/RecentFile.java new file mode 100644 index 0000000000..fdcc688aa1 --- /dev/null +++ b/android/source/src/java/org/libreoffice/ui/RecentFile.java @@ -0,0 +1,25 @@ +package org.libreoffice.ui; + +import android.net.Uri; + +/** + * An entry for a recently used file in the RecentFilesAdapter. + */ +public class RecentFile { + + private final Uri uri; + private final String displayName; + + public RecentFile(Uri docUri, String name) { + uri = docUri; + displayName = name; + } + + public Uri getUri() { + return uri; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/android/source/src/java/org/libreoffice/ui/RecentFilesAdapter.java b/android/source/src/java/org/libreoffice/ui/RecentFilesAdapter.java new file mode 100644 index 0000000000..ef00b9fb6c --- /dev/null +++ b/android/source/src/java/org/libreoffice/ui/RecentFilesAdapter.java @@ -0,0 +1,93 @@ +/* -*- tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package org.libreoffice.ui; + +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import org.libreoffice.R; + +import java.util.List; + +class RecentFilesAdapter extends RecyclerView.Adapter<RecentFilesAdapter.ViewHolder> { + + private final LibreOfficeUIActivity mActivity; + private final List<RecentFile> recentFiles; + + RecentFilesAdapter(LibreOfficeUIActivity activity, List<RecentFile> recentFiles) { + this.mActivity = activity; + this.recentFiles = recentFiles; + } + + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View item = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_recent_files, parent, false); + return new ViewHolder(item); + } + + @Override + public void onBindViewHolder(ViewHolder holder, int position) { + final RecentFile entry = recentFiles.get(position); + + holder.itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + mActivity.openDocument(entry.getUri()); + } + }); + + final String filename = entry.getDisplayName(); + holder.textView.setText(filename); + + int compoundDrawableInt = 0; + + switch (FileUtilities.getType(filename)) { + case FileUtilities.DOC: + compoundDrawableInt = R.drawable.writer; + break; + case FileUtilities.CALC: + compoundDrawableInt = R.drawable.calc; + break; + case FileUtilities.DRAWING: + compoundDrawableInt = R.drawable.draw; + break; + case FileUtilities.IMPRESS: + compoundDrawableInt = R.drawable.impress; + break; + } + + // set icon if known filetype was detected + if (compoundDrawableInt != 0) + holder.imageView.setImageDrawable(ContextCompat.getDrawable(mActivity, compoundDrawableInt)); + } + + @Override + public int getItemCount() { + return recentFiles.size(); + } + + class ViewHolder extends RecyclerView.ViewHolder { + + TextView textView; + ImageView imageView; + + ViewHolder(View itemView) { + super(itemView); + this.textView = itemView.findViewById(R.id.textView); + this.imageView = itemView.findViewById(R.id.imageView); + } + } +} diff --git a/android/source/src/java/org/mozilla/gecko/OnInterceptTouchListener.java b/android/source/src/java/org/mozilla/gecko/OnInterceptTouchListener.java new file mode 100644 index 0000000000..d0cd3d48a9 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/OnInterceptTouchListener.java @@ -0,0 +1,14 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import android.view.MotionEvent; +import android.view.View; + +public interface OnInterceptTouchListener extends View.OnTouchListener { + /** Override this method for a chance to consume events before the view or its children */ + public boolean onInterceptTouchEvent(View view, MotionEvent event); +} diff --git a/android/source/src/java/org/mozilla/gecko/OnSlideSwipeListener.java b/android/source/src/java/org/mozilla/gecko/OnSlideSwipeListener.java new file mode 100644 index 0000000000..29f50ebf49 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/OnSlideSwipeListener.java @@ -0,0 +1,94 @@ +/* -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* + * This file is part of the LibreOffice project. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package org.mozilla.gecko; + +import android.content.Context; +import android.view.GestureDetector; +import android.view.GestureDetector.SimpleOnGestureListener; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnTouchListener; +import android.util.Log; + +import org.libreoffice.LOKitShell; +import org.mozilla.gecko.gfx.GeckoLayerClient; +import org.mozilla.gecko.gfx.ImmutableViewportMetrics; + + +public class OnSlideSwipeListener implements OnTouchListener { + private static String LOGTAG = OnSlideSwipeListener.class.getName(); + + private final GestureDetector mGestureDetector; + private GeckoLayerClient mLayerClient; + + public OnSlideSwipeListener(Context ctx, GeckoLayerClient client){ + mGestureDetector = new GestureDetector(ctx, new GestureListener()); + mLayerClient = client; + } + + private final class GestureListener extends SimpleOnGestureListener { + + private static final int SWIPE_THRESHOLD = 100; + private static final int SWIPE_VELOCITY_THRESHOLD = 100; + + @Override + public boolean onDown(MotionEvent e) { + return false; + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velX, float velY) { + // Check if the page is already zoomed-in. + // Disable swiping gesture if that's the case. + ImmutableViewportMetrics viewportMetrics = mLayerClient.getViewportMetrics(); + if (viewportMetrics.viewportRectLeft > viewportMetrics.pageRectLeft || + viewportMetrics.viewportRectRight < viewportMetrics.pageRectRight) { + return false; + } + + // Otherwise, the page is smaller than viewport, perform swipe + // gesture. + try { + float diffY = e2.getY() - e1.getY(); + float diffX = e2.getX() - e1.getX(); + if (Math.abs(diffX) > Math.abs(diffY)) { + if (Math.abs(diffX) > SWIPE_THRESHOLD + && Math.abs(velX) > SWIPE_VELOCITY_THRESHOLD) { + if (diffX > 0) { + onSwipeRight(); + } else { + onSwipeLeft(); + } + } + } + } catch (Exception exception) { + exception.printStackTrace(); + } + return false; + } + } + + public void onSwipeRight() { + Log.d(LOGTAG, "onSwipeRight"); + LOKitShell.sendSwipeRightEvent(); + } + + public void onSwipeLeft() { + Log.d(LOGTAG, "onSwipeLeft"); + LOKitShell.sendSwipeLeftEvent(); + } + + @Override + public boolean onTouch(View v, MotionEvent me) { + return mGestureDetector.onTouchEvent(me); + } +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/mozilla/gecko/ZoomConstraints.java b/android/source/src/java/org/mozilla/gecko/ZoomConstraints.java new file mode 100644 index 0000000000..dbe2788272 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/ZoomConstraints.java @@ -0,0 +1,30 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +public final class ZoomConstraints { + private final float mDefaultZoom; + private final float mMinZoom; + private final float mMaxZoom; + + public ZoomConstraints(float defaultZoom, float minZoom, float maxZoom) { + mDefaultZoom = defaultZoom; + mMinZoom = minZoom; + mMaxZoom = maxZoom; + } + + public final float getDefaultZoom() { + return mDefaultZoom; + } + + public final float getMinZoom() { + return mMinZoom; + } + + public final float getMaxZoom() { + return mMaxZoom; + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/Axis.java b/android/source/src/java/org/mozilla/gecko/gfx/Axis.java new file mode 100644 index 0000000000..d4a7ac2ce5 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/Axis.java @@ -0,0 +1,337 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.util.Log; +import android.view.View; + +import org.mozilla.gecko.util.FloatUtils; + +import java.util.Map; + +/** + * This class represents the physics for one axis of movement (i.e. either + * horizontal or vertical). It tracks the different properties of movement + * like displacement, velocity, viewport dimensions, etc. pertaining to + * a particular axis. + */ +abstract class Axis { + private static final String LOGTAG = "GeckoAxis"; + + private static final String PREF_SCROLLING_FRICTION_SLOW = "ui.scrolling.friction_slow"; + private static final String PREF_SCROLLING_FRICTION_FAST = "ui.scrolling.friction_fast"; + private static final String PREF_SCROLLING_MAX_EVENT_ACCELERATION = "ui.scrolling.max_event_acceleration"; + private static final String PREF_SCROLLING_OVERSCROLL_DECEL_RATE = "ui.scrolling.overscroll_decel_rate"; + private static final String PREF_SCROLLING_OVERSCROLL_SNAP_LIMIT = "ui.scrolling.overscroll_snap_limit"; + private static final String PREF_SCROLLING_MIN_SCROLLABLE_DISTANCE = "ui.scrolling.min_scrollable_distance"; + + // This fraction of velocity remains after every animation frame when the velocity is low. + private static float FRICTION_SLOW; + // This fraction of velocity remains after every animation frame when the velocity is high. + private static float FRICTION_FAST; + // Below this velocity (in pixels per frame), the friction starts increasing from FRICTION_FAST + // to FRICTION_SLOW. + private static float VELOCITY_THRESHOLD; + // The maximum velocity change factor between events, per ms, in %. + // Direction changes are excluded. + private static float MAX_EVENT_ACCELERATION; + + // The rate of deceleration when the surface has overscrolled. + private static float OVERSCROLL_DECEL_RATE; + // The percentage of the surface which can be overscrolled before it must snap back. + private static float SNAP_LIMIT; + + // The minimum amount of space that must be present for an axis to be considered scrollable, + // in pixels. + private static float MIN_SCROLLABLE_DISTANCE; + + private static float getFloatPref(Map<String, Integer> prefs, String prefName, int defaultValue) { + Integer value = (prefs == null ? null : prefs.get(prefName)); + return (float)(value == null || value < 0 ? defaultValue : value) / 1000f; + } + + private static int getIntPref(Map<String, Integer> prefs, String prefName, int defaultValue) { + Integer value = (prefs == null ? null : prefs.get(prefName)); + return (value == null || value < 0 ? defaultValue : value); + } + + static final float MS_PER_FRAME = 4.0f; + private static final float FRAMERATE_MULTIPLIER = (1000f/60f) / MS_PER_FRAME; + + // The values we use for friction are based on a 16.6ms frame, adjust them to MS_PER_FRAME: + // FRICTION^1 = FRICTION_ADJUSTED^(16/MS_PER_FRAME) + // FRICTION_ADJUSTED = e ^ ((ln(FRICTION))/FRAMERATE_MULTIPLIER) + static float getFrameAdjustedFriction(float baseFriction) { + return (float)Math.pow(Math.E, (Math.log(baseFriction) / FRAMERATE_MULTIPLIER)); + } + + static void setPrefs(Map<String, Integer> prefs) { + FRICTION_SLOW = getFrameAdjustedFriction(getFloatPref(prefs, PREF_SCROLLING_FRICTION_SLOW, 850)); + FRICTION_FAST = getFrameAdjustedFriction(getFloatPref(prefs, PREF_SCROLLING_FRICTION_FAST, 970)); + VELOCITY_THRESHOLD = 10 / FRAMERATE_MULTIPLIER; + MAX_EVENT_ACCELERATION = getFloatPref(prefs, PREF_SCROLLING_MAX_EVENT_ACCELERATION, 12); + OVERSCROLL_DECEL_RATE = getFrameAdjustedFriction(getFloatPref(prefs, PREF_SCROLLING_OVERSCROLL_DECEL_RATE, 40)); + SNAP_LIMIT = getFloatPref(prefs, PREF_SCROLLING_OVERSCROLL_SNAP_LIMIT, 300); + MIN_SCROLLABLE_DISTANCE = getFloatPref(prefs, PREF_SCROLLING_MIN_SCROLLABLE_DISTANCE, 500); + Log.i(LOGTAG, "Prefs: " + FRICTION_SLOW + "," + FRICTION_FAST + "," + VELOCITY_THRESHOLD + "," + + MAX_EVENT_ACCELERATION + "," + OVERSCROLL_DECEL_RATE + "," + SNAP_LIMIT + "," + MIN_SCROLLABLE_DISTANCE); + } + + static { + // set the scrolling parameters to default values on startup + setPrefs(null); + } + + private enum FlingStates { + STOPPED, + PANNING, + FLINGING, + } + + private enum Overscroll { + NONE, + MINUS, // Overscrolled in the negative direction + PLUS, // Overscrolled in the positive direction + BOTH, // Overscrolled in both directions (page is zoomed to smaller than screen) + } + + private final SubdocumentScrollHelper mSubscroller; + + private int mOverscrollMode; /* Default to only overscrolling if we're allowed to scroll in a direction */ + private float mFirstTouchPos; /* Position of the first touch event on the current drag. */ + private float mTouchPos; /* Position of the most recent touch event on the current drag. */ + private float mLastTouchPos; /* Position of the touch event before touchPos. */ + private float mVelocity; /* Velocity in this direction; pixels per animation frame. */ + private boolean mScrollingDisabled; /* Whether movement on this axis is locked. */ + private boolean mDisableSnap; /* Whether overscroll snapping is disabled. */ + private float mDisplacement; + + private FlingStates mFlingState; /* The fling state we're in on this axis. */ + + protected abstract float getOrigin(); + protected abstract float getViewportLength(); + protected abstract float getPageStart(); + protected abstract float getPageLength(); + + Axis(SubdocumentScrollHelper subscroller) { + mSubscroller = subscroller; + mOverscrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS; + } + + public void setOverScrollMode(int overscrollMode) { + mOverscrollMode = overscrollMode; + } + + public int getOverScrollMode() { + return mOverscrollMode; + } + + private float getViewportEnd() { + return getOrigin() + getViewportLength(); + } + + private float getPageEnd() { + return getPageStart() + getPageLength(); + } + + void startTouch(float pos) { + mVelocity = 0.0f; + mScrollingDisabled = false; + mFirstTouchPos = mTouchPos = mLastTouchPos = pos; + } + + float panDistance(float currentPos) { + return currentPos - mFirstTouchPos; + } + + void setScrollingDisabled(boolean disabled) { + mScrollingDisabled = disabled; + } + + void saveTouchPos() { + mLastTouchPos = mTouchPos; + } + + void updateWithTouchAt(float pos, float timeDelta) { + float newVelocity = (mTouchPos - pos) / timeDelta * MS_PER_FRAME; + + // If there's a direction change, or current velocity is very low, + // allow setting of the velocity outright. Otherwise, use the current + // velocity and a maximum change factor to set the new velocity. + boolean curVelocityIsLow = Math.abs(mVelocity) < 1.0f / FRAMERATE_MULTIPLIER; + boolean directionChange = (mVelocity > 0) != (newVelocity > 0); + if (curVelocityIsLow || (directionChange && !FloatUtils.fuzzyEquals(newVelocity, 0.0f))) { + mVelocity = newVelocity; + } else { + float maxChange = Math.abs(mVelocity * timeDelta * MAX_EVENT_ACCELERATION); + mVelocity = Math.min(mVelocity + maxChange, Math.max(mVelocity - maxChange, newVelocity)); + } + + mTouchPos = pos; + } + + boolean overscrolled() { + return getOverscroll() != Overscroll.NONE; + } + + private Overscroll getOverscroll() { + boolean minus = (getOrigin() < getPageStart()); + boolean plus = (getViewportEnd() > getPageEnd()); + if (minus && plus) { + return Overscroll.BOTH; + } else if (minus) { + return Overscroll.MINUS; + } else if (plus) { + return Overscroll.PLUS; + } else { + return Overscroll.NONE; + } + } + + // Returns the amount that the page has been overscrolled. If the page hasn't been + // overscrolled on this axis, returns 0. + private float getExcess() { + switch (getOverscroll()) { + case MINUS: return getPageStart() - getOrigin(); + case PLUS: return getViewportEnd() - getPageEnd(); + case BOTH: return (getViewportEnd() - getPageEnd()) + (getPageStart() - getOrigin()); + default: return 0.0f; + } + } + + /* + * Returns true if the page is zoomed in to some degree along this axis such that scrolling is + * possible and this axis has not been scroll locked while panning. Otherwise, returns false. + */ + boolean scrollable() { + // If we're scrolling a subdocument, ignore the viewport length restrictions (since those + // apply to the top-level document) and only take into account axis locking. + if (mSubscroller.scrolling()) { + return !mScrollingDisabled; + } + + // if we are axis locked, return false + if (mScrollingDisabled) { + return false; + } + + // there is scrollable space, and we're not disabled, or the document fits the viewport + // but we always allow overscroll anyway + return getViewportLength() <= getPageLength() - MIN_SCROLLABLE_DISTANCE || + getOverScrollMode() == View.OVER_SCROLL_ALWAYS; + } + + /* + * Returns the resistance, as a multiplier, that should be taken into account when + * tracking or pinching. + */ + float getEdgeResistance(boolean forPinching) { + float excess = getExcess(); + if (excess > 0.0f && (getOverscroll() == Overscroll.BOTH || !forPinching)) { + // excess can be greater than viewport length, but the resistance + // must never drop below 0.0 + return Math.max(0.0f, SNAP_LIMIT - excess / getViewportLength()); + } + return 1.0f; + } + + /* Returns the velocity. If the axis is locked, returns 0. */ + float getRealVelocity() { + return scrollable() ? mVelocity : 0f; + } + + void startPan() { + mFlingState = FlingStates.PANNING; + } + + void startFling(boolean stopped) { + mDisableSnap = mSubscroller.scrolling(); + + if (stopped) { + mFlingState = FlingStates.STOPPED; + } else { + mFlingState = FlingStates.FLINGING; + } + } + + /* Advances a fling animation by one step. */ + boolean advanceFling() { + if (mFlingState != FlingStates.FLINGING) { + return false; + } + if (mSubscroller.scrolling() && !mSubscroller.lastScrollSucceeded()) { + // if the subdocument stopped scrolling, it's because it reached the end + // of the subdocument. we don't do overscroll on subdocuments, so there's + // no point in continuing this fling. + return false; + } + + float excess = getExcess(); + Overscroll overscroll = getOverscroll(); + boolean decreasingOverscroll = false; + if ((overscroll == Overscroll.MINUS && mVelocity > 0) || + (overscroll == Overscroll.PLUS && mVelocity < 0)) + { + decreasingOverscroll = true; + } + + if (mDisableSnap || FloatUtils.fuzzyEquals(excess, 0.0f) || decreasingOverscroll) { + // If we aren't overscrolled, just apply friction. + if (Math.abs(mVelocity) >= VELOCITY_THRESHOLD) { + mVelocity *= FRICTION_FAST; + } else { + float t = mVelocity / VELOCITY_THRESHOLD; + mVelocity *= FloatUtils.interpolate(FRICTION_SLOW, FRICTION_FAST, t); + } + } else { + // Otherwise, decrease the velocity linearly. + float elasticity = 1.0f - excess / (getViewportLength() * SNAP_LIMIT); + if (overscroll == Overscroll.MINUS) { + mVelocity = Math.min((mVelocity + OVERSCROLL_DECEL_RATE) * elasticity, 0.0f); + } else { // must be Overscroll.PLUS + mVelocity = Math.max((mVelocity - OVERSCROLL_DECEL_RATE) * elasticity, 0.0f); + } + } + + return true; + } + + void stopFling() { + mVelocity = 0.0f; + mFlingState = FlingStates.STOPPED; + } + + // Performs displacement of the viewport position according to the current velocity. + void displace() { + // if this isn't scrollable just return + if (!scrollable()) + return; + + if (mFlingState == FlingStates.PANNING) + mDisplacement += (mLastTouchPos - mTouchPos) * getEdgeResistance(false); + else + mDisplacement += mVelocity; + + // if overscroll is disabled and we're trying to overscroll, reset the displacement + // to remove any excess. Using getExcess alone isn't enough here since it relies on + // getOverscroll which doesn't take into account any new displacement being applied + if (getOverScrollMode() == View.OVER_SCROLL_NEVER) { + if (mDisplacement + getOrigin() < getPageStart()) { + mDisplacement = getPageStart() - getOrigin(); + stopFling(); + } else if (mDisplacement + getViewportEnd() > getPageEnd()) { + mDisplacement = getPageEnd() - getViewportEnd(); + stopFling(); + } + } + } + + float resetDisplacement() { + float d = mDisplacement; + mDisplacement = 0.0f; + return d; + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/BufferedCairoImage.java b/android/source/src/java/org/mozilla/gecko/gfx/BufferedCairoImage.java new file mode 100644 index 0000000000..a616fcc4da --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/BufferedCairoImage.java @@ -0,0 +1,83 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + + +import android.graphics.Bitmap; +import android.util.Log; + +import org.libreoffice.kit.DirectBufferAllocator; + +import java.nio.ByteBuffer; + +/** + * A Cairo image that simply saves a buffer of pixel data. + */ +public class BufferedCairoImage extends CairoImage { + private static String LOGTAG = "GeckoBufferedCairoImage"; + private ByteBuffer mBuffer; + private IntSize mSize; + private int mFormat; + + /** + * Creates a buffered Cairo image from a byte buffer. + */ + public BufferedCairoImage(ByteBuffer inBuffer, int inWidth, int inHeight, int inFormat) { + setBuffer(inBuffer, inWidth, inHeight, inFormat); + } + + /** + * Creates a buffered Cairo image from an Android bitmap. + */ + public BufferedCairoImage(Bitmap bitmap) { + setBitmap(bitmap); + } + + private synchronized void freeBuffer() { + mBuffer = DirectBufferAllocator.free(mBuffer); + } + + @Override + public void destroy() { + try { + freeBuffer(); + } catch (Exception ex) { + Log.e(LOGTAG, "error clearing buffer: ", ex); + } + } + + @Override + public ByteBuffer getBuffer() { + return mBuffer; + } + + @Override + public IntSize getSize() { + return mSize; + } + + @Override + public int getFormat() { + return mFormat; + } + + + public void setBuffer(ByteBuffer buffer, int width, int height, int format) { + freeBuffer(); + mBuffer = buffer; + mSize = new IntSize(width, height); + mFormat = format; + } + + public void setBitmap(Bitmap bitmap) { + mFormat = CairoUtils.bitmapConfigToCairoFormat(bitmap.getConfig()); + mSize = new IntSize(bitmap.getWidth(), bitmap.getHeight()); + + int bpp = CairoUtils.bitsPerPixelForCairoFormat(mFormat) / 8; + mBuffer = DirectBufferAllocator.allocate(mSize.getArea() * bpp); + bitmap.copyPixelsToBuffer(mBuffer.asIntBuffer()); + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/CairoGLInfo.java b/android/source/src/java/org/mozilla/gecko/gfx/CairoGLInfo.java new file mode 100644 index 0000000000..078aa41bae --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/CairoGLInfo.java @@ -0,0 +1,35 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import javax.microedition.khronos.opengles.GL10; + +/** Information needed to render Cairo bitmaps using OpenGL ES. */ +public class CairoGLInfo { + public final int internalFormat; + public final int format; + public final int type; + + public CairoGLInfo(int cairoFormat) { + switch (cairoFormat) { + case CairoImage.FORMAT_ARGB32: + internalFormat = format = GL10.GL_RGBA; type = GL10.GL_UNSIGNED_BYTE; + break; + case CairoImage.FORMAT_RGB24: + internalFormat = format = GL10.GL_RGB; type = GL10.GL_UNSIGNED_BYTE; + break; + case CairoImage.FORMAT_RGB16_565: + internalFormat = format = GL10.GL_RGB; type = GL10.GL_UNSIGNED_SHORT_5_6_5; + break; + case CairoImage.FORMAT_A8: + case CairoImage.FORMAT_A1: + throw new RuntimeException("Cairo FORMAT_A1 and FORMAT_A8 unsupported"); + default: + throw new RuntimeException("Unknown Cairo format"); + } + } +} + diff --git a/android/source/src/java/org/mozilla/gecko/gfx/CairoImage.java b/android/source/src/java/org/mozilla/gecko/gfx/CairoImage.java new file mode 100644 index 0000000000..5a18a4bb19 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/CairoImage.java @@ -0,0 +1,28 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import java.nio.ByteBuffer; + +/* + * A bitmap with pixel data in one of the formats that Cairo understands. + */ +public abstract class CairoImage { + public abstract ByteBuffer getBuffer(); + + public abstract void destroy(); + + public abstract IntSize getSize(); + public abstract int getFormat(); + + public static final int FORMAT_INVALID = -1; + public static final int FORMAT_ARGB32 = 0; + public static final int FORMAT_RGB24 = 1; + public static final int FORMAT_A8 = 2; + public static final int FORMAT_A1 = 3; + public static final int FORMAT_RGB16_565 = 4; +} + diff --git a/android/source/src/java/org/mozilla/gecko/gfx/CairoUtils.java b/android/source/src/java/org/mozilla/gecko/gfx/CairoUtils.java new file mode 100644 index 0000000000..e0db6530d5 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/CairoUtils.java @@ -0,0 +1,51 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.Bitmap; + +/** + * Utility methods useful when displaying Cairo bitmaps using OpenGL ES. + */ +public class CairoUtils { + private CairoUtils() { /* Don't call me. */ } + + public static int bitsPerPixelForCairoFormat(int cairoFormat) { + switch (cairoFormat) { + case CairoImage.FORMAT_A1: return 1; + case CairoImage.FORMAT_A8: return 8; + case CairoImage.FORMAT_RGB16_565: return 16; + case CairoImage.FORMAT_RGB24: return 24; + case CairoImage.FORMAT_ARGB32: return 32; + default: + throw new RuntimeException("Unknown Cairo format"); + } + } + + public static int bitmapConfigToCairoFormat(Bitmap.Config config) { + if (config == null) + return CairoImage.FORMAT_ARGB32; /* Droid Pro fix. */ + + switch (config) { + case ALPHA_8: return CairoImage.FORMAT_A8; + case ARGB_4444: throw new RuntimeException("ARGB_444 unsupported"); + case ARGB_8888: return CairoImage.FORMAT_ARGB32; + case RGB_565: return CairoImage.FORMAT_RGB16_565; + default: throw new RuntimeException("Unknown Skia bitmap config"); + } + } + + public static Bitmap.Config cairoFormatTobitmapConfig(int format) { + switch (format) { + case CairoImage.FORMAT_A8: return Bitmap.Config.ALPHA_8; + case CairoImage.FORMAT_ARGB32: return Bitmap.Config.ARGB_8888; + case CairoImage.FORMAT_RGB16_565: return Bitmap.Config.RGB_565; + default: + throw new RuntimeException("Unknown CairoImage format"); + } + } +} + diff --git a/android/source/src/java/org/mozilla/gecko/gfx/ComposedTileLayer.java b/android/source/src/java/org/mozilla/gecko/gfx/ComposedTileLayer.java new file mode 100644 index 0000000000..bdef702218 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/ComposedTileLayer.java @@ -0,0 +1,290 @@ +package org.mozilla.gecko.gfx; + +import android.content.ComponentCallbacks2; +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.RectF; +import android.graphics.Region; +import android.util.Log; + +import org.libreoffice.LOKitShell; +import org.libreoffice.TileIdentifier; +import org.mozilla.gecko.util.FloatUtils; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +public abstract class ComposedTileLayer extends Layer implements ComponentCallbacks2 { + private static final String LOGTAG = ComposedTileLayer.class.getSimpleName(); + + protected final List<SubTile> tiles = new ArrayList<SubTile>(); + + protected final IntSize tileSize; + private final ReadWriteLock tilesReadWriteLock = new ReentrantReadWriteLock(); + private final Lock tilesReadLock = tilesReadWriteLock.readLock(); + private final Lock tilesWriteLock = tilesReadWriteLock.writeLock(); + + protected RectF currentViewport = new RectF(); + protected float currentZoom = 1.0f; + protected RectF currentPageRect = new RectF(); + + private long reevaluationNanoTime = 0; + + public ComposedTileLayer(Context context) { + context.registerComponentCallbacks(this); + this.tileSize = new IntSize(256, 256); + } + + protected static RectF roundToTileSize(RectF input, IntSize tileSize) { + float minX = ((int) (input.left / tileSize.width)) * tileSize.width; + float minY = ((int) (input.top / tileSize.height)) * tileSize.height; + float maxX = ((int) (input.right / tileSize.width) + 1) * tileSize.width; + float maxY = ((int) (input.bottom / tileSize.height) + 1) * tileSize.height; + return new RectF(minX, minY, maxX, maxY); + } + + protected static RectF inflate(RectF rect, IntSize inflateSize) { + RectF newRect = new RectF(rect); + newRect.left -= inflateSize.width; + newRect.left = newRect.left < 0.0f ? 0.0f : newRect.left; + + newRect.top -= inflateSize.height; + newRect.top = newRect.top < 0.0f ? 0.0f : newRect.top; + + newRect.right += inflateSize.width; + newRect.bottom += inflateSize.height; + + return newRect; + } + + protected static RectF normalizeRect(RectF rect, float sourceFactor, float targetFactor) { + return new RectF( + (rect.left / sourceFactor) * targetFactor, + (rect.top / sourceFactor) * targetFactor, + (rect.right / sourceFactor) * targetFactor, + (rect.bottom / sourceFactor) * targetFactor); + } + + public void invalidate() { + tilesReadLock.lock(); + for (SubTile tile : tiles) { + tile.invalidate(); + } + tilesReadLock.unlock(); + } + + @Override + public void beginTransaction() { + super.beginTransaction(); + tilesReadLock.lock(); + for (SubTile tile : tiles) { + tile.beginTransaction(); + } + tilesReadLock.unlock(); + } + + @Override + public void endTransaction() { + tilesReadLock.lock(); + for (SubTile tile : tiles) { + tile.endTransaction(); + } + tilesReadLock.unlock(); + super.endTransaction(); + } + + @Override + public void draw(RenderContext context) { + tilesReadLock.lock(); + for (SubTile tile : tiles) { + if (RectF.intersects(tile.getBounds(context), context.viewport)) { + tile.draw(context); + } + } + tilesReadLock.unlock(); + } + + @Override + protected void performUpdates(RenderContext context) { + super.performUpdates(context); + + tilesReadLock.lock(); + for (SubTile tile : tiles) { + tile.beginTransaction(); + tile.refreshTileMetrics(); + tile.endTransaction(); + tile.performUpdates(context); + } + tilesReadLock.unlock(); + } + + @Override + public Region getValidRegion(RenderContext context) { + Region validRegion = new Region(); + tilesReadLock.lock(); + for (SubTile tile : tiles) { + validRegion.op(tile.getValidRegion(context), Region.Op.UNION); + } + tilesReadLock.unlock(); + return validRegion; + } + + @Override + public void setResolution(float newResolution) { + super.setResolution(newResolution); + tilesReadLock.lock(); + for (SubTile tile : tiles) { + tile.setResolution(newResolution); + } + tilesReadLock.unlock(); + } + + public void reevaluateTiles(ImmutableViewportMetrics viewportMetrics, DisplayPortMetrics mDisplayPort) { + RectF newViewPort = getViewPort(viewportMetrics); + float newZoom = getZoom(viewportMetrics); + + // When + if (newZoom <= 0.0 || Float.isNaN(newZoom)) { + return; + } + + if (currentViewport.equals(newViewPort) && FloatUtils.fuzzyEquals(currentZoom, newZoom)) { + return; + } + + long currentReevaluationNanoTime = System.nanoTime(); + if ((currentReevaluationNanoTime - reevaluationNanoTime) < 25 * 1000000) { + return; + } + + reevaluationNanoTime = currentReevaluationNanoTime; + + currentViewport = newViewPort; + currentZoom = newZoom; + currentPageRect = viewportMetrics.getPageRect(); + + LOKitShell.sendTileReevaluationRequest(this); + } + + protected abstract RectF getViewPort(ImmutableViewportMetrics viewportMetrics); + + protected abstract float getZoom(ImmutableViewportMetrics viewportMetrics); + + protected abstract int getTilePriority(); + + private boolean containsTilesMatching(float x, float y, float currentZoom) { + tilesReadLock.lock(); + try { + for (SubTile tile : tiles) { + if (tile.id.x == x && tile.id.y == y && tile.id.zoom == currentZoom) { + return true; + } + } + return false; + } finally { + tilesReadLock.unlock(); + } + } + + public void addNewTiles(List<SubTile> newTiles) { + for (float y = currentViewport.top; y < currentViewport.bottom; y += tileSize.height) { + if (y > currentPageRect.height()) { + continue; + } + for (float x = currentViewport.left; x < currentViewport.right; x += tileSize.width) { + if (x > currentPageRect.width()) { + continue; + } + if (!containsTilesMatching(x, y, currentZoom)) { + TileIdentifier tileId = new TileIdentifier((int) x, (int) y, currentZoom, tileSize); + SubTile tile = createNewTile(tileId); + newTiles.add(tile); + } + } + } + } + + public void clearMarkedTiles() { + tilesWriteLock.lock(); + Iterator<SubTile> iterator = tiles.iterator(); + while (iterator.hasNext()) { + SubTile tile = iterator.next(); + if (tile.markedForRemoval) { + tile.destroy(); + iterator.remove(); + } + } + tilesWriteLock.unlock(); + } + + public void markTiles() { + tilesReadLock.lock(); + for (SubTile tile : tiles) { + if (FloatUtils.fuzzyEquals(tile.id.zoom, currentZoom)) { + RectF tileRect = tile.id.getRectF(); + if (!RectF.intersects(currentViewport, tileRect)) { + tile.markForRemoval(); + } + } else { + tile.markForRemoval(); + } + } + tilesReadLock.unlock(); + } + + public void clearAndReset() { + tilesWriteLock.lock(); + tiles.clear(); + tilesWriteLock.unlock(); + currentViewport = new RectF(); + } + + private SubTile createNewTile(TileIdentifier tileId) { + SubTile tile = new SubTile(tileId); + tile.beginTransaction(); + tilesWriteLock.lock(); + tiles.add(tile); + tilesWriteLock.unlock(); + return tile; + } + + public boolean isStillValid(TileIdentifier tileId) { + return RectF.intersects(currentViewport, tileId.getRectF()) || currentViewport.contains(tileId.getRectF()); + } + + /** + * Invalidate tiles which intersect the input rect + */ + public void invalidateTiles(List<SubTile> tilesToInvalidate, RectF cssRect) { + RectF zoomedRect = RectUtils.scale(cssRect, currentZoom); + tilesReadLock.lock(); + for (SubTile tile : tiles) { + if (!tile.markedForRemoval && RectF.intersects(zoomedRect, tile.id.getRectF())) { + tilesToInvalidate.add(tile); + } + } + tilesReadLock.unlock(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + } + + @Override + public void onLowMemory() { + Log.i(LOGTAG, "onLowMemory"); + } + + @Override + public void onTrimMemory(int level) { + if (level >= 15 /*TRIM_MEMORY_RUNNING_CRITICAL*/) { + Log.i(LOGTAG, "Trimming memory - TRIM_MEMORY_RUNNING_CRITICAL"); + } else if (level >= 10 /*TRIM_MEMORY_RUNNING_LOW*/) { + Log.i(LOGTAG, "Trimming memory - TRIM_MEMORY_RUNNING_LOW"); + } + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/DisplayPortCalculator.java b/android/source/src/java/org/mozilla/gecko/gfx/DisplayPortCalculator.java new file mode 100644 index 0000000000..d98efa2d50 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/DisplayPortCalculator.java @@ -0,0 +1,760 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.PointF; +import android.graphics.RectF; +import android.util.Log; + +import org.json.JSONArray; +import org.libreoffice.LOKitShell; +import org.libreoffice.LibreOfficeMainActivity; +import org.mozilla.gecko.util.FloatUtils; + +import java.util.Map; + +final class DisplayPortCalculator { + private static final String LOGTAG = DisplayPortCalculator.class.getSimpleName(); + private static final PointF ZERO_VELOCITY = new PointF(0, 0); + + // Keep this in sync with the TILEDLAYERBUFFER_TILE_SIZE defined in gfx/layers/TiledLayerBuffer.h + private static final int TILE_SIZE = 256; + + private static final String PREF_DISPLAYPORT_STRATEGY = "gfx.displayport.strategy"; + private static final String PREF_DISPLAYPORT_FM_MULTIPLIER = "gfx.displayport.strategy_fm.multiplier"; + private static final String PREF_DISPLAYPORT_FM_DANGER_X = "gfx.displayport.strategy_fm.danger_x"; + private static final String PREF_DISPLAYPORT_FM_DANGER_Y = "gfx.displayport.strategy_fm.danger_y"; + private static final String PREF_DISPLAYPORT_VB_MULTIPLIER = "gfx.displayport.strategy_vb.multiplier"; + private static final String PREF_DISPLAYPORT_VB_VELOCITY_THRESHOLD = "gfx.displayport.strategy_vb.threshold"; + private static final String PREF_DISPLAYPORT_VB_REVERSE_BUFFER = "gfx.displayport.strategy_vb.reverse_buffer"; + private static final String PREF_DISPLAYPORT_VB_DANGER_X_BASE = "gfx.displayport.strategy_vb.danger_x_base"; + private static final String PREF_DISPLAYPORT_VB_DANGER_Y_BASE = "gfx.displayport.strategy_vb.danger_y_base"; + private static final String PREF_DISPLAYPORT_VB_DANGER_X_INCR = "gfx.displayport.strategy_vb.danger_x_incr"; + private static final String PREF_DISPLAYPORT_VB_DANGER_Y_INCR = "gfx.displayport.strategy_vb.danger_y_incr"; + private static final String PREF_DISPLAYPORT_PB_VELOCITY_THRESHOLD = "gfx.displayport.strategy_pb.threshold"; + + private DisplayPortStrategy sStrategy; + private final LibreOfficeMainActivity mMainActivity; + + DisplayPortCalculator(LibreOfficeMainActivity context) { + this.mMainActivity = context; + sStrategy = new VelocityBiasStrategy(mMainActivity, null); + } + + DisplayPortMetrics calculate(ImmutableViewportMetrics metrics, PointF velocity) { + return sStrategy.calculate(metrics, (velocity == null ? ZERO_VELOCITY : velocity)); + } + + boolean aboutToCheckerboard(ImmutableViewportMetrics metrics, PointF velocity, DisplayPortMetrics displayPort) { + if (displayPort == null) { + return true; + } + return sStrategy.aboutToCheckerboard(metrics, (velocity == null ? ZERO_VELOCITY : velocity), displayPort); + } + + boolean drawTimeUpdate(long millis, int pixels) { + return sStrategy.drawTimeUpdate(millis, pixels); + } + + void resetPageState() { + sStrategy.resetPageState(); + } + + static void addPrefNames(JSONArray prefs) { + prefs.put(PREF_DISPLAYPORT_STRATEGY); + prefs.put(PREF_DISPLAYPORT_FM_MULTIPLIER); + prefs.put(PREF_DISPLAYPORT_FM_DANGER_X); + prefs.put(PREF_DISPLAYPORT_FM_DANGER_Y); + prefs.put(PREF_DISPLAYPORT_VB_MULTIPLIER); + prefs.put(PREF_DISPLAYPORT_VB_VELOCITY_THRESHOLD); + prefs.put(PREF_DISPLAYPORT_VB_REVERSE_BUFFER); + prefs.put(PREF_DISPLAYPORT_VB_DANGER_X_BASE); + prefs.put(PREF_DISPLAYPORT_VB_DANGER_Y_BASE); + prefs.put(PREF_DISPLAYPORT_VB_DANGER_X_INCR); + prefs.put(PREF_DISPLAYPORT_VB_DANGER_Y_INCR); + prefs.put(PREF_DISPLAYPORT_PB_VELOCITY_THRESHOLD); + } + + /** + * Set the active strategy to use. + * See the gfx.displayport.strategy pref in mobile/android/app/mobile.js to see the + * mapping between ints and strategies. + */ + boolean setStrategy(Map<String, Integer> prefs) { + Integer strategy = prefs.get(PREF_DISPLAYPORT_STRATEGY); + if (strategy == null) { + return false; + } + + switch (strategy) { + case 0: + sStrategy = new FixedMarginStrategy(prefs); + break; + case 1: + sStrategy = new VelocityBiasStrategy(mMainActivity, prefs); + break; + case 2: + sStrategy = new DynamicResolutionStrategy(mMainActivity, prefs); + break; + case 3: + sStrategy = new NoMarginStrategy(prefs); + break; + case 4: + sStrategy = new PredictionBiasStrategy(mMainActivity, prefs); + break; + default: + Log.e(LOGTAG, "Invalid strategy index specified"); + return false; + } + Log.i(LOGTAG, "Set strategy " + sStrategy.toString()); + return true; + } + + private static float getFloatPref(Map<String, Integer> prefs, String prefName, int defaultValue) { + Integer value = (prefs == null ? null : prefs.get(prefName)); + return (float)(value == null || value < 0 ? defaultValue : value) / 1000f; + } + + private static abstract class DisplayPortStrategy { + /** Calculates a displayport given a viewport and panning velocity. */ + public abstract DisplayPortMetrics calculate(ImmutableViewportMetrics metrics, PointF velocity); + /** Returns true if a checkerboard is about to be visible and we should not throttle drawing. */ + public abstract boolean aboutToCheckerboard(ImmutableViewportMetrics metrics, PointF velocity, DisplayPortMetrics displayPort); + /** Notify the strategy of a new recorded draw time. Return false to turn off draw time recording. */ + public boolean drawTimeUpdate(long millis, int pixels) { return false; } + /** Reset any page-specific state stored, as the page being displayed has changed. */ + public void resetPageState() {} + } + + /** + * Return the dimensions for a rect that has area (width*height) that does not exceed the page size in the + * given metrics object. The area in the returned FloatSize may be less than width*height if the page is + * small, but it will never be larger than width*height. + * Note that this process may change the relative aspect ratio of the given dimensions. + */ + private static FloatSize reshapeForPage(float width, float height, ImmutableViewportMetrics metrics) { + // figure out how much of the desired buffer amount we can actually use on the horizontal axis + float usableWidth = Math.min(width, metrics.getPageWidth()); + // if we reduced the buffer amount on the horizontal axis, we should take that saved memory and + // use it on the vertical axis + float extraUsableHeight = (float)Math.floor(((width - usableWidth) * height) / usableWidth); + float usableHeight = Math.min(height + extraUsableHeight, metrics.getPageHeight()); + if (usableHeight < height && usableWidth == width) { + // and the reverse - if we shrunk the buffer on the vertical axis we can add it to the horizontal + float extraUsableWidth = (float)Math.floor(((height - usableHeight) * width) / usableHeight); + usableWidth = Math.min(width + extraUsableWidth, metrics.getPageWidth()); + } + return new FloatSize(usableWidth, usableHeight); + } + + /** + * Expand the given rect in all directions by a "danger zone". The size of the danger zone on an axis + * is the size of the view on that axis multiplied by the given multiplier. The expanded rect is then + * clamped to page bounds and returned. + */ + private static RectF expandByDangerZone(RectF rect, float dangerZoneXMultiplier, float dangerZoneYMultiplier, ImmutableViewportMetrics metrics) { + // calculate the danger zone amounts in pixels + float dangerZoneX = metrics.getWidth() * dangerZoneXMultiplier; + float dangerZoneY = metrics.getHeight() * dangerZoneYMultiplier; + rect = RectUtils.expand(rect, dangerZoneX, dangerZoneY); + // clamp to page bounds + return clampToPageBounds(rect, metrics); + } + + /** + * Expand the given margins such that when they are applied on the viewport, the resulting rect + * does not have any partial tiles, except when it is clipped by the page bounds. This assumes + * the tiles are TILE_SIZE by TILE_SIZE and start at the origin, such that there will always be + * a tile at (0,0)-(TILE_SIZE,TILE_SIZE)). + */ + private static DisplayPortMetrics getTileAlignedDisplayPortMetrics(RectF margins, float zoom, ImmutableViewportMetrics metrics) { + float left = metrics.viewportRectLeft - margins.left; + float top = metrics.viewportRectTop - margins.top; + float right = metrics.viewportRectRight + margins.right; + float bottom = metrics.viewportRectBottom + margins.bottom; + left = (float) Math.max(metrics.pageRectLeft, TILE_SIZE * Math.floor(left / TILE_SIZE)); + top = (float) Math.max(metrics.pageRectTop, TILE_SIZE * Math.floor(top / TILE_SIZE)); + right = (float) Math.min(metrics.pageRectRight, TILE_SIZE * Math.ceil(right / TILE_SIZE)); + bottom = (float) Math.min(metrics.pageRectBottom, TILE_SIZE * Math.ceil(bottom / TILE_SIZE)); + return new DisplayPortMetrics(left, top, right, bottom, zoom); + } + + /** + * Adjust the given margins so if they are applied on the viewport in the metrics, the resulting rect + * does not exceed the page bounds. This code will maintain the total margin amount for a given axis; + * it assumes that margins.left + metrics.getWidth() + margins.right is less than or equal to + * metrics.getPageWidth(); and the same for the y axis. + */ + private static RectF shiftMarginsForPageBounds(RectF margins, ImmutableViewportMetrics metrics) { + // check how much we're overflowing in each direction. note that at most one of leftOverflow + // and rightOverflow can be greater than zero, and at most one of topOverflow and bottomOverflow + // can be greater than zero, because of the assumption described in the method javadoc. + float leftOverflow = metrics.pageRectLeft - (metrics.viewportRectLeft - margins.left); + float rightOverflow = (metrics.viewportRectRight + margins.right) - metrics.pageRectRight; + float topOverflow = metrics.pageRectTop - (metrics.viewportRectTop - margins.top); + float bottomOverflow = (metrics.viewportRectBottom + margins.bottom) - metrics.pageRectBottom; + + // if the margins overflow the page bounds, shift them to other side on the same axis + if (leftOverflow > 0) { + margins.left -= leftOverflow; + margins.right += leftOverflow; + } else if (rightOverflow > 0) { + margins.right -= rightOverflow; + margins.left += rightOverflow; + } + if (topOverflow > 0) { + margins.top -= topOverflow; + margins.bottom += topOverflow; + } else if (bottomOverflow > 0) { + margins.bottom -= bottomOverflow; + margins.top += bottomOverflow; + } + return margins; + } + + /** + * Clamp the given rect to the page bounds and return it. + */ + private static RectF clampToPageBounds(RectF rect, ImmutableViewportMetrics metrics) { + if (rect.top < metrics.pageRectTop) rect.top = metrics.pageRectTop; + if (rect.left < metrics.pageRectLeft) rect.left = metrics.pageRectLeft; + if (rect.right > metrics.pageRectRight) rect.right = metrics.pageRectRight; + if (rect.bottom > metrics.pageRectBottom) rect.bottom = metrics.pageRectBottom; + return rect; + } + + /** + * This class implements the variation where we basically don't bother with a display port. + */ + private static class NoMarginStrategy extends DisplayPortStrategy { + NoMarginStrategy(Map<String, Integer> prefs) { + // no prefs in this strategy + } + + public DisplayPortMetrics calculate(ImmutableViewportMetrics metrics, PointF velocity) { + return new DisplayPortMetrics(metrics.viewportRectLeft, + metrics.viewportRectTop, + metrics.viewportRectRight, + metrics.viewportRectBottom, + metrics.zoomFactor); + } + + public boolean aboutToCheckerboard(ImmutableViewportMetrics metrics, PointF velocity, DisplayPortMetrics displayPort) { + return true; + } + + @Override + public String toString() { + return "NoMarginStrategy"; + } + } + + /** + * This class implements the variation where we use a fixed-size margin on the display port. + * The margin is always 300 pixels in all directions, except when we are (a) approaching a page + * boundary, and/or (b) if we are limited by the page size. In these cases we try to maintain + * the area of the display port by (a) shifting the buffer to the other side on the same axis, + * and/or (b) increasing the buffer on the other axis to compensate for the reduced buffer on + * one axis. + */ + private static class FixedMarginStrategy extends DisplayPortStrategy { + // The length of each axis of the display port will be the corresponding view length + // multiplied by this factor. + private final float SIZE_MULTIPLIER; + + // If the visible rect is within the danger zone (measured as a fraction of the view size + // from the edge of the displayport) we start redrawing to minimize checkerboarding. + private final float DANGER_ZONE_X_MULTIPLIER; + private final float DANGER_ZONE_Y_MULTIPLIER; + + FixedMarginStrategy(Map<String, Integer> prefs) { + SIZE_MULTIPLIER = getFloatPref(prefs, PREF_DISPLAYPORT_FM_MULTIPLIER, 2000); + DANGER_ZONE_X_MULTIPLIER = getFloatPref(prefs, PREF_DISPLAYPORT_FM_DANGER_X, 100); + DANGER_ZONE_Y_MULTIPLIER = getFloatPref(prefs, PREF_DISPLAYPORT_FM_DANGER_Y, 200); + } + + public DisplayPortMetrics calculate(ImmutableViewportMetrics metrics, PointF velocity) { + float displayPortWidth = metrics.getWidth() * SIZE_MULTIPLIER; + float displayPortHeight = metrics.getHeight() * SIZE_MULTIPLIER; + + // we need to avoid having a display port that is larger than the page, or we will end up + // painting things outside the page bounds (bug 729169). we simultaneously need to make + // the display port as large as possible so that we redraw less. reshape the display + // port dimensions to accomplish this. + FloatSize usableSize = reshapeForPage(displayPortWidth, displayPortHeight, metrics); + float horizontalBuffer = usableSize.width - metrics.getWidth(); + float verticalBuffer = usableSize.height - metrics.getHeight(); + + // and now calculate the display port margins based on how much buffer we've decided to use and + // the page bounds, ensuring we use all of the available buffer amounts on one side or the other + // on any given axis. (i.e. if we're scrolled to the top of the page, the vertical buffer is + // entirely below the visible viewport, but if we're halfway down the page, the vertical buffer + // is split). + RectF margins = new RectF(); + margins.left = horizontalBuffer / 2.0f; + margins.right = horizontalBuffer - margins.left; + margins.top = verticalBuffer / 2.0f; + margins.bottom = verticalBuffer - margins.top; + margins = shiftMarginsForPageBounds(margins, metrics); + + return getTileAlignedDisplayPortMetrics(margins, metrics.zoomFactor, metrics); + } + + public boolean aboutToCheckerboard(ImmutableViewportMetrics metrics, PointF velocity, DisplayPortMetrics displayPort) { + // Increase the size of the viewport based on the danger zone multiplier (and clamp to page + // boundaries), and intersect it with the current displayport to determine whether we're + // close to checkerboarding. + RectF adjustedViewport = expandByDangerZone(metrics.getViewport(), DANGER_ZONE_X_MULTIPLIER, DANGER_ZONE_Y_MULTIPLIER, metrics); + return !displayPort.contains(adjustedViewport); + } + + @Override + public String toString() { + return "FixedMarginStrategy mult=" + SIZE_MULTIPLIER + ", dangerX=" + DANGER_ZONE_X_MULTIPLIER + ", dangerY=" + DANGER_ZONE_Y_MULTIPLIER; + } + } + + /** + * This class implements the variation with a small fixed-size margin with velocity bias. + * In this variation, the default margins are pretty small relative to the view size, but + * they are affected by the panning velocity. Specifically, if we are panning on one axis, + * we remove the margins on the other axis because we are likely axis-locked. Also once + * we are panning in one direction above a certain threshold velocity, we shift the buffer + * so that it is almost entirely in the direction of the pan, with a little bit in the + * reverse direction. + */ + private static class VelocityBiasStrategy extends DisplayPortStrategy { + // The length of each axis of the display port will be the corresponding view length + // multiplied by this factor. + private final float SIZE_MULTIPLIER; + // The velocity above which we apply the velocity bias + private final float VELOCITY_THRESHOLD; + // How much of the buffer to keep in the reverse direction of the velocity + private final float REVERSE_BUFFER; + // If the visible rect is within the danger zone we start redrawing to minimize + // checkerboarding. the danger zone amount is a linear function of the form: + // viewportsize * (base + velocity * incr) + // where base and incr are configurable values. + private final float DANGER_ZONE_BASE_X_MULTIPLIER; + private final float DANGER_ZONE_BASE_Y_MULTIPLIER; + private final float DANGER_ZONE_INCR_X_MULTIPLIER; + private final float DANGER_ZONE_INCR_Y_MULTIPLIER; + + VelocityBiasStrategy(LibreOfficeMainActivity context, Map<String, Integer> prefs) { + SIZE_MULTIPLIER = getFloatPref(prefs, PREF_DISPLAYPORT_VB_MULTIPLIER, 2000); + VELOCITY_THRESHOLD = LOKitShell.getDpi(context) * getFloatPref(prefs, PREF_DISPLAYPORT_VB_VELOCITY_THRESHOLD, 32); + REVERSE_BUFFER = getFloatPref(prefs, PREF_DISPLAYPORT_VB_REVERSE_BUFFER, 200); + DANGER_ZONE_BASE_X_MULTIPLIER = getFloatPref(prefs, PREF_DISPLAYPORT_VB_DANGER_X_BASE, 1000); + DANGER_ZONE_BASE_Y_MULTIPLIER = getFloatPref(prefs, PREF_DISPLAYPORT_VB_DANGER_Y_BASE, 1000); + DANGER_ZONE_INCR_X_MULTIPLIER = getFloatPref(prefs, PREF_DISPLAYPORT_VB_DANGER_X_INCR, 0); + DANGER_ZONE_INCR_Y_MULTIPLIER = getFloatPref(prefs, PREF_DISPLAYPORT_VB_DANGER_Y_INCR, 0); + } + + /** + * Split the given amounts into margins based on the VELOCITY_THRESHOLD and REVERSE_BUFFER values. + * If the velocity is above the VELOCITY_THRESHOLD on an axis, split the amount into REVERSE_BUFFER + * and 1.0 - REVERSE_BUFFER fractions. The REVERSE_BUFFER fraction is set as the margin in the + * direction opposite to the velocity, and the remaining fraction is set as the margin in the direction + * of the velocity. If the velocity is lower than VELOCITY_THRESHOLD, split the amount evenly into the + * two margins on that axis. + */ + private RectF velocityBiasedMargins(float xAmount, float yAmount, PointF velocity) { + RectF margins = new RectF(); + + if (velocity.x > VELOCITY_THRESHOLD) { + margins.left = xAmount * REVERSE_BUFFER; + } else if (velocity.x < -VELOCITY_THRESHOLD) { + margins.left = xAmount * (1.0f - REVERSE_BUFFER); + } else { + margins.left = xAmount / 2.0f; + } + margins.right = xAmount - margins.left; + + if (velocity.y > VELOCITY_THRESHOLD) { + margins.top = yAmount * REVERSE_BUFFER; + } else if (velocity.y < -VELOCITY_THRESHOLD) { + margins.top = yAmount * (1.0f - REVERSE_BUFFER); + } else { + margins.top = yAmount / 2.0f; + } + margins.bottom = yAmount - margins.top; + + return margins; + } + + public DisplayPortMetrics calculate(ImmutableViewportMetrics metrics, PointF velocity) { + float displayPortWidth = metrics.getWidth() * SIZE_MULTIPLIER; + float displayPortHeight = metrics.getHeight() * SIZE_MULTIPLIER; + + // but if we're panning on one axis, set the margins for the other axis to zero since we are likely + // axis locked and won't be displaying that extra area. + if (Math.abs(velocity.x) > VELOCITY_THRESHOLD && FloatUtils.fuzzyEquals(velocity.y, 0)) { + displayPortHeight = metrics.getHeight(); + } else if (Math.abs(velocity.y) > VELOCITY_THRESHOLD && FloatUtils.fuzzyEquals(velocity.x, 0)) { + displayPortWidth = metrics.getWidth(); + } + + // we need to avoid having a display port that is larger than the page, or we will end up + // painting things outside the page bounds (bug 729169). + displayPortWidth = Math.min(displayPortWidth, metrics.getPageWidth()); + displayPortHeight = Math.min(displayPortHeight, metrics.getPageHeight()); + float horizontalBuffer = displayPortWidth - metrics.getWidth(); + float verticalBuffer = displayPortHeight - metrics.getHeight(); + + // split the buffer amounts into margins based on velocity, and shift it to + // take into account the page bounds + RectF margins = velocityBiasedMargins(horizontalBuffer, verticalBuffer, velocity); + margins = shiftMarginsForPageBounds(margins, metrics); + + return getTileAlignedDisplayPortMetrics(margins, metrics.zoomFactor, metrics); + } + + public boolean aboutToCheckerboard(ImmutableViewportMetrics metrics, PointF velocity, DisplayPortMetrics displayPort) { + // calculate the danger zone amounts based on the prefs + float dangerZoneX = metrics.getWidth() * (DANGER_ZONE_BASE_X_MULTIPLIER + (velocity.x * DANGER_ZONE_INCR_X_MULTIPLIER)); + float dangerZoneY = metrics.getHeight() * (DANGER_ZONE_BASE_Y_MULTIPLIER + (velocity.y * DANGER_ZONE_INCR_Y_MULTIPLIER)); + // clamp it such that when added to the viewport, they don't exceed page size. + // this is a prerequisite to calling shiftMarginsForPageBounds as we do below. + dangerZoneX = Math.min(dangerZoneX, metrics.getPageWidth() - metrics.getWidth()); + dangerZoneY = Math.min(dangerZoneY, metrics.getPageHeight() - metrics.getHeight()); + + // split the danger zone into margins based on velocity, and ensure it doesn't exceed + // page bounds. + RectF dangerMargins = velocityBiasedMargins(dangerZoneX, dangerZoneY, velocity); + dangerMargins = shiftMarginsForPageBounds(dangerMargins, metrics); + + // we're about to checkerboard if the current viewport area + the danger zone margins + // fall out of the current displayport anywhere. + RectF adjustedViewport = new RectF( + metrics.viewportRectLeft - dangerMargins.left, + metrics.viewportRectTop - dangerMargins.top, + metrics.viewportRectRight + dangerMargins.right, + metrics.viewportRectBottom + dangerMargins.bottom); + return !displayPort.contains(adjustedViewport); + } + + @Override + public String toString() { + return "VelocityBiasStrategy mult=" + SIZE_MULTIPLIER + ", threshold=" + VELOCITY_THRESHOLD + ", reverse=" + REVERSE_BUFFER + + ", dangerBaseX=" + DANGER_ZONE_BASE_X_MULTIPLIER + ", dangerBaseY=" + DANGER_ZONE_BASE_Y_MULTIPLIER + + ", dangerIncrX=" + DANGER_ZONE_INCR_Y_MULTIPLIER + ", dangerIncrY=" + DANGER_ZONE_INCR_Y_MULTIPLIER; + } + } + + /** + * This class implements the variation where we draw more of the page at low resolution while panning. + * In this variation, as we pan faster, we increase the page area we are drawing, but reduce the draw + * resolution to compensate. This results in the same device-pixel area drawn; the compositor then + * scales this up to the viewport zoom level. This results in a large area of the page drawn but it + * looks blurry. The assumption is that drawing extra that we never display is better than checkerboarding, + * where we draw less but never even show it on the screen. + */ + private static class DynamicResolutionStrategy extends DisplayPortStrategy { + + // The velocity above which we start zooming out the display port to keep up + // with the panning. + private final float VELOCITY_EXPANSION_THRESHOLD; + + + DynamicResolutionStrategy(LibreOfficeMainActivity context, Map<String, Integer> prefs) { + // ignore prefs for now + VELOCITY_EXPANSION_THRESHOLD = LOKitShell.getDpi(context) / 16f; + VELOCITY_FAST_THRESHOLD = VELOCITY_EXPANSION_THRESHOLD * 2.0f; + } + + // The length of each axis of the display port will be the corresponding view length + // multiplied by this factor. + private static final float SIZE_MULTIPLIER = 1.5f; + + // How much we increase the display port based on velocity. Assuming no friction and + // splitting (see below), this should be the number of frames (@60fps) between us + // calculating the display port and the draw of the *next* display port getting composited + // and displayed on the screen. This is because the timeline looks like this: + // Java: pan pan pan pan pan pan ! pan pan pan pan pan pan ! + // Gecko: \-> draw -> composite / \-> draw -> composite / + // The display port calculated on the first "pan" gets composited to the screen at the + // first exclamation mark, and remains on the screen until the second exclamation mark. + // In order to avoid checkerboarding, that display port must be able to contain all of + // the panning until the second exclamation mark, which encompasses two entire draw/composite + // cycles. + // If we take into account friction, our velocity multiplier should be reduced as the + // amount of pan will decrease each time. If we take into account display port splitting, + // it should be increased as the splitting means some of the display port will be used to + // draw in the opposite direction of the velocity. For now I'm assuming these two cancel + // each other out. + private static final float VELOCITY_MULTIPLIER = 60.0f; + + // The following constants adjust how biased the display port is in the direction of panning. + // When panning fast (above the FAST_THRESHOLD) we use the fast split factor to split the + // display port "buffer" area, otherwise we use the slow split factor. This is based on the + // assumption that if the user is panning fast, they are less likely to reverse directions + // and go backwards, so we should spend more of our display port buffer in the direction of + // panning. + private final float VELOCITY_FAST_THRESHOLD; + private static final float FAST_SPLIT_FACTOR = 0.95f; + private static final float SLOW_SPLIT_FACTOR = 0.8f; + + // The following constants are used for viewport prediction; we use them to estimate where + // the viewport will be soon and whether or not we should trigger a draw right now. "soon" + // in the previous sentence really refers to the amount of time it would take to draw and + // composite from the point at which we do the calculation, and that is not really a known + // quantity. The velocity multiplier is how much we multiply the velocity by; it has the + // same caveats as the VELOCITY_MULTIPLIER above except that it only needs to take into account + // one draw/composite cycle instead of two. The danger zone multiplier is a multiplier of the + // viewport size that we use as an extra "danger zone" around the viewport; if this danger + // zone falls outside the display port then we are approaching the point at which we will + // checkerboard, and hence should start drawing. Note that if DANGER_ZONE_MULTIPLIER is + // greater than (SIZE_MULTIPLIER - 1.0f), then at zero velocity we will always be in the + // danger zone, and thus will be constantly drawing. + private static final float PREDICTION_VELOCITY_MULTIPLIER = 30.0f; + private static final float DANGER_ZONE_MULTIPLIER = 0.20f; // must be less than (SIZE_MULTIPLIER - 1.0f) + + public DisplayPortMetrics calculate(ImmutableViewportMetrics metrics, PointF velocity) { + float displayPortWidth = metrics.getWidth() * SIZE_MULTIPLIER; + float displayPortHeight = metrics.getHeight() * SIZE_MULTIPLIER; + + // for resolution calculation purposes, we need to know what the adjusted display port dimensions + // would be if we had zero velocity, so calculate that here before we increase the display port + // based on velocity. + FloatSize reshapedSize = reshapeForPage(displayPortWidth, displayPortHeight, metrics); + + // increase displayPortWidth and displayPortHeight based on the velocity, but maintaining their + // relative aspect ratio. + if (velocity.length() > VELOCITY_EXPANSION_THRESHOLD) { + float velocityFactor = Math.max(Math.abs(velocity.x) / displayPortWidth, + Math.abs(velocity.y) / displayPortHeight); + velocityFactor *= VELOCITY_MULTIPLIER; + + displayPortWidth += (displayPortWidth * velocityFactor); + displayPortHeight += (displayPortHeight * velocityFactor); + } + + // at this point, displayPortWidth and displayPortHeight are how much of the page (in device pixels) + // we want to be rendered by Gecko. Note here "device pixels" is equivalent to CSS pixels multiplied + // by metrics.zoomFactor + + // we need to avoid having a display port that is larger than the page, or we will end up + // painting things outside the page bounds (bug 729169). we simultaneously need to make + // the display port as large as possible so that we redraw less. reshape the display + // port dimensions to accomplish this. this may change the aspect ratio of the display port, + // but we are assuming that this is desirable because the advantages from pre-drawing will + // outweigh the disadvantages from any buffer reallocations that might occur. + FloatSize usableSize = reshapeForPage(displayPortWidth, displayPortHeight, metrics); + float horizontalBuffer = usableSize.width - metrics.getWidth(); + float verticalBuffer = usableSize.height - metrics.getHeight(); + + // at this point, horizontalBuffer and verticalBuffer are the dimensions of the buffer area we have. + // the buffer area is the off-screen area that is part of the display port and will be pre-drawn in case + // the user scrolls there. we now need to split the buffer area on each axis so that we know + // what the exact margins on each side will be. first we split the buffer amount based on the direction + // we're moving, so that we have a larger buffer in the direction of travel. + RectF margins = new RectF(); + margins.left = splitBufferByVelocity(horizontalBuffer, velocity.x); + margins.right = horizontalBuffer - margins.left; + margins.top = splitBufferByVelocity(verticalBuffer, velocity.y); + margins.bottom = verticalBuffer - margins.top; + + // then, we account for running into the page bounds - so that if we hit the top of the page, we need + // to drop the top margin and move that amount to the bottom margin. + margins = shiftMarginsForPageBounds(margins, metrics); + + // finally, we calculate the resolution we want to render the display port area at. We do this + // so that as we expand the display port area (because of velocity), we reduce the resolution of + // the painted area so as to maintain the size of the buffer Gecko is painting into. we calculate + // the reduction in resolution by comparing the display port size with and without the velocity + // changes applied. + // this effectively means that as we pan faster and faster, the display port grows, but we paint + // at lower resolutions. this paints more area to reduce checkerboard at the cost of increasing + // compositor-scaling and blurriness. Once we stop panning, the blurriness must be entirely gone. + // Note that usable* could be less than base* if we are pinch-zoomed out into overscroll, so we + // clamp it to make sure this doesn't increase our display resolution past metrics.zoomFactor. + float scaleFactor = Math.min(reshapedSize.width / usableSize.width, reshapedSize.height / usableSize.height); + float displayResolution = metrics.zoomFactor * Math.min(1.0f, scaleFactor); + + return new DisplayPortMetrics( + metrics.viewportRectLeft - margins.left, + metrics.viewportRectTop - margins.top, + metrics.viewportRectRight + margins.right, + metrics.viewportRectBottom + margins.bottom, + displayResolution); + } + + /** + * Split the given buffer amount into two based on the velocity. + * Given an amount of total usable buffer on an axis, this will + * return the amount that should be used on the left/top side of + * the axis (the side which a negative velocity vector corresponds + * to). + */ + private float splitBufferByVelocity(float amount, float velocity) { + // if no velocity, so split evenly + if (FloatUtils.fuzzyEquals(velocity, 0)) { + return amount / 2.0f; + } + // if we're moving quickly, assign more of the amount in that direction + // since is less likely that we will reverse direction immediately + if (velocity < -VELOCITY_FAST_THRESHOLD) { + return amount * FAST_SPLIT_FACTOR; + } + if (velocity > VELOCITY_FAST_THRESHOLD) { + return amount * (1.0f - FAST_SPLIT_FACTOR); + } + // if we're moving slowly, then assign less of the amount in that direction + if (velocity < 0) { + return amount * SLOW_SPLIT_FACTOR; + } else { + return amount * (1.0f - SLOW_SPLIT_FACTOR); + } + } + + public boolean aboutToCheckerboard(ImmutableViewportMetrics metrics, PointF velocity, DisplayPortMetrics displayPort) { + // Expand the viewport based on our velocity (and clamp it to page boundaries). + // Then intersect it with the last-requested displayport to determine whether we're + // close to checkerboarding. + + RectF predictedViewport = metrics.getViewport(); + + // first we expand the viewport in the direction we're moving based on some + // multiple of the current velocity. + if (velocity.length() > 0) { + if (velocity.x < 0) { + predictedViewport.left += velocity.x * PREDICTION_VELOCITY_MULTIPLIER; + } else if (velocity.x > 0) { + predictedViewport.right += velocity.x * PREDICTION_VELOCITY_MULTIPLIER; + } + + if (velocity.y < 0) { + predictedViewport.top += velocity.y * PREDICTION_VELOCITY_MULTIPLIER; + } else if (velocity.y > 0) { + predictedViewport.bottom += velocity.y * PREDICTION_VELOCITY_MULTIPLIER; + } + } + + // then we expand the viewport evenly in all directions just to have an extra + // safety zone. this also clamps it to page bounds. + predictedViewport = expandByDangerZone(predictedViewport, DANGER_ZONE_MULTIPLIER, DANGER_ZONE_MULTIPLIER, metrics); + return !displayPort.contains(predictedViewport); + } + + @Override + public String toString() { + return "DynamicResolutionStrategy"; + } + } + + /** + * This class implements the variation where we use the draw time to predict where we will be when + * a draw completes, and draw that instead of where we are now. In this variation, when our panning + * speed drops below a certain threshold, we draw 9 viewports' worth of content so that the user can + * pan in any direction without encountering checkerboarding. + * Once the user is panning, we modify the displayport to encompass an area range of where we think + * the user will be when the draw completes. This heuristic relies on both the estimated draw time + * the panning velocity; unexpected changes in either of these values will cause the heuristic to + * fail and show checkerboard. + */ + private static class PredictionBiasStrategy extends DisplayPortStrategy { + private static float VELOCITY_THRESHOLD; + + private int mPixelArea; // area of the viewport, used in draw time calculations + private int mMinFramesToDraw; // minimum number of frames we take to draw + private int mMaxFramesToDraw; // maximum number of frames we take to draw + + PredictionBiasStrategy(LibreOfficeMainActivity context, Map<String, Integer> prefs) { + VELOCITY_THRESHOLD = LOKitShell.getDpi(context) * getFloatPref(prefs, PREF_DISPLAYPORT_PB_VELOCITY_THRESHOLD, 16); + resetPageState(); + } + + public DisplayPortMetrics calculate(ImmutableViewportMetrics metrics, PointF velocity) { + float width = metrics.getWidth(); + float height = metrics.getHeight(); + mPixelArea = (int)(width * height); + + if (velocity.length() < VELOCITY_THRESHOLD) { + // if we're going slow, expand the displayport to 9x viewport size + RectF margins = new RectF(width, height, width, height); + return getTileAlignedDisplayPortMetrics(margins, metrics.zoomFactor, metrics); + } + + // figure out how far we expect to be + float minDx = velocity.x * mMinFramesToDraw; + float minDy = velocity.y * mMinFramesToDraw; + float maxDx = velocity.x * mMaxFramesToDraw; + float maxDy = velocity.y * mMaxFramesToDraw; + + // figure out how many pixels we will be drawing when we draw the above-calculated range. + // this will be larger than the viewport area. + float pixelsToDraw = (width + Math.abs(maxDx - minDx)) * (height + Math.abs(maxDy - minDy)); + // adjust how far we will get because of the time spent drawing all these extra pixels. this + // will again increase the number of pixels drawn so really we could keep iterating this over + // and over, but once seems enough for now. + maxDx = maxDx * pixelsToDraw / mPixelArea; + maxDy = maxDy * pixelsToDraw / mPixelArea; + + // and finally generate the displayport. the min/max stuff takes care of + // negative velocities as well as positive. + RectF margins = new RectF( + -Math.min(minDx, maxDx), + -Math.min(minDy, maxDy), + Math.max(minDx, maxDx), + Math.max(minDy, maxDy)); + return getTileAlignedDisplayPortMetrics(margins, metrics.zoomFactor, metrics); + } + + public boolean aboutToCheckerboard(ImmutableViewportMetrics metrics, PointF velocity, DisplayPortMetrics displayPort) { + // the code below is the same as in calculate() but is awkward to refactor since it has multiple outputs. + // refer to the comments in calculate() to understand what this is doing. + float minDx = velocity.x * mMinFramesToDraw; + float minDy = velocity.y * mMinFramesToDraw; + float maxDx = velocity.x * mMaxFramesToDraw; + float maxDy = velocity.y * mMaxFramesToDraw; + float pixelsToDraw = (metrics.getWidth() + Math.abs(maxDx - minDx)) * (metrics.getHeight() + Math.abs(maxDy - minDy)); + maxDx = maxDx * pixelsToDraw / mPixelArea; + maxDy = maxDy * pixelsToDraw / mPixelArea; + + // now that we have an idea of how far we will be when the draw completes, take the farthest + // end of that range and see if it falls outside the displayport bounds. if it does, allow + // the draw to go through + RectF predictedViewport = metrics.getViewport(); + predictedViewport.left += maxDx; + predictedViewport.top += maxDy; + predictedViewport.right += maxDx; + predictedViewport.bottom += maxDy; + + predictedViewport = clampToPageBounds(predictedViewport, metrics); + return !displayPort.contains(predictedViewport); + } + + @Override + public boolean drawTimeUpdate(long millis, int pixels) { + // calculate the number of frames it took to draw a viewport-sized area + float normalizedTime = (float)mPixelArea * (float)millis / (float)pixels; + int normalizedFrames = (int)Math.ceil(normalizedTime * 60f / 1000f); + // broaden our range on how long it takes to draw if the draw falls outside + // the range. this allows it to grow gradually. this heuristic may need to + // be tweaked into more of a floating window average or something. + if (normalizedFrames <= mMinFramesToDraw) { + mMinFramesToDraw--; + } else if (normalizedFrames > mMaxFramesToDraw) { + mMaxFramesToDraw++; + } else { + return true; + } + Log.d(LOGTAG, "Widened draw range to [" + mMinFramesToDraw + ", " + mMaxFramesToDraw + "]"); + return true; + } + + @Override + public void resetPageState() { + mMinFramesToDraw = 0; + mMaxFramesToDraw = 2; + } + + @Override + public String toString() { + return "PredictionBiasStrategy threshold=" + VELOCITY_THRESHOLD; + } + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/DisplayPortMetrics.java b/android/source/src/java/org/mozilla/gecko/gfx/DisplayPortMetrics.java new file mode 100644 index 0000000000..f622c44ff9 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/DisplayPortMetrics.java @@ -0,0 +1,67 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.RectF; + +import org.mozilla.gecko.util.FloatUtils; + +/* + * This class keeps track of the area we request Gecko to paint, as well + * as the resolution of the paint. The area may be different from the visible + * area of the page, and the resolution may be different from the resolution + * used in the compositor to render the page. This is so that we can ask Gecko + * to paint a much larger area without using extra memory, and then render some + * subsection of that with compositor scaling. + */ +public final class DisplayPortMetrics { + private final RectF mPosition; + private final float mResolution; + + public RectF getPosition() { + return mPosition; + } + + public float getResolution() { + return mResolution; + } + + public DisplayPortMetrics() { + this(0, 0, 0, 0, 1); + } + + public DisplayPortMetrics(float left, float top, float right, float bottom, float resolution) { + mPosition = new RectF(left, top, right, bottom); + mResolution = resolution; + } + + public boolean contains(RectF rect) { + return mPosition.contains(rect); + } + + public boolean fuzzyEquals(DisplayPortMetrics metrics) { + return RectUtils.fuzzyEquals(mPosition, metrics.mPosition) + && FloatUtils.fuzzyEquals(mResolution, metrics.mResolution); + } + + public String toJSON() { + StringBuffer sb = new StringBuffer(256); + sb.append("{ \"left\": ").append(mPosition.left) + .append(", \"top\": ").append(mPosition.top) + .append(", \"right\": ").append(mPosition.right) + .append(", \"bottom\": ").append(mPosition.bottom) + .append(", \"resolution\": ").append(mResolution) + .append('}'); + return sb.toString(); + } + + @Override + public String toString() { + return "DisplayPortMetrics v=(" + mPosition.left + "," + + mPosition.top + "," + mPosition.right + "," + + mPosition.bottom + ") z=" + mResolution; + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/DynamicTileLayer.java b/android/source/src/java/org/mozilla/gecko/gfx/DynamicTileLayer.java new file mode 100644 index 0000000000..ea95c032e8 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/DynamicTileLayer.java @@ -0,0 +1,30 @@ +package org.mozilla.gecko.gfx; + +import android.content.Context; +import android.graphics.RectF; + +public class DynamicTileLayer extends ComposedTileLayer { + public DynamicTileLayer(Context context) { + super(context); + } + + @Override + protected RectF getViewPort(ImmutableViewportMetrics viewportMetrics) { + RectF rect = viewportMetrics.getViewport(); + return inflate(roundToTileSize(rect, tileSize), getInflateFactor()); + } + + @Override + protected float getZoom(ImmutableViewportMetrics viewportMetrics) { + return viewportMetrics.zoomFactor; + } + + @Override + protected int getTilePriority() { + return 0; + } + + private IntSize getInflateFactor() { + return new IntSize(tileSize.width*2, tileSize.height*4); + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/FixedZoomTileLayer.java b/android/source/src/java/org/mozilla/gecko/gfx/FixedZoomTileLayer.java new file mode 100644 index 0000000000..e86494c20b --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/FixedZoomTileLayer.java @@ -0,0 +1,31 @@ +package org.mozilla.gecko.gfx; + +import android.content.Context; +import android.graphics.RectF; + +public class FixedZoomTileLayer extends ComposedTileLayer { + public FixedZoomTileLayer(Context context) { + super(context); + } + + @Override + protected RectF getViewPort(ImmutableViewportMetrics viewportMetrics) { + float zoom = getZoom(viewportMetrics); + RectF rect = normalizeRect(viewportMetrics.getViewport(), viewportMetrics.zoomFactor, zoom); + return inflate(roundToTileSize(rect, tileSize), getInflateFactor()); + } + + @Override + protected float getZoom(ImmutableViewportMetrics viewportMetrics) { + return 1.0f / 16.0f; + } + + @Override + protected int getTilePriority() { + return -1; + } + + private IntSize getInflateFactor() { + return new IntSize(tileSize.width, tileSize.height*6); + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/FloatSize.java b/android/source/src/java/org/mozilla/gecko/gfx/FloatSize.java new file mode 100644 index 0000000000..7b18373115 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/FloatSize.java @@ -0,0 +1,53 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.util.FloatUtils; + +public class FloatSize { + public final float width, height; + + public FloatSize(FloatSize size) { width = size.width; height = size.height; } + public FloatSize(IntSize size) { width = size.width; height = size.height; } + public FloatSize(float aWidth, float aHeight) { width = aWidth; height = aHeight; } + + public FloatSize(JSONObject json) { + try { + width = (float)json.getDouble("width"); + height = (float)json.getDouble("height"); + } catch (JSONException e) { + throw new RuntimeException(e); + } + } + + @Override + public String toString() { return "(" + width + "," + height + ")"; } + + public boolean isPositive() { + return (width > 0 && height > 0); + } + + public boolean fuzzyEquals(FloatSize size) { + return (FloatUtils.fuzzyEquals(size.width, width) && + FloatUtils.fuzzyEquals(size.height, height)); + } + + public FloatSize scale(float factor) { + return new FloatSize(width * factor, height * factor); + } + + /* + * Returns the size that represents a linear transition between this size and `to` at time `t`, + * which is on the scale [0, 1). + */ + public FloatSize interpolate(FloatSize to, float t) { + return new FloatSize(FloatUtils.interpolate(width, to.width, t), + FloatUtils.interpolate(height, to.height, t)); + } +} + diff --git a/android/source/src/java/org/mozilla/gecko/gfx/GLController.java b/android/source/src/java/org/mozilla/gecko/gfx/GLController.java new file mode 100644 index 0000000000..6a43dd6a87 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/GLController.java @@ -0,0 +1,215 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import javax.microedition.khronos.egl.EGL10; +import javax.microedition.khronos.egl.EGL11; +import javax.microedition.khronos.egl.EGLConfig; +import javax.microedition.khronos.egl.EGLContext; +import javax.microedition.khronos.egl.EGLDisplay; +import javax.microedition.khronos.egl.EGLSurface; +import javax.microedition.khronos.opengles.GL; +import javax.microedition.khronos.opengles.GL10; + +public class GLController { + private static final int EGL_CONTEXT_CLIENT_VERSION = 0x3098; + private static final String LOGTAG = "GeckoGLController"; + + private LayerView mView; + private int mGLVersion; + private int mWidth, mHeight; + + private EGL10 mEGL; + private EGLDisplay mEGLDisplay; + private EGLConfig mEGLConfig; + private EGLContext mEGLContext; + private EGLSurface mEGLSurface; + + private static final int LOCAL_EGL_OPENGL_ES2_BIT = 4; + + private static final int[] CONFIG_SPEC = { + EGL10.EGL_RED_SIZE, 5, + EGL10.EGL_GREEN_SIZE, 6, + EGL10.EGL_BLUE_SIZE, 5, + EGL10.EGL_SURFACE_TYPE, EGL10.EGL_WINDOW_BIT, + EGL10.EGL_RENDERABLE_TYPE, LOCAL_EGL_OPENGL_ES2_BIT, + EGL10.EGL_NONE + }; + + public GLController(LayerView view) { + mView = view; + mGLVersion = 2; + } + + public void setGLVersion(int version) { + mGLVersion = version; + } + + /** You must call this on the same thread you intend to use OpenGL on. */ + public void initGLContext() { + initEGLContext(); + createEGLSurface(); + } + + public void disposeGLContext() { + if (mEGL == null) { + return; + } + + if (!mEGL.eglMakeCurrent(mEGLDisplay, EGL10.EGL_NO_SURFACE, EGL10.EGL_NO_SURFACE, + EGL10.EGL_NO_CONTEXT)) { + throw new GLControllerException("EGL context could not be released! " + + getEGLError()); + } + + if (mEGLSurface != null) { + if (!mEGL.eglDestroySurface(mEGLDisplay, mEGLSurface)) { + throw new GLControllerException("EGL surface could not be destroyed! " + + getEGLError()); + } + + mEGLSurface = null; + } + + if (mEGLContext != null) { + if (!mEGL.eglDestroyContext(mEGLDisplay, mEGLContext)) { + throw new GLControllerException("EGL context could not be destroyed! " + + getEGLError()); + } + + mEGLContext = null; + } + } + + public GL10 getGL() { return (GL10) mEGLContext.getGL(); } + public EGLDisplay getEGLDisplay() { return mEGLDisplay; } + public EGLConfig getEGLConfig() { return mEGLConfig; } + public EGLContext getEGLContext() { return mEGLContext; } + public EGLSurface getEGLSurface() { return mEGLSurface; } + public LayerView getView() { return mView; } + + public boolean hasSurface() { + return mEGLSurface != null; + } + + public boolean swapBuffers() { + return mEGL.eglSwapBuffers(mEGLDisplay, mEGLSurface); + } + + public synchronized int getWidth() { + return mWidth; + } + + public synchronized int getHeight() { + return mHeight; + } + + synchronized void surfaceDestroyed() { + notifyAll(); + } + + synchronized void surfaceChanged(int newWidth, int newHeight) { + mWidth = newWidth; + mHeight = newHeight; + notifyAll(); + } + + private void initEGL() { + mEGL = (EGL10)EGLContext.getEGL(); + + mEGLDisplay = mEGL.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY); + if (mEGLDisplay == EGL10.EGL_NO_DISPLAY) { + throw new GLControllerException("eglGetDisplay() failed"); + } + + int[] version = new int[2]; + if (!mEGL.eglInitialize(mEGLDisplay, version)) { + throw new GLControllerException("eglInitialize() failed " + getEGLError()); + } + + mEGLConfig = chooseConfig(); + } + + private void initEGLContext() { + initEGL(); + + int[] attribList = { EGL_CONTEXT_CLIENT_VERSION, mGLVersion, EGL10.EGL_NONE }; + mEGLContext = mEGL.eglCreateContext(mEGLDisplay, mEGLConfig, EGL10.EGL_NO_CONTEXT, + attribList); + if (mEGLContext == null || mEGLContext == EGL10.EGL_NO_CONTEXT) { + throw new GLControllerException("createContext() failed " + + getEGLError()); + } + + if (mView.getRenderer() != null) { + GL10 gl = (GL10) mEGLContext.getGL(); + mView.getRenderer().onSurfaceCreated(gl, mEGLConfig); + mView.getRenderer().onSurfaceChanged(gl, mWidth, mHeight); + } + } + + private EGLConfig chooseConfig() { + int[] numConfigs = new int[1]; + if (!mEGL.eglChooseConfig(mEGLDisplay, CONFIG_SPEC, null, 0, numConfigs) || + numConfigs[0] <= 0) { + throw new GLControllerException("No available EGL configurations " + + getEGLError()); + } + + EGLConfig[] configs = new EGLConfig[numConfigs[0]]; + if (!mEGL.eglChooseConfig(mEGLDisplay, CONFIG_SPEC, configs, numConfigs[0], numConfigs)) { + throw new GLControllerException("No EGL configuration for that specification " + + getEGLError()); + } + + // Select the first 565 RGB configuration. + int[] red = new int[1], green = new int[1], blue = new int[1]; + for (EGLConfig config : configs) { + mEGL.eglGetConfigAttrib(mEGLDisplay, config, EGL10.EGL_RED_SIZE, red); + mEGL.eglGetConfigAttrib(mEGLDisplay, config, EGL10.EGL_GREEN_SIZE, green); + mEGL.eglGetConfigAttrib(mEGLDisplay, config, EGL10.EGL_BLUE_SIZE, blue); + if (red[0] == 5 && green[0] == 6 && blue[0] == 5) { + return config; + } + } + + // if there's no 565 RGB configuration, select another one that fulfils the specification + return configs[0]; + } + + private void createEGLSurface() { + Object window = mView.getNativeWindow(); + mEGLSurface = mEGL.eglCreateWindowSurface(mEGLDisplay, mEGLConfig, window, null); + if (mEGLSurface == null || mEGLSurface == EGL10.EGL_NO_SURFACE) { + throw new GLControllerException("EGL window surface could not be created! " + + getEGLError()); + } + + if (!mEGL.eglMakeCurrent(mEGLDisplay, mEGLSurface, mEGLSurface, mEGLContext)) { + throw new GLControllerException("EGL surface could not be made into the current " + + "surface! " + getEGLError()); + } + + if (mView.getRenderer() != null) { + GL10 gl = (GL10) mEGLContext.getGL(); + mView.getRenderer().onSurfaceCreated(gl, mEGLConfig); + mView.getRenderer().onSurfaceChanged(gl, mView.getWidth(), mView.getHeight()); + } + } + + private String getEGLError() { + return "Error " + mEGL.eglGetError(); + } + + public static class GLControllerException extends RuntimeException { + public static final long serialVersionUID = 1L; + + GLControllerException(String e) { + super(e); + } + } +} + diff --git a/android/source/src/java/org/mozilla/gecko/gfx/GeckoLayerClient.java b/android/source/src/java/org/mozilla/gecko/gfx/GeckoLayerClient.java new file mode 100644 index 0000000000..72a96f0bb0 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/GeckoLayerClient.java @@ -0,0 +1,356 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import org.libreoffice.LibreOfficeMainActivity; +import android.graphics.PointF; +import android.graphics.RectF; +import android.util.DisplayMetrics; + +import org.libreoffice.LOKitShell; +import org.mozilla.gecko.ZoomConstraints; + +import java.util.List; + +public class GeckoLayerClient implements PanZoomTarget { + private static final String LOGTAG = GeckoLayerClient.class.getSimpleName(); + + private LayerRenderer mLayerRenderer; + + private LibreOfficeMainActivity mContext; + private IntSize mScreenSize; + private DisplayPortMetrics mDisplayPort; + + private ComposedTileLayer mLowResLayer; + private ComposedTileLayer mRootLayer; + + private boolean mForceRedraw; + + /* The current viewport metrics. + * This is volatile so that we can read and write to it from different threads. + * We avoid synchronization to make getting the viewport metrics from + * the compositor as cheap as possible. The viewport is immutable so + * we don't need to worry about anyone mutating it while we're reading from it. + * Specifically: + * 1) reading mViewportMetrics from any thread is fine without synchronization + * 2) writing to mViewportMetrics requires synchronizing on the layer controller object + * 3) whenever reading multiple fields from mViewportMetrics without synchronization (i.e. in + * case 1 above) you should always first grab a local copy of the reference, and then use + * that because mViewportMetrics might get reassigned in between reading the different + * fields. */ + private volatile ImmutableViewportMetrics mViewportMetrics; + + private ZoomConstraints mZoomConstraints; + + private boolean mIsReady; + + private PanZoomController mPanZoomController; + private LayerView mView; + private final DisplayPortCalculator mDisplayPortCalculator; + + public GeckoLayerClient(LibreOfficeMainActivity context) { + // we can fill these in with dummy values because they are always written + // to before being read + mContext = context; + mScreenSize = new IntSize(0, 0); + mDisplayPort = new DisplayPortMetrics(); + mDisplayPortCalculator = new DisplayPortCalculator(mContext); + + mForceRedraw = true; + DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); + mViewportMetrics = new ImmutableViewportMetrics(displayMetrics); + } + + public void setView(LayerView view) { + mView = view; + mPanZoomController = PanZoomController.Factory.create(mContext, this, view); + mView.connect(this); + } + + public void notifyReady() { + mIsReady = true; + + mRootLayer = new DynamicTileLayer(mContext); + mLowResLayer = new FixedZoomTileLayer(mContext); + + mLayerRenderer = new LayerRenderer(mView); + + mView.setLayerRenderer(mLayerRenderer); + + sendResizeEventIfNecessary(false); + mView.requestRender(); + } + + public void destroy() { + mPanZoomController.destroy(); + } + + Layer getRoot() { + return mIsReady ? mRootLayer : null; + } + + Layer getLowResLayer() { + return mIsReady ? mLowResLayer : null; + } + + public LayerView getView() { + return mView; + } + + /** + * Returns true if this controller is fine with performing a redraw operation or false if it + * would prefer that the action didn't take place. + */ + private boolean getRedrawHint() { + if (mForceRedraw) { + mForceRedraw = false; + return true; + } + + if (!mPanZoomController.getRedrawHint()) { + return false; + } + return mDisplayPortCalculator.aboutToCheckerboard(mViewportMetrics, mPanZoomController.getVelocityVector(), getDisplayPort()); + } + + /** + * The view calls this function to indicate that the viewport changed size. It must hold the + * monitor while calling it. + * + * TODO: Refactor this to use an interface. Expose that interface only to the view and not + * to the layer client. That way, the layer client won't be tempted to call this, which might + * result in an infinite loop. + */ + void setViewportSize(FloatSize size, boolean forceResizeEvent) { + mViewportMetrics = mViewportMetrics.setViewportSize(size.width, size.height); + sendResizeEventIfNecessary(forceResizeEvent); + } + + PanZoomController getPanZoomController() { + return mPanZoomController; + } + + /* Informs Gecko that the screen size has changed. + * @param force: If true, a resize event will always be sent, otherwise + * it is only sent if size has changed. */ + private void sendResizeEventIfNecessary(boolean force) { + DisplayMetrics metrics = mContext.getResources().getDisplayMetrics(); + IntSize newScreenSize = new IntSize(metrics.widthPixels, metrics.heightPixels); + + if (!force && mScreenSize.equals(newScreenSize)) { + return; + } + + mScreenSize = newScreenSize; + + LOKitShell.sendSizeChangedEvent(mScreenSize.width, mScreenSize.height); + } + + /** + * Sets the current page rect. You must hold the monitor while calling this. + */ + private void setPageRect(RectF rect, RectF cssRect) { + // Since the "rect" is always just a multiple of "cssRect" we don't need to + // check both; this function assumes that both "rect" and "cssRect" are relative + // the zoom factor in mViewportMetrics. + if (mViewportMetrics.getCssPageRect().equals(cssRect)) + return; + + mViewportMetrics = mViewportMetrics.setPageRect(rect, cssRect); + + // Page size is owned by the layer client, so no need to notify it of + // this change. + + post(new Runnable() { + public void run() { + mPanZoomController.pageRectUpdated(); + mView.requestRender(); + } + }); + } + + private void adjustViewport(DisplayPortMetrics displayPort) { + ImmutableViewportMetrics metrics = getViewportMetrics(); + + ImmutableViewportMetrics clampedMetrics = metrics.clamp(); + + if (displayPort == null) { + displayPort = mDisplayPortCalculator.calculate(metrics, mPanZoomController.getVelocityVector()); + } + + mDisplayPort = displayPort; + + reevaluateTiles(); + } + + /** + * Aborts any pan/zoom animation that is currently in progress. + */ + public void abortPanZoomAnimation() { + if (mPanZoomController != null) { + mView.post(new Runnable() { + public void run() { + mPanZoomController.abortAnimation(); + } + }); + } + } + + public void setZoomConstraints(ZoomConstraints constraints) { + mZoomConstraints = constraints; + } + + /** The compositor invokes this function whenever it determines that the page rect + * has changed (based on the information it gets from layout). If setFirstPaintViewport + * is invoked on a frame, then this function will not be. For any given frame, this + * function will be invoked before syncViewportInfo. + */ + public void setPageRect(float cssPageLeft, float cssPageTop, float cssPageRight, float cssPageBottom) { + synchronized (getLock()) { + RectF cssPageRect = new RectF(cssPageLeft, cssPageTop, cssPageRight, cssPageBottom); + float ourZoom = getViewportMetrics().zoomFactor; + setPageRect(RectUtils.scale(cssPageRect, ourZoom), cssPageRect); + // Here the page size of the document has changed, but the document being displayed + // is still the same. Therefore, we don't need to send anything to browser.js; any + // changes we need to make to the display port will get sent the next time we call + // adjustViewport(). + } + } + + private DisplayPortMetrics getDisplayPort() { + return mDisplayPort; + } + + public void beginDrawing() { + mLowResLayer.beginTransaction(); + mRootLayer.beginTransaction(); + } + + public void endDrawing() { + mLowResLayer.endTransaction(); + mRootLayer.endTransaction(); + } + + private void geometryChanged() { + sendResizeEventIfNecessary(false); + if (getRedrawHint()) { + adjustViewport(null); + } + } + + /** Implementation of PanZoomTarget */ + @Override + public ImmutableViewportMetrics getViewportMetrics() { + return mViewportMetrics; + } + + /** Implementation of PanZoomTarget */ + @Override + public ZoomConstraints getZoomConstraints() { + return mZoomConstraints; + } + + /** Implementation of PanZoomTarget */ + @Override + public void setAnimationTarget(ImmutableViewportMetrics viewport) { + if (mIsReady) { + // We know what the final viewport of the animation is going to be, so + // immediately request a draw of that area by setting the display port + // accordingly. This way we should have the content pre-rendered by the + // time the animation is done. + DisplayPortMetrics displayPort = mDisplayPortCalculator.calculate(viewport, null); + adjustViewport(displayPort); + } + } + + /** Implementation of PanZoomTarget + * You must hold the monitor while calling this. + */ + @Override + public void setViewportMetrics(ImmutableViewportMetrics viewport) { + mViewportMetrics = viewport; + mView.requestRender(); + if (mIsReady) { + geometryChanged(); + } + } + + /** Implementation of PanZoomTarget */ + @Override + public void forceRedraw() { + mForceRedraw = true; + if (mIsReady) { + geometryChanged(); + } + } + + /** Implementation of PanZoomTarget */ + @Override + public boolean post(Runnable action) { + return mView.post(action); + } + + /** Implementation of PanZoomTarget */ + @Override + public Object getLock() { + return this; + } + + public PointF convertViewPointToLayerPoint(PointF viewPoint) { + ImmutableViewportMetrics viewportMetrics = mViewportMetrics; + PointF origin = viewportMetrics.getOrigin(); + float zoom = viewportMetrics.zoomFactor; + + return new PointF( + ((viewPoint.x + origin.x) / zoom), + ((viewPoint.y + origin.y) / zoom)); + } + + /** Implementation of PanZoomTarget */ + @Override + public boolean isFullScreen() { + return false; + } + + public void zoomTo(RectF rect) { + if (mPanZoomController instanceof JavaPanZoomController) { + ((JavaPanZoomController) mPanZoomController).animatedZoomTo(rect); + } + } + + /** + * Move the viewport to the desired point, and change the zoom level. + */ + public void moveTo(PointF point, Float zoom) { + if (mPanZoomController instanceof JavaPanZoomController) { + ((JavaPanZoomController) mPanZoomController).animatedMove(point, zoom); + } + } + + public void zoomTo(float pageWidth, float pageHeight) { + zoomTo(new RectF(0, 0, pageWidth, pageHeight)); + } + + public void forceRender() { + mView.requestRender(); + } + + /* Root Layer Access */ + private void reevaluateTiles() { + mLowResLayer.reevaluateTiles(mViewportMetrics, mDisplayPort); + mRootLayer.reevaluateTiles(mViewportMetrics, mDisplayPort); + } + + public void clearAndResetlayers() { + mLowResLayer.clearAndReset(); + mRootLayer.clearAndReset(); + } + + public void invalidateTiles(List<SubTile> tilesToInvalidate, RectF rect) { + mLowResLayer.invalidateTiles(tilesToInvalidate, rect); + mRootLayer.invalidateTiles(tilesToInvalidate, rect); + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/ImmutableViewportMetrics.java b/android/source/src/java/org/mozilla/gecko/gfx/ImmutableViewportMetrics.java new file mode 100644 index 0000000000..f90580fbee --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/ImmutableViewportMetrics.java @@ -0,0 +1,241 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.PointF; +import android.graphics.RectF; +import android.util.DisplayMetrics; + +import org.mozilla.gecko.util.FloatUtils; + +/** + * ImmutableViewportMetrics are used to store the viewport metrics + * in way that we can access a version of them from multiple threads + * without having to take a lock + */ +public class ImmutableViewportMetrics { + + // We need to flatten the RectF and FloatSize structures + // because Java doesn't have the concept of const classes + public final float pageRectLeft; + public final float pageRectTop; + public final float pageRectRight; + public final float pageRectBottom; + public final float cssPageRectLeft; + public final float cssPageRectTop; + public final float cssPageRectRight; + public final float cssPageRectBottom; + public final float viewportRectLeft; + public final float viewportRectTop; + public final float viewportRectRight; + public final float viewportRectBottom; + public final float zoomFactor; + + public ImmutableViewportMetrics(DisplayMetrics metrics) { + viewportRectLeft = pageRectLeft = cssPageRectLeft = 0; + viewportRectTop = pageRectTop = cssPageRectTop = 0; + viewportRectRight = pageRectRight = cssPageRectRight = metrics.widthPixels; + viewportRectBottom = pageRectBottom = cssPageRectBottom = metrics.heightPixels; + zoomFactor = 1.0f; + } + + private ImmutableViewportMetrics(float aPageRectLeft, float aPageRectTop, + float aPageRectRight, float aPageRectBottom, float aCssPageRectLeft, + float aCssPageRectTop, float aCssPageRectRight, float aCssPageRectBottom, + float aViewportRectLeft, float aViewportRectTop, float aViewportRectRight, + float aViewportRectBottom, float aZoomFactor) + { + pageRectLeft = aPageRectLeft; + pageRectTop = aPageRectTop; + pageRectRight = aPageRectRight; + pageRectBottom = aPageRectBottom; + cssPageRectLeft = aCssPageRectLeft; + cssPageRectTop = aCssPageRectTop; + cssPageRectRight = aCssPageRectRight; + cssPageRectBottom = aCssPageRectBottom; + viewportRectLeft = aViewportRectLeft; + viewportRectTop = aViewportRectTop; + viewportRectRight = aViewportRectRight; + viewportRectBottom = aViewportRectBottom; + zoomFactor = aZoomFactor; + } + + public float getWidth() { + return viewportRectRight - viewportRectLeft; + } + + public float getHeight() { + return viewportRectBottom - viewportRectTop; + } + + public PointF getOrigin() { + return new PointF(viewportRectLeft, viewportRectTop); + } + + public FloatSize getSize() { + return new FloatSize(viewportRectRight - viewportRectLeft, viewportRectBottom - viewportRectTop); + } + + public RectF getViewport() { + return new RectF(viewportRectLeft, + viewportRectTop, + viewportRectRight, + viewportRectBottom); + } + + public RectF getCssViewport() { + return RectUtils.scale(getViewport(), 1/zoomFactor); + } + + public RectF getPageRect() { + return new RectF(pageRectLeft, pageRectTop, pageRectRight, pageRectBottom); + } + + public float getPageWidth() { + return pageRectRight - pageRectLeft; + } + + public float getPageHeight() { + return pageRectBottom - pageRectTop; + } + + public RectF getCssPageRect() { + return new RectF(cssPageRectLeft, cssPageRectTop, cssPageRectRight, cssPageRectBottom); + } + + public float getZoomFactor() { + return zoomFactor; + } + + /* + * Returns the viewport metrics that represent a linear transition between "this" and "to" at + * time "t", which is on the scale [0, 1). This function interpolates all values stored in + * the viewport metrics. + */ + public ImmutableViewportMetrics interpolate(ImmutableViewportMetrics to, float t) { + return new ImmutableViewportMetrics( + FloatUtils.interpolate(pageRectLeft, to.pageRectLeft, t), + FloatUtils.interpolate(pageRectTop, to.pageRectTop, t), + FloatUtils.interpolate(pageRectRight, to.pageRectRight, t), + FloatUtils.interpolate(pageRectBottom, to.pageRectBottom, t), + FloatUtils.interpolate(cssPageRectLeft, to.cssPageRectLeft, t), + FloatUtils.interpolate(cssPageRectTop, to.cssPageRectTop, t), + FloatUtils.interpolate(cssPageRectRight, to.cssPageRectRight, t), + FloatUtils.interpolate(cssPageRectBottom, to.cssPageRectBottom, t), + FloatUtils.interpolate(viewportRectLeft, to.viewportRectLeft, t), + FloatUtils.interpolate(viewportRectTop, to.viewportRectTop, t), + FloatUtils.interpolate(viewportRectRight, to.viewportRectRight, t), + FloatUtils.interpolate(viewportRectBottom, to.viewportRectBottom, t), + FloatUtils.interpolate(zoomFactor, to.zoomFactor, t)); + } + + public ImmutableViewportMetrics setViewportSize(float width, float height) { + return new ImmutableViewportMetrics( + pageRectLeft, pageRectTop, pageRectRight, pageRectBottom, + cssPageRectLeft, cssPageRectTop, cssPageRectRight, cssPageRectBottom, + viewportRectLeft, viewportRectTop, viewportRectLeft + width, viewportRectTop + height, + zoomFactor); + } + + public ImmutableViewportMetrics setViewportOrigin(float newOriginX, float newOriginY) { + return new ImmutableViewportMetrics( + pageRectLeft, pageRectTop, pageRectRight, pageRectBottom, + cssPageRectLeft, cssPageRectTop, cssPageRectRight, cssPageRectBottom, + newOriginX, newOriginY, newOriginX + getWidth(), newOriginY + getHeight(), + zoomFactor); + } + + public ImmutableViewportMetrics setZoomFactor(float newZoomFactor) { + return new ImmutableViewportMetrics( + pageRectLeft, pageRectTop, pageRectRight, pageRectBottom, + cssPageRectLeft, cssPageRectTop, cssPageRectRight, cssPageRectBottom, + viewportRectLeft, viewportRectTop, viewportRectRight, viewportRectBottom, + newZoomFactor); + } + + public ImmutableViewportMetrics offsetViewportBy(float dx, float dy) { + return setViewportOrigin(viewportRectLeft + dx, viewportRectTop + dy); + } + + public ImmutableViewportMetrics setPageRect(RectF pageRect, RectF cssPageRect) { + return new ImmutableViewportMetrics( + pageRect.left, pageRect.top, pageRect.right, pageRect.bottom, + cssPageRect.left, cssPageRect.top, cssPageRect.right, cssPageRect.bottom, + viewportRectLeft, viewportRectTop, viewportRectRight, viewportRectBottom, + zoomFactor); + } + + /* This will set the zoom factor and re-scale page-size and viewport offset + * accordingly. The given focus will remain at the same point on the screen + * after scaling. + */ + public ImmutableViewportMetrics scaleTo(float newZoomFactor, PointF focus) { + // cssPageRect* is invariant, since we're setting the scale factor + // here. The page rect is based on the CSS page rect. + float newPageRectLeft = cssPageRectLeft * newZoomFactor; + float newPageRectTop = cssPageRectTop * newZoomFactor; + float newPageRectRight = cssPageRectLeft + ((cssPageRectRight - cssPageRectLeft) * newZoomFactor); + float newPageRectBottom = cssPageRectTop + ((cssPageRectBottom - cssPageRectTop) * newZoomFactor); + + PointF origin = getOrigin(); + origin.offset(focus.x, focus.y); + origin = PointUtils.scale(origin, newZoomFactor / zoomFactor); + origin.offset(-focus.x, -focus.y); + + return new ImmutableViewportMetrics( + newPageRectLeft, newPageRectTop, newPageRectRight, newPageRectBottom, + cssPageRectLeft, cssPageRectTop, cssPageRectRight, cssPageRectBottom, + origin.x, origin.y, origin.x + getWidth(), origin.y + getHeight(), + newZoomFactor); + } + + /** Clamps the viewport to remain within the page rect. */ + public ImmutableViewportMetrics clamp() { + RectF newViewport = getViewport(); + + // The viewport bounds ought to never exceed the page bounds. + if (newViewport.right > pageRectRight) + newViewport.offset(pageRectRight - newViewport.right, 0); + if (newViewport.left < pageRectLeft) + newViewport.offset(pageRectLeft - newViewport.left, 0); + + if (newViewport.bottom > pageRectBottom) + newViewport.offset(0, pageRectBottom - newViewport.bottom); + if (newViewport.top < pageRectTop) + newViewport.offset(0, pageRectTop - newViewport.top); + + return new ImmutableViewportMetrics( + pageRectLeft, pageRectTop, pageRectRight, pageRectBottom, + cssPageRectLeft, cssPageRectTop, cssPageRectRight, cssPageRectBottom, + newViewport.left, newViewport.top, newViewport.right, newViewport.bottom, + zoomFactor); + } + + public boolean fuzzyEquals(ImmutableViewportMetrics other) { + return FloatUtils.fuzzyEquals(pageRectLeft, other.pageRectLeft) + && FloatUtils.fuzzyEquals(pageRectTop, other.pageRectTop) + && FloatUtils.fuzzyEquals(pageRectRight, other.pageRectRight) + && FloatUtils.fuzzyEquals(pageRectBottom, other.pageRectBottom) + && FloatUtils.fuzzyEquals(cssPageRectLeft, other.cssPageRectLeft) + && FloatUtils.fuzzyEquals(cssPageRectTop, other.cssPageRectTop) + && FloatUtils.fuzzyEquals(cssPageRectRight, other.cssPageRectRight) + && FloatUtils.fuzzyEquals(cssPageRectBottom, other.cssPageRectBottom) + && FloatUtils.fuzzyEquals(viewportRectLeft, other.viewportRectLeft) + && FloatUtils.fuzzyEquals(viewportRectTop, other.viewportRectTop) + && FloatUtils.fuzzyEquals(viewportRectRight, other.viewportRectRight) + && FloatUtils.fuzzyEquals(viewportRectBottom, other.viewportRectBottom) + && FloatUtils.fuzzyEquals(zoomFactor, other.zoomFactor); + } + + @Override + public String toString() { + return "ImmutableViewportMetrics v=(" + viewportRectLeft + "," + viewportRectTop + "," + + viewportRectRight + "," + viewportRectBottom + ") p=(" + pageRectLeft + "," + + pageRectTop + "," + pageRectRight + "," + pageRectBottom + ") c=(" + + cssPageRectLeft + "," + cssPageRectTop + "," + cssPageRectRight + "," + + cssPageRectBottom + ") z=" + zoomFactor; + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/InputConnectionHandler.java b/android/source/src/java/org/mozilla/gecko/gfx/InputConnectionHandler.java new file mode 100644 index 0000000000..d460c19e1c --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/InputConnectionHandler.java @@ -0,0 +1,15 @@ +package org.mozilla.gecko.gfx; + +import android.view.KeyEvent; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; + +public interface InputConnectionHandler +{ + InputConnection onCreateInputConnection(EditorInfo outAttrs); + boolean onKeyPreIme(int keyCode, KeyEvent event); + boolean onKeyDown(int keyCode, KeyEvent event); + boolean onKeyLongPress(int keyCode, KeyEvent event); + boolean onKeyMultiple(int keyCode, int repeatCount, KeyEvent event); + boolean onKeyUp(int keyCode, KeyEvent event); +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/IntSize.java b/android/source/src/java/org/mozilla/gecko/gfx/IntSize.java new file mode 100644 index 0000000000..b0741d2f68 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/IntSize.java @@ -0,0 +1,73 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import org.json.JSONException; +import org.json.JSONObject; + +public class IntSize { + public final int width, height; + + public IntSize(IntSize size) { width = size.width; height = size.height; } + public IntSize(int inWidth, int inHeight) { width = inWidth; height = inHeight; } + + public IntSize(FloatSize size) { + width = Math.round(size.width); + height = Math.round(size.height); + } + + public IntSize(JSONObject json) { + try { + width = json.getInt("width"); + height = json.getInt("height"); + } catch (JSONException e) { + throw new RuntimeException(e); + } + } + + public int getArea() { + return width * height; + } + + public boolean equals(IntSize size) { + return ((size.width == width) && (size.height == height)); + } + + public boolean isPositive() { + return (width > 0 && height > 0); + } + + @Override + public String toString() { return "(" + width + "," + height + ")"; } + + public IntSize scale(float factor) { + return new IntSize(Math.round(width * factor), + Math.round(height * factor)); + } + + /* Returns the power of two that is greater than or equal to value */ + public static int nextPowerOfTwo(int value) { + // code taken from http://acius2.blogspot.com/2007/11/calculating-next-power-of-2.html + if (0 == value--) { + return 1; + } + value = (value >> 1) | value; + value = (value >> 2) | value; + value = (value >> 4) | value; + value = (value >> 8) | value; + value = (value >> 16) | value; + return value + 1; + } + + public static int nextPowerOfTwo(float value) { + return nextPowerOfTwo((int) value); + } + + public IntSize nextPowerOfTwo() { + return new IntSize(nextPowerOfTwo(width), nextPowerOfTwo(height)); + } +} + diff --git a/android/source/src/java/org/mozilla/gecko/gfx/JavaPanZoomController.java b/android/source/src/java/org/mozilla/gecko/gfx/JavaPanZoomController.java new file mode 100644 index 0000000000..b20d602a21 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/JavaPanZoomController.java @@ -0,0 +1,1087 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.PointF; +import android.graphics.RectF; +import android.os.Build; +import android.util.Log; +import android.view.GestureDetector; +import android.view.InputDevice; +import android.view.MotionEvent; +import android.view.View; + +import org.libreoffice.LOKitShell; +import org.libreoffice.LibreOfficeMainActivity; +import org.mozilla.gecko.ZoomConstraints; +import org.mozilla.gecko.util.FloatUtils; + +import java.util.Timer; +import java.util.TimerTask; +import java.lang.StrictMath; + +/* + * Handles the kinetic scrolling and zooming physics for a layer controller. + * + * Many ideas are from Joe Hewitt's Scrollability: + * https://github.com/joehewitt/scrollability/ + */ +class JavaPanZoomController + extends GestureDetector.SimpleOnGestureListener + implements PanZoomController, SimpleScaleGestureDetector.SimpleScaleGestureListener +{ + private static final String LOGTAG = "GeckoPanZoomController"; + + // Animation stops if the velocity is below this value when overscrolled or panning. + private static final float STOPPED_THRESHOLD = 4.0f; + + // Animation stops is the velocity is below this threshold when flinging. + private static final float FLING_STOPPED_THRESHOLD = 0.1f; + + // The distance the user has to pan before we recognize it as such (e.g. to avoid 1-pixel pans + // between the touch-down and touch-up of a click). In units of density-independent pixels. + private final float PAN_THRESHOLD; + + // Angle from axis within which we stay axis-locked + private static final double AXIS_LOCK_ANGLE = Math.PI / 6.0; // 30 degrees + + // The maximum amount we allow you to zoom into a page + private static final float MAX_ZOOM = 4.0f; + + // The threshold zoom factor of whether a double tap triggers zoom-in or zoom-out + private static final float DOUBLE_TAP_THRESHOLD = 1.0f; + + // The maximum amount we would like to scroll with the mouse + private final float MAX_SCROLL; + + private enum PanZoomState { + NOTHING, /* no touch-start events received */ + FLING, /* all touches removed, but we're still scrolling page */ + TOUCHING, /* one touch-start event received */ + PANNING_LOCKED, /* touch-start followed by move (i.e. panning with axis lock) */ + PANNING, /* panning without axis lock */ + PANNING_HOLD, /* in panning, but not moving. + * similar to TOUCHING but after starting a pan */ + PANNING_HOLD_LOCKED, /* like PANNING_HOLD, but axis lock still in effect */ + PINCHING, /* nth touch-start, where n > 1. this mode allows pan and zoom */ + ANIMATED_ZOOM, /* animated zoom to a new rect */ + BOUNCE, /* in a bounce animation */ + + WAITING_LISTENERS, /* a state halfway between NOTHING and TOUCHING - the user has + put a finger down, but we don't yet know if a touch listener has + prevented the default actions yet. we still need to abort animations. */ + } + + private final PanZoomTarget mTarget; + private final SubdocumentScrollHelper mSubscroller; + private final Axis mX; + private final Axis mY; + private final TouchEventHandler mTouchEventHandler; + private Thread mMainThread; + private LibreOfficeMainActivity mContext; + + /* The timer that handles flings or bounces. */ + private Timer mAnimationTimer; + /* The runnable being scheduled by the animation timer. */ + private AnimationRunnable mAnimationRunnable; + /* The zoom focus at the first zoom event (in page coordinates). */ + private PointF mLastZoomFocus; + /* The time the last motion event took place. */ + private long mLastEventTime; + /* Current state the pan/zoom UI is in. */ + private PanZoomState mState; + /* Whether or not to wait for a double-tap before dispatching a single-tap */ + private boolean mWaitForDoubleTap; + + JavaPanZoomController(LibreOfficeMainActivity context, PanZoomTarget target, View view) { + mContext = context; + PAN_THRESHOLD = 1/16f * LOKitShell.getDpi(view.getContext()); + MAX_SCROLL = 0.075f * LOKitShell.getDpi(view.getContext()); + mTarget = target; + mSubscroller = new SubdocumentScrollHelper(); + mX = new AxisX(mSubscroller); + mY = new AxisY(mSubscroller); + mTouchEventHandler = new TouchEventHandler(view.getContext(), view, this); + + mMainThread = mContext.getMainLooper().getThread(); + checkMainThread(); + + setState(PanZoomState.NOTHING); + } + + public void destroy() { + mSubscroller.destroy(); + mTouchEventHandler.destroy(); + } + + private static float easeOut(float t) { + // ease-out approx. + // -(t-1)^2+1 + t = t-1; + return -t*t+1; + } + + private void setState(PanZoomState state) { + if (state != mState) { + mState = state; + } + } + + private ImmutableViewportMetrics getMetrics() { + return mTarget.getViewportMetrics(); + } + + // for debugging bug 713011; it can be taken out once that is resolved. + private void checkMainThread() { + if (mMainThread != Thread.currentThread()) { + // log with full stack trace + Log.e(LOGTAG, "Uh-oh, we're running on the wrong thread!", new Exception()); + } + } + + /** This function MUST be called on the UI thread */ + public boolean onMotionEvent(MotionEvent event) { + if ((event.getSource() & InputDevice.SOURCE_CLASS_MASK) == InputDevice.SOURCE_CLASS_POINTER + && (event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_SCROLL) { + return handlePointerScroll(event); + } + return false; + } + + /** This function MUST be called on the UI thread */ + public boolean onTouchEvent(MotionEvent event) { + return mTouchEventHandler.handleEvent(event); + } + + boolean handleEvent(MotionEvent event) { + switch (event.getAction() & MotionEvent.ACTION_MASK) { + case MotionEvent.ACTION_DOWN: return handleTouchStart(event); + case MotionEvent.ACTION_MOVE: return handleTouchMove(event); + case MotionEvent.ACTION_UP: return handleTouchEnd(event); + case MotionEvent.ACTION_CANCEL: return handleTouchCancel(event); + } + return false; + } + + /** This function MUST be called on the UI thread */ + public void notifyDefaultActionPrevented(boolean prevented) { + mTouchEventHandler.handleEventListenerAction(!prevented); + } + + /** This function must be called from the UI thread. */ + public void abortAnimation() { + checkMainThread(); + // this happens when gecko changes the viewport on us or if the device is rotated. + // if that's the case, abort any animation in progress and re-zoom so that the page + // snaps to edges. for other cases (where the user's finger(s) are down) don't do + // anything special. + switch (mState) { + case FLING: + mX.stopFling(); + mY.stopFling(); + // fall through + case BOUNCE: + case ANIMATED_ZOOM: + // the zoom that's in progress likely makes no sense any more (such as if + // the screen orientation changed) so abort it + setState(PanZoomState.NOTHING); + // fall through + case NOTHING: + // Don't do animations here; they're distracting and can cause flashes on page + // transitions. + synchronized (mTarget.getLock()) { + mTarget.setViewportMetrics(getValidViewportMetrics()); + mTarget.forceRedraw(); + } + break; + } + } + + /** This function must be called on the UI thread. */ + void startingNewEventBlock(MotionEvent event, boolean waitingForTouchListeners) { + checkMainThread(); + mSubscroller.cancel(); + if (waitingForTouchListeners && (event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_DOWN) { + // this is the first touch point going down, so we enter the pending state + // setting the state will kill any animations in progress, possibly leaving + // the page in overscroll + setState(PanZoomState.WAITING_LISTENERS); + } + } + + /** This function must be called on the UI thread. */ + void preventedTouchFinished() { + checkMainThread(); + if (mState == PanZoomState.WAITING_LISTENERS) { + // if we enter here, we just finished a block of events whose default actions + // were prevented by touch listeners. Now there are no touch points left, so + // we need to reset our state and re-bounce because we might be in overscroll + bounce(); + } + } + + /** This must be called on the UI thread. */ + public void pageRectUpdated() { + if (mState == PanZoomState.NOTHING) { + synchronized (mTarget.getLock()) { + ImmutableViewportMetrics validated = getValidViewportMetrics(); + if (!getMetrics().fuzzyEquals(validated)) { + // page size changed such that we are now in overscroll. snap to + // the nearest valid viewport + mTarget.setViewportMetrics(validated); + } + } + } + } + + /* + * Panning/scrolling + */ + + private boolean handleTouchStart(MotionEvent event) { + // user is taking control of movement, so stop + // any auto-movement we have going + stopAnimationTimer(); + + switch (mState) { + case ANIMATED_ZOOM: + // We just interrupted a double-tap animation, so force a redraw in + // case this touchstart is just a tap that doesn't end up triggering + // a redraw + mTarget.forceRedraw(); + // fall through + case FLING: + case BOUNCE: + case NOTHING: + case WAITING_LISTENERS: + startTouch(event.getX(0), event.getY(0), event.getEventTime()); + return false; + case TOUCHING: + case PANNING: + case PANNING_LOCKED: + case PANNING_HOLD: + case PANNING_HOLD_LOCKED: + case PINCHING: + Log.e(LOGTAG, "Received impossible touch down while in " + mState); + return false; + } + Log.e(LOGTAG, "Unhandled case " + mState + " in handleTouchStart"); + return false; + } + + private boolean handleTouchMove(MotionEvent event) { + if (mState == PanZoomState.PANNING_LOCKED || mState == PanZoomState.PANNING) { + if (getVelocity() > 18.0f) { + mContext.hideSoftKeyboard(); + } + } + + switch (mState) { + case FLING: + case BOUNCE: + case WAITING_LISTENERS: + // should never happen + Log.e(LOGTAG, "Received impossible touch move while in " + mState); + // fall through + case ANIMATED_ZOOM: + case NOTHING: + // may happen if user double-taps and drags without lifting after the + // second tap. ignore the move if this happens. + return false; + + case TOUCHING: + // Don't allow panning if there is an element in full-screen mode. See bug 775511. + if (mTarget.isFullScreen() || panDistance(event) < PAN_THRESHOLD) { + return false; + } + cancelTouch(); + startPanning(event.getX(0), event.getY(0), event.getEventTime()); + track(event); + return true; + + case PANNING_HOLD_LOCKED: + setState(PanZoomState.PANNING_LOCKED); + // fall through + case PANNING_LOCKED: + track(event); + return true; + + case PANNING_HOLD: + setState(PanZoomState.PANNING); + // fall through + case PANNING: + track(event); + return true; + + case PINCHING: + // scale gesture listener will handle this + return false; + } + Log.e(LOGTAG, "Unhandled case " + mState + " in handleTouchMove"); + return false; + } + + private boolean handleTouchEnd(MotionEvent event) { + + switch (mState) { + case FLING: + case BOUNCE: + case WAITING_LISTENERS: + // should never happen + Log.e(LOGTAG, "Received impossible touch end while in " + mState); + // fall through + case ANIMATED_ZOOM: + case NOTHING: + // may happen if user double-taps and drags without lifting after the + // second tap. ignore if this happens. + return false; + + case TOUCHING: + // the switch into TOUCHING might have happened while the page was + // snapping back after overscroll. we need to finish the snap if that + // was the case + bounce(); + return false; + + case PANNING: + case PANNING_LOCKED: + case PANNING_HOLD: + case PANNING_HOLD_LOCKED: + setState(PanZoomState.FLING); + fling(); + return true; + + case PINCHING: + setState(PanZoomState.NOTHING); + return true; + } + Log.e(LOGTAG, "Unhandled case " + mState + " in handleTouchEnd"); + return false; + } + + private boolean handleTouchCancel(MotionEvent event) { + cancelTouch(); + + if (mState == PanZoomState.WAITING_LISTENERS) { + // we might get a cancel event from the TouchEventHandler while in the + // WAITING_LISTENERS state if the touch listeners prevent-default the + // block of events. at this point being in WAITING_LISTENERS is equivalent + // to being in NOTHING with the exception of possibly being in overscroll. + // so here we don't want to do anything right now; the overscroll will be + // corrected in preventedTouchFinished(). + return false; + } + + // ensure we snap back if we're overscrolled + bounce(); + return false; + } + + private boolean handlePointerScroll(MotionEvent event) { + if (mState == PanZoomState.NOTHING || mState == PanZoomState.FLING) { + float scrollX = event.getAxisValue(MotionEvent.AXIS_HSCROLL); + float scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL); + + scrollBy(scrollX * MAX_SCROLL, scrollY * MAX_SCROLL); + bounce(); + return true; + } + return false; + } + + private void startTouch(float x, float y, long time) { + mX.startTouch(x); + mY.startTouch(y); + setState(PanZoomState.TOUCHING); + mLastEventTime = time; + } + + private void startPanning(float x, float y, long time) { + float dx = mX.panDistance(x); + float dy = mY.panDistance(y); + double angle = Math.atan2(dy, dx); // range [-pi, pi] + angle = Math.abs(angle); // range [0, pi] + + // When the touch move breaks through the pan threshold, reposition the touch down origin + // so the page won't jump when we start panning. + mX.startTouch(x); + mY.startTouch(y); + mLastEventTime = time; + + if (!mX.scrollable() || !mY.scrollable()) { + setState(PanZoomState.PANNING); + } else if (angle < AXIS_LOCK_ANGLE || angle > (Math.PI - AXIS_LOCK_ANGLE)) { + mY.setScrollingDisabled(true); + setState(PanZoomState.PANNING_LOCKED); + } else if (Math.abs(angle - (Math.PI / 2)) < AXIS_LOCK_ANGLE) { + mX.setScrollingDisabled(true); + setState(PanZoomState.PANNING_LOCKED); + } else { + setState(PanZoomState.PANNING); + } + } + + private float panDistance(MotionEvent move) { + float dx = mX.panDistance(move.getX(0)); + float dy = mY.panDistance(move.getY(0)); + return (float) Math.hypot(dx , dy); + } + + private void track(float x, float y, long time) { + float timeDelta = (float)(time - mLastEventTime); + if (FloatUtils.fuzzyEquals(timeDelta, 0)) { + // probably a duplicate event, ignore it. using a zero timeDelta will mess + // up our velocity + return; + } + mLastEventTime = time; + + mX.updateWithTouchAt(x, timeDelta); + mY.updateWithTouchAt(y, timeDelta); + } + + private void track(MotionEvent event) { + mX.saveTouchPos(); + mY.saveTouchPos(); + + for (int i = 0; i < event.getHistorySize(); i++) { + track(event.getHistoricalX(0, i), + event.getHistoricalY(0, i), + event.getHistoricalEventTime(i)); + } + track(event.getX(0), event.getY(0), event.getEventTime()); + + if (stopped()) { + if (mState == PanZoomState.PANNING) { + setState(PanZoomState.PANNING_HOLD); + } else if (mState == PanZoomState.PANNING_LOCKED) { + setState(PanZoomState.PANNING_HOLD_LOCKED); + } else { + // should never happen, but handle anyway for robustness + Log.e(LOGTAG, "Impossible case " + mState + " when stopped in track"); + setState(PanZoomState.PANNING_HOLD_LOCKED); + } + } + + mX.startPan(); + mY.startPan(); + updatePosition(); + } + + private void scrollBy(float dx, float dy) { + ImmutableViewportMetrics scrolled = getMetrics().offsetViewportBy(dx, dy); + mTarget.setViewportMetrics(scrolled); + } + + private void fling() { + updatePosition(); + + stopAnimationTimer(); + + boolean stopped = stopped(); + mX.startFling(stopped); + mY.startFling(stopped); + + startAnimationTimer(new FlingRunnable()); + } + + /* Performs a bounce-back animation to the given viewport metrics. */ + private void bounce(ImmutableViewportMetrics metrics, PanZoomState state) { + stopAnimationTimer(); + + ImmutableViewportMetrics bounceStartMetrics = getMetrics(); + if (bounceStartMetrics.fuzzyEquals(metrics)) { + setState(PanZoomState.NOTHING); + finishAnimation(); + return; + } + + setState(state); + + // At this point we have already set mState to BOUNCE or ANIMATED_ZOOM, so + // getRedrawHint() is returning false. This means we can safely call + // setAnimationTarget to set the new final display port and not have it get + // clobbered by display ports from intermediate animation frames. + mTarget.setAnimationTarget(metrics); + startAnimationTimer(new BounceRunnable(bounceStartMetrics, metrics)); + } + + /* Performs a bounce-back animation to the nearest valid viewport metrics. */ + private void bounce() { + bounce(getValidViewportMetrics(), PanZoomState.BOUNCE); + } + + /* Starts the fling or bounce animation. */ + private void startAnimationTimer(final AnimationRunnable runnable) { + if (mAnimationTimer != null) { + Log.e(LOGTAG, "Attempted to start a new fling without canceling the old one!"); + stopAnimationTimer(); + } + + mAnimationTimer = new Timer("Animation Timer"); + mAnimationRunnable = runnable; + mAnimationTimer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { mTarget.post(runnable); } + }, 0, (int)Axis.MS_PER_FRAME); + } + + /* Stops the fling or bounce animation. */ + private void stopAnimationTimer() { + if (mAnimationTimer != null) { + mAnimationTimer.cancel(); + mAnimationTimer = null; + } + if (mAnimationRunnable != null) { + mAnimationRunnable.terminate(); + mAnimationRunnable = null; + } + } + + private float getVelocity() { + float xvel = mX.getRealVelocity(); + float yvel = mY.getRealVelocity(); + return (float) StrictMath.hypot(xvel, yvel); + } + + public PointF getVelocityVector() { + return new PointF(mX.getRealVelocity(), mY.getRealVelocity()); + } + + private boolean stopped() { + return getVelocity() < STOPPED_THRESHOLD; + } + + private PointF resetDisplacement() { + return new PointF(mX.resetDisplacement(), mY.resetDisplacement()); + } + + private void updatePosition() { + mX.displace(); + mY.displace(); + PointF displacement = resetDisplacement(); + if (FloatUtils.fuzzyEquals(displacement.x, 0.0f) && FloatUtils.fuzzyEquals(displacement.y, 0.0f)) { + return; + } + if (! mSubscroller.scrollBy(displacement)) { + synchronized (mTarget.getLock()) { + scrollBy(displacement.x, displacement.y); + } + } + } + + private abstract class AnimationRunnable implements Runnable { + private boolean mAnimationTerminated; + + /* This should always run on the UI thread */ + public final void run() { + /* + * Since the animation timer queues this runnable on the UI thread, it + * is possible that even when the animation timer is cancelled, there + * are multiple instances of this queued, so we need to have another + * mechanism to abort. This is done by using the mAnimationTerminated flag. + */ + if (mAnimationTerminated) { + return; + } + animateFrame(); + } + + protected abstract void animateFrame(); + + /* This should always run on the UI thread */ + final void terminate() { + mAnimationTerminated = true; + } + } + + /* The callback that performs the bounce animation. */ + private class BounceRunnable extends AnimationRunnable { + /* The current frame of the bounce-back animation */ + private int mBounceFrame; + /* + * The viewport metrics that represent the start and end of the bounce-back animation, + * respectively. + */ + private ImmutableViewportMetrics mBounceStartMetrics; + private ImmutableViewportMetrics mBounceEndMetrics; + + BounceRunnable(ImmutableViewportMetrics startMetrics, ImmutableViewportMetrics endMetrics) { + mBounceStartMetrics = startMetrics; + mBounceEndMetrics = endMetrics; + } + + protected void animateFrame() { + /* + * The pan/zoom controller might have signaled to us that it wants to abort the + * animation by setting the state to PanZoomState.NOTHING. Handle this case and bail + * out. + */ + if (!(mState == PanZoomState.BOUNCE || mState == PanZoomState.ANIMATED_ZOOM)) { + finishAnimation(); + return; + } + + /* Perform the next frame of the bounce-back animation. */ + if (mBounceFrame < (int)(256f/Axis.MS_PER_FRAME)) { + advanceBounce(); + return; + } + + /* Finally, if there's nothing else to do, complete the animation and go to sleep. */ + finishBounce(); + finishAnimation(); + setState(PanZoomState.NOTHING); + } + + /* Performs one frame of a bounce animation. */ + private void advanceBounce() { + synchronized (mTarget.getLock()) { + float t = easeOut(mBounceFrame * Axis.MS_PER_FRAME / 256f); + ImmutableViewportMetrics newMetrics = mBounceStartMetrics.interpolate(mBounceEndMetrics, t); + mTarget.setViewportMetrics(newMetrics); + mBounceFrame++; + } + } + + /* Concludes a bounce animation and snaps the viewport into place. */ + private void finishBounce() { + synchronized (mTarget.getLock()) { + mTarget.setViewportMetrics(mBounceEndMetrics); + mBounceFrame = -1; + } + } + } + + // The callback that performs the fling animation. + private class FlingRunnable extends AnimationRunnable { + protected void animateFrame() { + /* + * The pan/zoom controller might have signaled to us that it wants to abort the + * animation by setting the state to PanZoomState.NOTHING. Handle this case and bail + * out. + */ + if (mState != PanZoomState.FLING) { + finishAnimation(); + return; + } + + /* Advance flings, if necessary. */ + boolean flingingX = mX.advanceFling(); + boolean flingingY = mY.advanceFling(); + + boolean overscrolled = (mX.overscrolled() || mY.overscrolled()); + + /* If we're still flinging in any direction, update the origin. */ + if (flingingX || flingingY) { + updatePosition(); + + /* + * Check to see if we're still flinging with an appreciable velocity. The threshold is + * higher in the case of overscroll, so we bounce back eagerly when overscrolling but + * coast smoothly to a stop when not. In other words, require a greater velocity to + * maintain the fling once we enter overscroll. + */ + float threshold = (overscrolled && !mSubscroller.scrolling() ? STOPPED_THRESHOLD : FLING_STOPPED_THRESHOLD); + if (getVelocity() >= threshold) { + mContext.getDocumentOverlay().showPageNumberRect(); + // we're still flinging + return; + } + + mX.stopFling(); + mY.stopFling(); + } + + /* Perform a bounce-back animation if overscrolled. */ + if (overscrolled) { + bounce(); + } else { + finishAnimation(); + setState(PanZoomState.NOTHING); + } + } + } + + private void finishAnimation() { + checkMainThread(); + + stopAnimationTimer(); + + mContext.getDocumentOverlay().hidePageNumberRect(); + + // Force a viewport synchronisation + mTarget.forceRedraw(); + } + + /* Returns the nearest viewport metrics with no overscroll visible. */ + private ImmutableViewportMetrics getValidViewportMetrics() { + return getValidViewportMetrics(getMetrics()); + } + + private ImmutableViewportMetrics getValidViewportMetrics(ImmutableViewportMetrics viewportMetrics) { + /* First, we adjust the zoom factor so that we can make no overscrolled area visible. */ + float zoomFactor = viewportMetrics.zoomFactor; + RectF pageRect = viewportMetrics.getPageRect(); + RectF viewport = viewportMetrics.getViewport(); + + float focusX = viewport.width() / 2.0f; + float focusY = viewport.height() / 2.0f; + + float minZoomFactor = 0.0f; + float maxZoomFactor = MAX_ZOOM; + + ZoomConstraints constraints = mTarget.getZoomConstraints(); + if (null == constraints) { + Log.e(LOGTAG, "ZoomConstraints not available - too impatient?"); + return viewportMetrics; + + } + if (constraints.getMinZoom() > 0) + minZoomFactor = constraints.getMinZoom(); + if (constraints.getMaxZoom() > 0) + maxZoomFactor = constraints.getMaxZoom(); + + maxZoomFactor = Math.max(maxZoomFactor, minZoomFactor); + + if (zoomFactor < minZoomFactor) { + // if one (or both) of the page dimensions is smaller than the viewport, + // zoom using the top/left as the focus on that axis. this prevents the + // scenario where, if both dimensions are smaller than the viewport, but + // by different scale factors, we end up scrolled to the end on one axis + // after applying the scale + PointF center = new PointF(focusX, focusY); + viewportMetrics = viewportMetrics.scaleTo(minZoomFactor, center); + } else if (zoomFactor > maxZoomFactor) { + PointF center = new PointF(viewport.width() / 2.0f, viewport.height() / 2.0f); + viewportMetrics = viewportMetrics.scaleTo(maxZoomFactor, center); + } + + /* Now we pan to the right origin. */ + viewportMetrics = viewportMetrics.clamp(); + + viewportMetrics = pushPageToCenterOfViewport(viewportMetrics); + + return viewportMetrics; + } + + private ImmutableViewportMetrics pushPageToCenterOfViewport(ImmutableViewportMetrics viewportMetrics) { + RectF pageRect = viewportMetrics.getPageRect(); + RectF viewportRect = viewportMetrics.getViewport(); + + if (pageRect.width() < viewportRect.width()) { + float originX = (viewportRect.width() - pageRect.width()) / 2.0f; + viewportMetrics = viewportMetrics.setViewportOrigin(-originX, viewportMetrics.getOrigin().y); + } + + if (pageRect.height() < viewportRect.height()) { + float originY = (viewportRect.height() - pageRect.height()) / 2.0f; + viewportMetrics = viewportMetrics.setViewportOrigin(viewportMetrics.getOrigin().x, -originY); + } + + return viewportMetrics; + } + + private class AxisX extends Axis { + AxisX(SubdocumentScrollHelper subscroller) { super(subscroller); } + @Override + public float getOrigin() { return getMetrics().viewportRectLeft; } + @Override + protected float getViewportLength() { return getMetrics().getWidth(); } + @Override + protected float getPageStart() { return getMetrics().pageRectLeft; } + @Override + protected float getPageLength() { return getMetrics().getPageWidth(); } + } + + private class AxisY extends Axis { + AxisY(SubdocumentScrollHelper subscroller) { super(subscroller); } + @Override + public float getOrigin() { return getMetrics().viewportRectTop; } + @Override + protected float getViewportLength() { return getMetrics().getHeight(); } + @Override + protected float getPageStart() { return getMetrics().pageRectTop; } + @Override + protected float getPageLength() { return getMetrics().getPageHeight(); } + } + + /* + * Zooming + */ + @Override + public boolean onScaleBegin(SimpleScaleGestureDetector detector) { + if (mState == PanZoomState.ANIMATED_ZOOM) + return false; + + if (null == mTarget.getZoomConstraints()) + return false; + + setState(PanZoomState.PINCHING); + mLastZoomFocus = new PointF(detector.getFocusX(), detector.getFocusY()); + cancelTouch(); + + return true; + } + + @Override + public boolean onScale(SimpleScaleGestureDetector detector) { + if (mTarget.isFullScreen()) + return false; + + if (mState != PanZoomState.PINCHING) + return false; + + float prevSpan = detector.getPreviousSpan(); + if (FloatUtils.fuzzyEquals(prevSpan, 0.0f)) { + // let's eat this one to avoid setting the new zoom to infinity (bug 711453) + return true; + } + + float spanRatio = detector.getCurrentSpan() / prevSpan; + + synchronized (mTarget.getLock()) { + float newZoomFactor = getMetrics().zoomFactor * spanRatio; + float minZoomFactor = 0.0f; // deliberately set to zero to allow big zoom out effect + float maxZoomFactor = MAX_ZOOM; + + ZoomConstraints constraints = mTarget.getZoomConstraints(); + + if (constraints.getMaxZoom() > 0) + maxZoomFactor = constraints.getMaxZoom(); + + if (newZoomFactor < minZoomFactor) { + // apply resistance when zooming past minZoomFactor, + // such that it asymptotically reaches minZoomFactor / 2.0 + // but never exceeds that + final float rate = 0.5f; // controls how quickly we approach the limit + float excessZoom = minZoomFactor - newZoomFactor; + excessZoom = 1.0f - (float)Math.exp(-excessZoom * rate); + newZoomFactor = minZoomFactor * (1.0f - excessZoom / 2.0f); + } + + if (newZoomFactor > maxZoomFactor) { + // apply resistance when zooming past maxZoomFactor, + // such that it asymptotically reaches maxZoomFactor + 1.0 + // but never exceeds that + float excessZoom = newZoomFactor - maxZoomFactor; + excessZoom = 1.0f - (float)Math.exp(-excessZoom); + newZoomFactor = maxZoomFactor + excessZoom; + } + + scrollBy(mLastZoomFocus.x - detector.getFocusX(), + mLastZoomFocus.y - detector.getFocusY()); + PointF focus = new PointF(detector.getFocusX(), detector.getFocusY()); + scaleWithFocus(newZoomFactor, focus); + } + + mLastZoomFocus.set(detector.getFocusX(), detector.getFocusY()); + + return true; + } + + @Override + public void onScaleEnd(SimpleScaleGestureDetector detector) { + if (mState == PanZoomState.ANIMATED_ZOOM) + return; + + // switch back to the touching state + startTouch(detector.getFocusX(), detector.getFocusY(), detector.getEventTime()); + + // Force a viewport synchronisation + mTarget.forceRedraw(); + + } + + /** + * Scales the viewport, keeping the given focus point in the same place before and after the + * scale operation. You must hold the monitor while calling this. + */ + private void scaleWithFocus(float zoomFactor, PointF focus) { + ImmutableViewportMetrics viewportMetrics = getMetrics(); + viewportMetrics = viewportMetrics.scaleTo(zoomFactor, focus); + mTarget.setViewportMetrics(viewportMetrics); + } + + public boolean getRedrawHint() { + switch (mState) { + case PINCHING: + case ANIMATED_ZOOM: + case BOUNCE: + // don't redraw during these because the zoom is (or might be, in the case + // of BOUNCE) be changing rapidly and gecko will have to redraw the entire + // display port area. we trigger a force-redraw upon exiting these states. + return false; + default: + // allow redrawing in other states + return true; + } + } + + @Override + public boolean onDown(MotionEvent motionEvent) { + mWaitForDoubleTap = mTarget.getZoomConstraints() != null; + return false; + } + + @Override + public void onShowPress(MotionEvent motionEvent) { + // If we get this, it will be followed either by a call to + // onSingleTapUp (if the user lifts their finger before the + // long-press timeout) or a call to onLongPress (if the user + // does not). In the former case, we want to make sure it is + // treated as a click. (Note that if this is called, we will + // not get a call to onDoubleTap). + mWaitForDoubleTap = false; + } + + private PointF getMotionInDocumentCoordinates(MotionEvent motionEvent) { + RectF viewport = getValidViewportMetrics().getViewport(); + PointF viewPoint = new PointF(motionEvent.getX(0), motionEvent.getY(0)); + return mTarget.convertViewPointToLayerPoint(viewPoint); + } + + @Override + public void onLongPress(MotionEvent motionEvent) { + LOKitShell.sendTouchEvent("LongPress", getMotionInDocumentCoordinates(motionEvent)); + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + mContext.getDocumentOverlay().showPageNumberRect(); + return super.onScroll(e1, e2, distanceX, distanceY); + } + + @Override + public boolean onSingleTapUp(MotionEvent motionEvent) { + // When double-tapping is allowed, we have to wait to see if this is + // going to be a double-tap. + if (!mWaitForDoubleTap) { + LOKitShell.sendTouchEvent("SingleTap", getMotionInDocumentCoordinates(motionEvent)); + } + // return false because we still want to get the ACTION_UP event that triggers this + return false; + } + + @Override + public boolean onSingleTapConfirmed(MotionEvent motionEvent) { + // In cases where we don't wait for double-tap, we handle this in onSingleTapUp. + if (mWaitForDoubleTap) { + LOKitShell.sendTouchEvent("SingleTap", getMotionInDocumentCoordinates(motionEvent)); + } + return true; + } + + @Override + public boolean onDoubleTap(MotionEvent motionEvent) { + if (null == mTarget.getZoomConstraints()) { + return true; + } + // Double tap zooms in or out depending on the current zoom factor + PointF pointOfTap = getMotionInDocumentCoordinates(motionEvent); + ImmutableViewportMetrics metrics = getMetrics(); + float newZoom = metrics.getZoomFactor() >= + DOUBLE_TAP_THRESHOLD ? mTarget.getZoomConstraints().getDefaultZoom() : DOUBLE_TAP_THRESHOLD; + // calculate new top_left point from the point of tap + float ratio = newZoom/metrics.getZoomFactor(); + float newLeft = pointOfTap.x - 1/ratio * (pointOfTap.x - metrics.getOrigin().x / metrics.getZoomFactor()); + float newTop = pointOfTap.y - 1/ratio * (pointOfTap.y - metrics.getOrigin().y / metrics.getZoomFactor()); + // animate move to the new view + animatedMove(new PointF(newLeft, newTop), newZoom); + + LOKitShell.sendTouchEvent("DoubleTap", pointOfTap); + return true; + } + + private void cancelTouch() { + //GeckoEvent e = GeckoEvent.createBroadcastEvent("Gesture:CancelTouch", ""); + //GeckoAppShell.sendEventToGecko(e); + } + + /** + * Zoom to a specified rect IN CSS PIXELS. + * + * While we usually use device pixels, zoomToRect must be specified in CSS + * pixels. + */ + boolean animatedZoomTo(RectF zoomToRect) { + final float startZoom = getMetrics().zoomFactor; + + RectF viewport = getMetrics().getViewport(); + // 1. adjust the aspect ratio of zoomToRect to match that of the current viewport, + // enlarging as necessary (if it gets too big, it will get shrunk in the next step). + // while enlarging make sure we enlarge equally on both sides to keep the target rect + // centered. + float targetRatio = viewport.width() / viewport.height(); + float rectRatio = zoomToRect.width() / zoomToRect.height(); + if (FloatUtils.fuzzyEquals(targetRatio, rectRatio)) { + // all good, do nothing + } else if (targetRatio < rectRatio) { + // need to increase zoomToRect height + float newHeight = zoomToRect.width() / targetRatio; + zoomToRect.top -= (newHeight - zoomToRect.height()) / 2; + zoomToRect.bottom = zoomToRect.top + newHeight; + } else { // targetRatio > rectRatio) { + // need to increase zoomToRect width + float newWidth = targetRatio * zoomToRect.height(); + zoomToRect.left -= (newWidth - zoomToRect.width()) / 2; + zoomToRect.right = zoomToRect.left + newWidth; + } + + float finalZoom = viewport.width() / zoomToRect.width(); + + ImmutableViewportMetrics finalMetrics = getMetrics(); + finalMetrics = finalMetrics.setViewportOrigin( + zoomToRect.left * finalMetrics.zoomFactor, + zoomToRect.top * finalMetrics.zoomFactor); + finalMetrics = finalMetrics.scaleTo(finalZoom, new PointF(0.0f, 0.0f)); + + // 2. now run getValidViewportMetrics on it, so that the target viewport is + // clamped down to prevent overscroll, over-zoom, and other bad conditions. + finalMetrics = getValidViewportMetrics(finalMetrics); + + bounce(finalMetrics, PanZoomState.ANIMATED_ZOOM); + return true; + } + + /** + * Move the viewport to the top-left point to and zoom to the desired + * zoom factor. Input zoom factor can be null, in this case leave the zoom unchanged. + */ + boolean animatedMove(PointF topLeft, Float zoom) { + RectF moveToRect = getMetrics().getCssViewport(); + moveToRect.offsetTo(topLeft.x, topLeft.y); + + ImmutableViewportMetrics finalMetrics = getMetrics(); + + finalMetrics = finalMetrics.setViewportOrigin( + moveToRect.left * finalMetrics.zoomFactor, + moveToRect.top * finalMetrics.zoomFactor); + + if (zoom != null) { + finalMetrics = finalMetrics.scaleTo(zoom, new PointF(0.0f, 0.0f)); + } + finalMetrics = getValidViewportMetrics(finalMetrics); + + bounce(finalMetrics, PanZoomState.ANIMATED_ZOOM); + return true; + } + + /** This function must be called from the UI thread. */ + public void abortPanning() { + checkMainThread(); + bounce(); + } + + public void setOverScrollMode(int overscrollMode) { + mX.setOverScrollMode(overscrollMode); + mY.setOverScrollMode(overscrollMode); + } + + public int getOverScrollMode() { + return mX.getOverScrollMode(); + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/Layer.java b/android/source/src/java/org/mozilla/gecko/gfx/Layer.java new file mode 100644 index 0000000000..b7fee29fc9 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/Layer.java @@ -0,0 +1,218 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Region; + +import org.mozilla.gecko.util.FloatUtils; + +import java.nio.FloatBuffer; +import java.util.concurrent.locks.ReentrantLock; + +public abstract class Layer { + private final ReentrantLock mTransactionLock; + private boolean mInTransaction; + private Rect mNewPosition; + private float mNewResolution; + + protected Rect mPosition; + protected float mResolution; + protected boolean mUsesDefaultProgram = true; + + public Layer() { + this(null); + } + + public Layer(IntSize size) { + mTransactionLock = new ReentrantLock(); + if (size == null) { + mPosition = new Rect(); + } else { + mPosition = new Rect(0, 0, size.width, size.height); + } + mResolution = 1.0f; + } + + /** + * Updates the layer. This returns false if there is still work to be done + * after this update. + */ + public final boolean update(RenderContext context) { + if (mTransactionLock.isHeldByCurrentThread()) { + throw new RuntimeException("draw() called while transaction lock held by this " + + "thread?!"); + } + + if (mTransactionLock.tryLock()) { + try { + performUpdates(context); + return true; + } finally { + mTransactionLock.unlock(); + } + } + + return false; + } + + /** Subclasses override this function to draw the layer. */ + public abstract void draw(RenderContext context); + + /** Given the intrinsic size of the layer, returns the pixel boundaries of the layer rect. */ + protected RectF getBounds(RenderContext context) { + return RectUtils.scale(new RectF(mPosition), context.zoomFactor / mResolution); + } + + /** + * Returns the region of the layer that is considered valid. The default + * implementation of this will return the bounds of the layer, but this + * may be overridden. + */ + public Region getValidRegion(RenderContext context) { + return new Region(RectUtils.round(getBounds(context))); + } + + /** + * Call this before modifying the layer. Note that, for TileLayers, "modifying the layer" + * includes altering the underlying CairoImage in any way. Thus you must call this function + * before modifying the byte buffer associated with this layer. + * + * This function may block, so you should never call this on the main UI thread. + */ + public void beginTransaction() { + if (mTransactionLock.isHeldByCurrentThread()) + throw new RuntimeException("Nested transactions are not supported"); + mTransactionLock.lock(); + mInTransaction = true; + mNewResolution = mResolution; + } + + /** Call this when you're done modifying the layer. */ + public void endTransaction() { + if (!mInTransaction) + throw new RuntimeException("endTransaction() called outside a transaction"); + mInTransaction = false; + mTransactionLock.unlock(); + } + + /** Returns true if the layer is currently in a transaction and false otherwise. */ + protected boolean inTransaction() { + return mInTransaction; + } + + /** Returns the current layer position. */ + public Rect getPosition() { + return mPosition; + } + + /** Sets the position. Only valid inside a transaction. */ + public void setPosition(Rect newPosition) { + if (!mInTransaction) + throw new RuntimeException("setPosition() is only valid inside a transaction"); + mNewPosition = newPosition; + } + + /** Returns the current layer's resolution. */ + public float getResolution() { + return mResolution; + } + + /** + * Sets the layer resolution. This value is used to determine how many pixels per + * device pixel this layer was rendered at. This will be reflected by scaling by + * the reciprocal of the resolution in the layer's transform() function. + * Only valid inside a transaction. */ + public void setResolution(float newResolution) { + if (!mInTransaction) + throw new RuntimeException("setResolution() is only valid inside a transaction"); + mNewResolution = newResolution; + } + + public boolean usesDefaultProgram() { + return mUsesDefaultProgram; + } + + /** + * Subclasses may override this method to perform custom layer updates. This will be called + * with the transaction lock held. Subclass implementations of this method must call the + * superclass implementation. Returns false if there is still work to be done after this + * update is complete. + */ + protected void performUpdates(RenderContext context) { + if (mNewPosition != null) { + mPosition = mNewPosition; + mNewPosition = null; + } + if (mNewResolution != 0.0f) { + mResolution = mNewResolution; + mNewResolution = 0.0f; + } + } + + /** + * This function fills in the provided <tt>dest</tt> array with values to render a texture. + * The array is filled with 4 sets of {x, y, z, texture_x, texture_y} values (so 20 values + * in total) corresponding to the corners of the rect. + */ + protected final void fillRectCoordBuffer(float[] dest, RectF rect, float viewWidth, float viewHeight, + Rect cropRect, float texWidth, float texHeight) { + //x, y, z, texture_x, texture_y + dest[0] = rect.left / viewWidth; + dest[1] = rect.bottom / viewHeight; + dest[2] = 0; + dest[3] = cropRect.left / texWidth; + dest[4] = cropRect.top / texHeight; + + dest[5] = rect.left / viewWidth; + dest[6] = rect.top / viewHeight; + dest[7] = 0; + dest[8] = cropRect.left / texWidth; + dest[9] = cropRect.bottom / texHeight; + + dest[10] = rect.right / viewWidth; + dest[11] = rect.bottom / viewHeight; + dest[12] = 0; + dest[13] = cropRect.right / texWidth; + dest[14] = cropRect.top / texHeight; + + dest[15] = rect.right / viewWidth; + dest[16] = rect.top / viewHeight; + dest[17] = 0; + dest[18] = cropRect.right / texWidth; + dest[19] = cropRect.bottom / texHeight; + } + + public static class RenderContext { + public final RectF viewport; + public final RectF pageRect; + public final float zoomFactor; + public final int positionHandle; + public final int textureHandle; + public final FloatBuffer coordBuffer; + + public RenderContext(RectF aViewport, RectF aPageRect, float aZoomFactor, + int aPositionHandle, int aTextureHandle, FloatBuffer aCoordBuffer) { + viewport = aViewport; + pageRect = aPageRect; + zoomFactor = aZoomFactor; + positionHandle = aPositionHandle; + textureHandle = aTextureHandle; + coordBuffer = aCoordBuffer; + } + + public boolean fuzzyEquals(RenderContext other) { + if (other == null) { + return false; + } + return RectUtils.fuzzyEquals(viewport, other.viewport) + && RectUtils.fuzzyEquals(pageRect, other.pageRect) + && FloatUtils.fuzzyEquals(zoomFactor, other.zoomFactor); + } + } +} + diff --git a/android/source/src/java/org/mozilla/gecko/gfx/LayerRenderer.java b/android/source/src/java/org/mozilla/gecko/gfx/LayerRenderer.java new file mode 100644 index 0000000000..6ea7dd0edc --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/LayerRenderer.java @@ -0,0 +1,453 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.Color; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.RectF; +import android.opengl.GLES20; +import android.opengl.GLSurfaceView; +import android.os.SystemClock; +import android.util.Log; + +import org.libreoffice.kit.DirectBufferAllocator; +import org.mozilla.gecko.gfx.Layer.RenderContext; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; +import java.nio.IntBuffer; +import java.util.concurrent.CopyOnWriteArrayList; + +import javax.microedition.khronos.egl.EGLConfig; +import javax.microedition.khronos.opengles.GL10; + +/** + * The layer renderer implements the rendering logic for a layer view. + */ +public class LayerRenderer implements GLSurfaceView.Renderer { + private static final String LOGTAG = "GeckoLayerRenderer"; + + /* + * The amount of time a frame is allowed to take to render before we declare it a dropped + * frame. + */ + private static final int MAX_FRAME_TIME = 16; /* 1000 ms / 60 FPS */ + + private final LayerView mView; + private final SingleTileLayer mBackgroundLayer; + private final NinePatchTileLayer mShadowLayer; + private final ScrollbarLayer mHorizScrollLayer; + private final ScrollbarLayer mVertScrollLayer; + private final FadeRunnable mFadeRunnable; + private ByteBuffer mCoordByteBuffer; + private FloatBuffer mCoordBuffer; + private RenderContext mLastPageContext; + private int mMaxTextureSize; + + private CopyOnWriteArrayList<Layer> mExtraLayers = new CopyOnWriteArrayList<Layer>(); + + // Used by GLES 2.0 + private int mProgram; + private int mPositionHandle; + private int mTextureHandle; + private int mSampleHandle; + private int mTMatrixHandle; + + // column-major matrix applied to each vertex to shift the viewport from + // one ranging from (-1, -1),(1,1) to (0,0),(1,1) and to scale all sizes by + // a factor of 2 to fill up the screen + public static final float[] DEFAULT_TEXTURE_MATRIX = { + 2.0f, 0.0f, 0.0f, 0.0f, + 0.0f, 2.0f, 0.0f, 0.0f, + 0.0f, 0.0f, 2.0f, 0.0f, + -1.0f, -1.0f, 0.0f, 1.0f + }; + + private static final int COORD_BUFFER_SIZE = 20; + + // The shaders run on the GPU directly, the vertex shader is only applying the + // matrix transform detailed above + + // Note we flip the y-coordinate in the vertex shader from a + // coordinate system with (0,0) in the top left to one with (0,0) in + // the bottom left. + + public static final String DEFAULT_VERTEX_SHADER = + "uniform mat4 uTMatrix;\n" + + "attribute vec4 vPosition;\n" + + "attribute vec2 aTexCoord;\n" + + "varying vec2 vTexCoord;\n" + + "void main() {\n" + + " gl_Position = uTMatrix * vPosition;\n" + + " vTexCoord.x = aTexCoord.x;\n" + + " vTexCoord.y = 1.0 - aTexCoord.y;\n" + + "}\n"; + + // We use highp because the screenshot textures + // we use are large and we stretch them a lot + // so we need all the precision we can get. + // Unfortunately, highp is not required by ES 2.0 + // so on GPU's like Mali we end up getting mediump + public static final String DEFAULT_FRAGMENT_SHADER = + "precision highp float;\n" + + "varying vec2 vTexCoord;\n" + + "uniform sampler2D sTexture;\n" + + "void main() {\n" + + " gl_FragColor = texture2D(sTexture, vTexCoord);\n" + + "}\n"; + + public LayerRenderer(LayerView view) { + mView = view; + + CairoImage backgroundImage = new BufferedCairoImage(view.getBackgroundPattern()); + mBackgroundLayer = new SingleTileLayer(true, backgroundImage); + + CairoImage shadowImage = new BufferedCairoImage(view.getShadowPattern()); + mShadowLayer = new NinePatchTileLayer(shadowImage); + + mHorizScrollLayer = ScrollbarLayer.create(this, false); + mVertScrollLayer = ScrollbarLayer.create(this, true); + mFadeRunnable = new FadeRunnable(); + + // Initialize the FloatBuffer that will be used to store all vertices and texture + // coordinates in draw() commands. + mCoordByteBuffer = DirectBufferAllocator.allocate(COORD_BUFFER_SIZE * 4); + mCoordByteBuffer.order(ByteOrder.nativeOrder()); + mCoordBuffer = mCoordByteBuffer.asFloatBuffer(); + } + + @Override + protected void finalize() throws Throwable { + try { + DirectBufferAllocator.free(mCoordByteBuffer); + mCoordByteBuffer = null; + mCoordBuffer = null; + } finally { + super.finalize(); + } + } + + public void destroy() { + DirectBufferAllocator.free(mCoordByteBuffer); + mCoordByteBuffer = null; + mCoordBuffer = null; + mBackgroundLayer.destroy(); + mShadowLayer.destroy(); + mHorizScrollLayer.destroy(); + mVertScrollLayer.destroy(); + } + + @Override + public void onSurfaceCreated(GL10 gl, EGLConfig config) { + createDefaultProgram(); + activateDefaultProgram(); + } + + public void createDefaultProgram() { + int vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, DEFAULT_VERTEX_SHADER); + int fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, DEFAULT_FRAGMENT_SHADER); + + mProgram = GLES20.glCreateProgram(); + GLES20.glAttachShader(mProgram, vertexShader); // add the vertex shader to program + GLES20.glAttachShader(mProgram, fragmentShader); // add the fragment shader to program + GLES20.glLinkProgram(mProgram); // creates OpenGL program executables + + // Get handles to the vertex shader's vPosition, aTexCoord, sTexture, and uTMatrix members. + mPositionHandle = GLES20.glGetAttribLocation(mProgram, "vPosition"); + mTextureHandle = GLES20.glGetAttribLocation(mProgram, "aTexCoord"); + mSampleHandle = GLES20.glGetUniformLocation(mProgram, "sTexture"); + mTMatrixHandle = GLES20.glGetUniformLocation(mProgram, "uTMatrix"); + + int maxTextureSizeResult[] = new int[1]; + GLES20.glGetIntegerv(GLES20.GL_MAX_TEXTURE_SIZE, maxTextureSizeResult, 0); + mMaxTextureSize = maxTextureSizeResult[0]; + } + + // Activates the shader program. + public void activateDefaultProgram() { + // Add the program to the OpenGL environment + GLES20.glUseProgram(mProgram); + + // Set the transformation matrix + GLES20.glUniformMatrix4fv(mTMatrixHandle, 1, false, DEFAULT_TEXTURE_MATRIX, 0); + + // Enable the arrays from which we get the vertex and texture coordinates + GLES20.glEnableVertexAttribArray(mPositionHandle); + GLES20.glEnableVertexAttribArray(mTextureHandle); + + GLES20.glUniform1i(mSampleHandle, 0); + } + + // Deactivates the shader program. This must be done to avoid crashes after returning to the + // Gecko C++ compositor from Java. + public void deactivateDefaultProgram() { + GLES20.glDisableVertexAttribArray(mTextureHandle); + GLES20.glDisableVertexAttribArray(mPositionHandle); + GLES20.glUseProgram(0); + } + + public int getMaxTextureSize() { + return mMaxTextureSize; + } + + public void addLayer(Layer layer) { + synchronized (mExtraLayers) { + if (mExtraLayers.contains(layer)) { + mExtraLayers.remove(layer); + } + + mExtraLayers.add(layer); + } + } + + public void removeLayer(Layer layer) { + synchronized (mExtraLayers) { + mExtraLayers.remove(layer); + } + } + + /** + * Called whenever a new frame is about to be drawn. + */ + @Override + public void onDrawFrame(GL10 gl) { + Frame frame = new Frame(mView.getLayerClient().getViewportMetrics()); + synchronized (mView.getLayerClient()) { + frame.beginDrawing(); + frame.drawBackground(); + frame.drawRootLayer(); + frame.drawForeground(); + frame.endDrawing(); + } + } + + private RenderContext createScreenContext(ImmutableViewportMetrics metrics) { + RectF viewport = new RectF(0.0f, 0.0f, metrics.getWidth(), metrics.getHeight()); + RectF pageRect = new RectF(metrics.getPageRect()); + return createContext(viewport, pageRect, 1.0f); + } + + private RenderContext createPageContext(ImmutableViewportMetrics metrics) { + Rect viewport = RectUtils.round(metrics.getViewport()); + RectF pageRect = metrics.getPageRect(); + float zoomFactor = metrics.zoomFactor; + return createContext(new RectF(viewport), pageRect, zoomFactor); + } + + private RenderContext createContext(RectF viewport, RectF pageRect, float zoomFactor) { + return new RenderContext(viewport, pageRect, zoomFactor, mPositionHandle, mTextureHandle, + mCoordBuffer); + } + + @Override + public void onSurfaceChanged(GL10 gl, final int width, final int height) { + GLES20.glViewport(0, 0, width, height); + } + + /* + * create a vertex shader type (GLES20.GL_VERTEX_SHADER) + * or a fragment shader type (GLES20.GL_FRAGMENT_SHADER) + */ + public static int loadShader(int type, String shaderCode) { + int shader = GLES20.glCreateShader(type); + GLES20.glShaderSource(shader, shaderCode); + GLES20.glCompileShader(shader); + return shader; + } + + class FadeRunnable implements Runnable { + private boolean mStarted; + private long mRunAt; + + void scheduleStartFade(long delay) { + mRunAt = SystemClock.elapsedRealtime() + delay; + if (!mStarted) { + mView.postDelayed(this, delay); + mStarted = true; + } + } + + void scheduleNextFadeFrame() { + if (mStarted) { + Log.e(LOGTAG, "scheduleNextFadeFrame() called while scheduled for starting fade"); + } + mView.postDelayed(this, 1000L / 60L); // request another frame at 60fps + } + + boolean timeToFade() { + return !mStarted; + } + + public void run() { + long timeDelta = mRunAt - SystemClock.elapsedRealtime(); + if (timeDelta > 0) { + // the run-at time was pushed back, so reschedule + mView.postDelayed(this, timeDelta); + } else { + // reached the run-at time, execute + mStarted = false; + mView.requestRender(); + } + } + } + + public class Frame { + // A fixed snapshot of the viewport metrics that this frame is using to render content. + private ImmutableViewportMetrics mFrameMetrics; + // A rendering context for page-positioned layers, and one for screen-positioned layers. + private RenderContext mPageContext, mScreenContext; + // Whether a layer was updated. + private boolean mUpdated; + private final Rect mPageRect; + + public Frame(ImmutableViewportMetrics metrics) { + mFrameMetrics = metrics; + mPageContext = createPageContext(metrics); + mScreenContext = createScreenContext(metrics); + mPageRect = getPageRect(); + } + + private void setScissorRect() { + Rect scissorRect = transformToScissorRect(mPageRect); + GLES20.glEnable(GLES20.GL_SCISSOR_TEST); + GLES20.glScissor(scissorRect.left, scissorRect.top, + scissorRect.width(), scissorRect.height()); + } + + private Rect transformToScissorRect(Rect rect) { + IntSize screenSize = new IntSize(mFrameMetrics.getSize()); + + int left = Math.max(0, rect.left); + int top = Math.max(0, rect.top); + int right = Math.min(screenSize.width, rect.right); + int bottom = Math.min(screenSize.height, rect.bottom); + + return new Rect(left, screenSize.height - bottom, right, + (screenSize.height - bottom) + (bottom - top)); + } + + private Rect getPageRect() { + Point origin = PointUtils.round(mFrameMetrics.getOrigin()); + Rect pageRect = RectUtils.round(mFrameMetrics.getPageRect()); + pageRect.offset(-origin.x, -origin.y); + return pageRect; + } + + public void beginDrawing() { + TextureReaper.get().reap(); + TextureGenerator.get().fill(); + + mUpdated = true; + + Layer rootLayer = mView.getLayerClient().getRoot(); + Layer lowResLayer = mView.getLayerClient().getLowResLayer(); + + if (!mPageContext.fuzzyEquals(mLastPageContext)) { + // the viewport or page changed, so show the scrollbars again + // as per UX decision + mVertScrollLayer.unfade(); + mHorizScrollLayer.unfade(); + mFadeRunnable.scheduleStartFade(ScrollbarLayer.FADE_DELAY); + } else if (mFadeRunnable.timeToFade()) { + boolean stillFading = mVertScrollLayer.fade() | mHorizScrollLayer.fade(); + if (stillFading) { + mFadeRunnable.scheduleNextFadeFrame(); + } + } + mLastPageContext = mPageContext; + + /* Update layers. */ + if (rootLayer != null) mUpdated &= rootLayer.update(mPageContext); // called on compositor thread + if (lowResLayer != null) mUpdated &= lowResLayer.update(mPageContext); // called on compositor thread + mUpdated &= mBackgroundLayer.update(mScreenContext); // called on compositor thread + mUpdated &= mShadowLayer.update(mPageContext); // called on compositor thread + mUpdated &= mVertScrollLayer.update(mPageContext); // called on compositor thread + mUpdated &= mHorizScrollLayer.update(mPageContext); // called on compositor thread + + for (Layer layer : mExtraLayers) + mUpdated &= layer.update(mPageContext); // called on compositor thread + } + + public void drawBackground() { + GLES20.glDisable(GLES20.GL_SCISSOR_TEST); + + /* Update background color. */ + final int backgroundColor = Color.WHITE; + + /* Clear to the page background colour. The bits set here need to + * match up with those used in gfx/layers/opengl/LayerManagerOGL.cpp. + */ + GLES20.glClearColor(((backgroundColor >> 16) & 0xFF) / 255.0f, + ((backgroundColor >> 8) & 0xFF) / 255.0f, + (backgroundColor & 0xFF) / 255.0f, + 0.0f); + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | + GLES20.GL_DEPTH_BUFFER_BIT); + + /* Draw the background. */ + mBackgroundLayer.setMask(mPageRect); + mBackgroundLayer.draw(mScreenContext); + + /* Draw the drop shadow, if we need to. */ + RectF untransformedPageRect = new RectF(0.0f, 0.0f, mPageRect.width(), + mPageRect.height()); + if (!untransformedPageRect.contains(mFrameMetrics.getViewport())) + mShadowLayer.draw(mPageContext); + + /* Scissor around the page-rect, in case the page has shrunk + * since the screenshot layer was last updated. + */ + setScissorRect(); // Calls glEnable(GL_SCISSOR_TEST)) + } + + // Draws the layer the client added to us. + void drawRootLayer() { + Layer lowResLayer = mView.getLayerClient().getLowResLayer(); + if (lowResLayer == null) { + return; + } + lowResLayer.draw(mPageContext); + + Layer rootLayer = mView.getLayerClient().getRoot(); + if (rootLayer == null) { + return; + } + + rootLayer.draw(mPageContext); + } + + public void drawForeground() { + /* Draw any extra layers that were added (likely plugins) */ + if (mExtraLayers.size() > 0) { + for (Layer layer : mExtraLayers) { + if (!layer.usesDefaultProgram()) + deactivateDefaultProgram(); + + layer.draw(mPageContext); + + if (!layer.usesDefaultProgram()) + activateDefaultProgram(); + } + } + + /* Draw the vertical scrollbar. */ + if (mPageRect.height() > mFrameMetrics.getHeight()) + mVertScrollLayer.draw(mPageContext); + + /* Draw the horizontal scrollbar. */ + if (mPageRect.width() > mFrameMetrics.getWidth()) + mHorizScrollLayer.draw(mPageContext); + } + + public void endDrawing() { + // If a layer update requires further work, schedule another redraw + if (!mUpdated) + mView.requestRender(); + } + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/LayerView.java b/android/source/src/java/org/mozilla/gecko/gfx/LayerView.java new file mode 100644 index 0000000000..29049f9291 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/LayerView.java @@ -0,0 +1,337 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.PixelFormat; +import android.util.AttributeSet; +import android.util.Log; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.widget.FrameLayout; + +import org.libreoffice.LOEvent; +import org.libreoffice.LOKitShell; +import org.libreoffice.LibreOfficeMainActivity; +import org.libreoffice.R; +import org.mozilla.gecko.OnInterceptTouchListener; +import org.mozilla.gecko.OnSlideSwipeListener; + +/** + * A view rendered by the layer compositor. + * + * This view delegates to LayerRenderer to actually do the drawing. Its role is largely that of a + * mediator between the LayerRenderer and the LayerController. + */ +public class LayerView extends FrameLayout { + private static String LOGTAG = LayerView.class.getName(); + + private GeckoLayerClient mLayerClient; + private PanZoomController mPanZoomController; + private GLController mGLController; + private InputConnectionHandler mInputConnectionHandler; + private LayerRenderer mRenderer; + + private SurfaceView mSurfaceView; + + private Listener mListener; + private OnInterceptTouchListener mTouchIntercepter; + private LibreOfficeMainActivity mContext; + + public LayerView(Context context, AttributeSet attrs) { + super(context, attrs); + mContext = (LibreOfficeMainActivity) context; + + mSurfaceView = new SurfaceView(context); + addView(mSurfaceView, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + + SurfaceHolder holder = mSurfaceView.getHolder(); + holder.addCallback(new SurfaceListener()); + holder.setFormat(PixelFormat.RGB_565); + + mGLController = new GLController(this); + } + + void connect(GeckoLayerClient layerClient) { + mLayerClient = layerClient; + mPanZoomController = mLayerClient.getPanZoomController(); + mRenderer = new LayerRenderer(this); + mInputConnectionHandler = null; + + setFocusable(true); + setFocusableInTouchMode(true); + + createGLThread(); + setOnTouchListener(new OnSlideSwipeListener(getContext(), mLayerClient)); + } + + public void show() { + // Fix this if TextureView support is turned back on above + mSurfaceView.setVisibility(View.VISIBLE); + } + + public void hide() { + // Fix this if TextureView support is turned back on above + mSurfaceView.setVisibility(View.INVISIBLE); + } + + public void destroy() { + if (mLayerClient != null) { + mLayerClient.destroy(); + } + if (mRenderer != null) { + mRenderer.destroy(); + } + } + + public void setTouchIntercepter(final OnInterceptTouchListener touchIntercepter) { + // this gets run on the gecko thread, but for thread safety we want the assignment + // on the UI thread. + post(new Runnable() { + public void run() { + mTouchIntercepter = touchIntercepter; + } + }); + } + + public void setInputConnectionHandler(InputConnectionHandler inputConnectionHandler) { + mInputConnectionHandler = inputConnectionHandler; + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + requestFocus(); + } + + if (mTouchIntercepter != null && mTouchIntercepter.onInterceptTouchEvent(this, event)) { + return true; + } + if (mPanZoomController != null && mPanZoomController.onTouchEvent(event)) { + return true; + } + if (mTouchIntercepter != null && mTouchIntercepter.onTouch(this, event)) { + return true; + } + return false; + } + + @Override + public boolean onHoverEvent(MotionEvent event) { + return mTouchIntercepter != null && mTouchIntercepter.onTouch(this, event); + } + + @Override + public boolean onGenericMotionEvent(MotionEvent event) { + return mPanZoomController != null && mPanZoomController.onMotionEvent(event); + } + + public GeckoLayerClient getLayerClient() { return mLayerClient; } + public PanZoomController getPanZoomController() { return mPanZoomController; } + + public ImmutableViewportMetrics getViewportMetrics() { + return mLayerClient.getViewportMetrics(); + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + if (mInputConnectionHandler != null) + return mInputConnectionHandler.onCreateInputConnection(outAttrs); + return null; + } + + @Override + public boolean onKeyPreIme(int keyCode, KeyEvent event) { + return mInputConnectionHandler != null && mInputConnectionHandler.onKeyPreIme(keyCode, event); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + return mInputConnectionHandler != null && mInputConnectionHandler.onKeyDown(keyCode, event); + } + + @Override + public boolean onKeyLongPress(int keyCode, KeyEvent event) { + return mInputConnectionHandler != null && mInputConnectionHandler.onKeyLongPress(keyCode, event); + } + + @Override + public boolean onKeyMultiple(int keyCode, int repeatCount, KeyEvent event) { + return mInputConnectionHandler != null && mInputConnectionHandler.onKeyMultiple(keyCode, repeatCount, event); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + return mInputConnectionHandler != null && mInputConnectionHandler.onKeyUp(keyCode, event); + } + + public void requestRender() { + if (mListener != null) { + mListener.renderRequested(); + } + } + + public void addLayer(Layer layer) { + mRenderer.addLayer(layer); + } + + public void removeLayer(Layer layer) { + mRenderer.removeLayer(layer); + } + + public int getMaxTextureSize() { + return mRenderer.getMaxTextureSize(); + } + + public void setLayerRenderer(LayerRenderer renderer) { + mRenderer = renderer; + } + + public LayerRenderer getLayerRenderer() { + return mRenderer; + } + + public LayerRenderer getRenderer() { + return mRenderer; + } + + public void setListener(Listener listener) { + mListener = listener; + } + + Listener getListener() { + return mListener; + } + + public GLController getGLController() { + return mGLController; + } + + public Bitmap getDrawable(String name) { + Context context = getContext(); + Resources resources = context.getResources(); + String packageName = resources.getResourcePackageName(R.id.dummy_id_for_package_name_resolution); + int resourceID = resources.getIdentifier(name, "drawable", packageName); + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inScaled = false; + return BitmapFactory.decodeResource(context.getResources(), resourceID, options); + } + + Bitmap getBackgroundPattern() { + return getDrawable("background"); + } + + Bitmap getShadowPattern() { + return getDrawable("shadow"); + } + + private void onSizeChanged(int width, int height) { + mGLController.surfaceChanged(width, height); + + mLayerClient.setViewportSize(new FloatSize(width, height), false); + + if (mListener != null) { + mListener.surfaceChanged(width, height); + } + + LOKitShell.sendEvent(new LOEvent(LOEvent.UPDATE_ZOOM_CONSTRAINTS)); + } + + private void onDestroyed() { + mGLController.surfaceDestroyed(); + + if (mListener != null) { + mListener.compositionPauseRequested(); + } + } + + public Object getNativeWindow() { + return mSurfaceView.getHolder(); + } + + public interface Listener { + void compositorCreated(); + void renderRequested(); + void compositionPauseRequested(); + void surfaceChanged(int width, int height); + } + + private class SurfaceListener implements SurfaceHolder.Callback { + public void surfaceChanged(SurfaceHolder holder, int format, int width, + int height) { + onSizeChanged(width, height); + } + + public void surfaceCreated(SurfaceHolder holder) { + if (mRenderControllerThread != null) { + mRenderControllerThread.surfaceCreated(); + } + } + + public void surfaceDestroyed(SurfaceHolder holder) { + onDestroyed(); + } + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + if (changed) { + mLayerClient.setViewportSize(new FloatSize(right - left, bottom - top), true); + } + } + + private RenderControllerThread mRenderControllerThread; + + public synchronized void createGLThread() { + if (mRenderControllerThread != null) { + throw new LayerViewException ("createGLThread() called with a GL thread already in place!"); + } + + Log.e(LOGTAG, "### Creating GL thread!"); + mRenderControllerThread = new RenderControllerThread(mGLController); + mRenderControllerThread.start(); + setListener(mRenderControllerThread); + notifyAll(); + } + + public synchronized Thread destroyGLThread() { + // Wait for the GL thread to be started. + Log.e(LOGTAG, "### Waiting for GL thread to be created..."); + while (mRenderControllerThread == null) { + try { + wait(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + Log.e(LOGTAG, "### Destroying GL thread!"); + Thread thread = mRenderControllerThread; + mRenderControllerThread.shutdown(); + setListener(null); + mRenderControllerThread = null; + return thread; + } + + public static class LayerViewException extends RuntimeException { + public static final long serialVersionUID = 1L; + + LayerViewException(String e) { + super(e); + } + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/NinePatchTileLayer.java b/android/source/src/java/org/mozilla/gecko/gfx/NinePatchTileLayer.java new file mode 100644 index 0000000000..99f203961a --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/NinePatchTileLayer.java @@ -0,0 +1,131 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.RectF; +import android.opengl.GLES20; + +import java.nio.FloatBuffer; + +/** + * Encapsulates the logic needed to draw a nine-patch bitmap using OpenGL ES. + * + * For more information on nine-patch bitmaps, see the following document: + * http://developer.android.com/guide/topics/graphics/2d-graphics.html#nine-patch + */ +public class NinePatchTileLayer extends TileLayer { + private static final int PATCH_SIZE = 16; + private static final int TEXTURE_SIZE = 64; + + public NinePatchTileLayer(CairoImage image) { + super(image, PaintMode.NORMAL); + } + + @Override + public void draw(RenderContext context) { + if (!initialized()) + return; + + GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA); + GLES20.glEnable(GLES20.GL_BLEND); + + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, getTextureID()); + drawPatches(context); + } + + private void drawPatches(RenderContext context) { + /* + * We divide the nine-patch bitmap up as follows: + * + * +---+---+---+ + * | 0 | 1 | 2 | + * +---+---+---+ + * | 3 | | 4 | + * +---+---+---+ + * | 5 | 6 | 7 | + * +---+---+---+ + */ + + // page is the rect of the "missing" center spot in the picture above + RectF page = context.pageRect; + + drawPatch(context, 0, PATCH_SIZE * 3, /* 0 */ + page.left - PATCH_SIZE, page.top - PATCH_SIZE, PATCH_SIZE, PATCH_SIZE); + drawPatch(context, PATCH_SIZE, PATCH_SIZE * 3, /* 1 */ + page.left, page.top - PATCH_SIZE, page.width(), PATCH_SIZE); + drawPatch(context, PATCH_SIZE * 2, PATCH_SIZE * 3, /* 2 */ + page.right, page.top - PATCH_SIZE, PATCH_SIZE, PATCH_SIZE); + drawPatch(context, 0, PATCH_SIZE * 2, /* 3 */ + page.left - PATCH_SIZE, page.top, PATCH_SIZE, page.height()); + drawPatch(context, PATCH_SIZE * 2, PATCH_SIZE * 2, /* 4 */ + page.right, page.top, PATCH_SIZE, page.height()); + drawPatch(context, 0, PATCH_SIZE, /* 5 */ + page.left - PATCH_SIZE, page.bottom, PATCH_SIZE, PATCH_SIZE); + drawPatch(context, PATCH_SIZE, PATCH_SIZE, /* 6 */ + page.left, page.bottom, page.width(), PATCH_SIZE); + drawPatch(context, PATCH_SIZE * 2, PATCH_SIZE, /* 7 */ + page.right, page.bottom, PATCH_SIZE, PATCH_SIZE); + } + + private void drawPatch(RenderContext context, int textureX, int textureY, + float tileX, float tileY, float tileWidth, float tileHeight) { + RectF viewport = context.viewport; + float viewportHeight = viewport.height(); + float drawX = tileX - viewport.left; + float drawY = viewportHeight - (tileY + tileHeight - viewport.top); + + float[] coords = { + //x, y, z, texture_x, texture_y + drawX/viewport.width(), drawY/viewport.height(), 0, + textureX/(float)TEXTURE_SIZE, textureY/(float)TEXTURE_SIZE, + + drawX/viewport.width(), (drawY+tileHeight)/viewport.height(), 0, + textureX/(float)TEXTURE_SIZE, (textureY+PATCH_SIZE)/(float)TEXTURE_SIZE, + + (drawX+tileWidth)/viewport.width(), drawY/viewport.height(), 0, + (textureX+PATCH_SIZE)/(float)TEXTURE_SIZE, textureY/(float)TEXTURE_SIZE, + + (drawX+tileWidth)/viewport.width(), (drawY+tileHeight)/viewport.height(), 0, + (textureX+PATCH_SIZE)/(float)TEXTURE_SIZE, (textureY+PATCH_SIZE)/(float)TEXTURE_SIZE + + }; + + // Get the buffer and handles from the context + FloatBuffer coordBuffer = context.coordBuffer; + int positionHandle = context.positionHandle; + int textureHandle = context.textureHandle; + + // Make sure we are at position zero in the buffer in case other draw methods did not clean + // up after themselves + coordBuffer.position(0); + coordBuffer.put(coords); + + // Unbind any the current array buffer so we can use client side buffers + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0); + + // Vertex coordinates are x,y,z starting at position 0 into the buffer. + coordBuffer.position(0); + GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false, 20, coordBuffer); + + // Texture coordinates are texture_x, texture_y starting at position 3 into the buffer. + coordBuffer.position(3); + GLES20.glVertexAttribPointer(textureHandle, 2, GLES20.GL_FLOAT, false, 20, coordBuffer); + + GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, + GLES20.GL_CLAMP_TO_EDGE); + GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, + GLES20.GL_CLAMP_TO_EDGE); + + // Use bilinear filtering for both magnification and minimization of the texture. This + // applies only to the shadow layer so we do not incur a high overhead. + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, + GLES20.GL_LINEAR); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, + GLES20.GL_LINEAR); + + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/PanZoomController.java b/android/source/src/java/org/mozilla/gecko/gfx/PanZoomController.java new file mode 100644 index 0000000000..ebcd641f21 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/PanZoomController.java @@ -0,0 +1,36 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.PointF; +import android.view.MotionEvent; +import android.view.View; +import org.libreoffice.LibreOfficeMainActivity; + +interface PanZoomController { + + class Factory { + static PanZoomController create(LibreOfficeMainActivity context, PanZoomTarget target, View view) { + return new JavaPanZoomController(context, target, view); + } + } + + void destroy(); + + boolean onTouchEvent(MotionEvent event); + boolean onMotionEvent(MotionEvent event); + void notifyDefaultActionPrevented(boolean prevented); + + boolean getRedrawHint(); + PointF getVelocityVector(); + + void pageRectUpdated(); + void abortPanning(); + void abortAnimation(); + + void setOverScrollMode(int overscrollMode); + int getOverScrollMode(); +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/PanZoomTarget.java b/android/source/src/java/org/mozilla/gecko/gfx/PanZoomTarget.java new file mode 100644 index 0000000000..88e1b216c6 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/PanZoomTarget.java @@ -0,0 +1,26 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.PointF; + +import org.mozilla.gecko.ZoomConstraints; + +public interface PanZoomTarget { + public ImmutableViewportMetrics getViewportMetrics(); + public ZoomConstraints getZoomConstraints(); + + public void setAnimationTarget(ImmutableViewportMetrics viewport); + public void setViewportMetrics(ImmutableViewportMetrics viewport); + /** This triggers an (asynchronous) viewport update/redraw. */ + public void forceRedraw(); + + public boolean post(Runnable action); + public Object getLock(); + public PointF convertViewPointToLayerPoint(PointF viewPoint); + + boolean isFullScreen(); +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/PointUtils.java b/android/source/src/java/org/mozilla/gecko/gfx/PointUtils.java new file mode 100644 index 0000000000..4eff380527 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/PointUtils.java @@ -0,0 +1,53 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.Point; +import android.graphics.PointF; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.lang.StrictMath; + +public final class PointUtils { + public static PointF add(PointF one, PointF two) { + return new PointF(one.x + two.x, one.y + two.y); + } + + public static PointF subtract(PointF one, PointF two) { + return new PointF(one.x - two.x, one.y - two.y); + } + + public static PointF scale(PointF point, float factor) { + return new PointF(point.x * factor, point.y * factor); + } + + public static Point round(PointF point) { + return new Point(Math.round(point.x), Math.round(point.y)); + } + + /* Computes the magnitude of the given vector. */ + public static float distance(PointF point) { + return (float)StrictMath.hypot(point.x, point.y); + } + + /** Computes the scalar distance between two points. */ + public static float distance(PointF one, PointF two) { + return PointF.length(one.x - two.x, one.y - two.y); + } + + public static JSONObject toJSON(PointF point) throws JSONException { + // Ensure we put ints, not longs, because Gecko message handlers call getInt(). + int x = Math.round(point.x); + int y = Math.round(point.y); + JSONObject json = new JSONObject(); + json.put("x", x); + json.put("y", y); + return json; + } +} + diff --git a/android/source/src/java/org/mozilla/gecko/gfx/RectUtils.java b/android/source/src/java/org/mozilla/gecko/gfx/RectUtils.java new file mode 100644 index 0000000000..e7fa540a39 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/RectUtils.java @@ -0,0 +1,110 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.Rect; +import android.graphics.RectF; + +import org.mozilla.gecko.util.FloatUtils; + +public final class RectUtils { + private RectUtils() {} + + public static RectF expand(RectF rect, float moreWidth, float moreHeight) { + float halfMoreWidth = moreWidth / 2; + float halfMoreHeight = moreHeight / 2; + return new RectF(rect.left - halfMoreWidth, + rect.top - halfMoreHeight, + rect.right + halfMoreWidth, + rect.bottom + halfMoreHeight); + } + + public static RectF contract(RectF rect, float lessWidth, float lessHeight) { + float halfLessWidth = lessWidth / 2.0f; + float halfLessHeight = lessHeight / 2.0f; + return new RectF(rect.left + halfLessWidth, + rect.top + halfLessHeight, + rect.right - halfLessWidth, + rect.bottom - halfLessHeight); + } + + public static RectF intersect(RectF one, RectF two) { + float left = Math.max(one.left, two.left); + float top = Math.max(one.top, two.top); + float right = Math.min(one.right, two.right); + float bottom = Math.min(one.bottom, two.bottom); + return new RectF(left, top, Math.max(right, left), Math.max(bottom, top)); + } + + public static RectF scale(RectF rect, float scale) { + float x = rect.left * scale; + float y = rect.top * scale; + return new RectF(x, y, + x + (rect.width() * scale), + y + (rect.height() * scale)); + } + + public static RectF inverseScale(RectF rect, float scale) { + float x = rect.left / scale; + float y = rect.top / scale; + return new RectF(x, y, + x + (rect.width() / scale), + y + (rect.height() / scale)); + } + + /** Returns the nearest integer rect of the given rect. */ + public static Rect round(RectF rect) { + Rect r = new Rect(); + round(rect, r); + return r; + } + + public static void round(RectF rect, Rect dest) { + dest.set(Math.round(rect.left), Math.round(rect.top), + Math.round(rect.right), Math.round(rect.bottom)); + } + + public static Rect roundIn(RectF rect) { + return new Rect((int)Math.ceil(rect.left), (int)Math.ceil(rect.top), + (int)Math.floor(rect.right), (int)Math.floor(rect.bottom)); + } + + public static IntSize getSize(Rect rect) { + return new IntSize(rect.width(), rect.height()); + } + + public static Point getOrigin(Rect rect) { + return new Point(rect.left, rect.top); + } + + public static PointF getOrigin(RectF rect) { + return new PointF(rect.left, rect.top); + } + + public static boolean fuzzyEquals(RectF a, RectF b) { + if (a == null && b == null) + return true; + else + return a != null && b != null + && FloatUtils.fuzzyEquals(a.top, b.top) + && FloatUtils.fuzzyEquals(a.left, b.left) + && FloatUtils.fuzzyEquals(a.right, b.right) + && FloatUtils.fuzzyEquals(a.bottom, b.bottom); + } + + /** + * Assign rectangle values from source to target. + */ + public static void assign(final RectF target, final RectF source) + { + target.left = source.left; + target.top = source.top; + target.right = source.right; + target.bottom = source.bottom; + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/RenderControllerThread.java b/android/source/src/java/org/mozilla/gecko/gfx/RenderControllerThread.java new file mode 100644 index 0000000000..5c74d56a00 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/RenderControllerThread.java @@ -0,0 +1,143 @@ +package org.mozilla.gecko.gfx; + +import android.opengl.GLSurfaceView; + +import java.util.concurrent.LinkedBlockingQueue; + +import javax.microedition.khronos.opengles.GL10; + +/** + * Thread which controls the rendering to OpenGL context. Render commands are queued and + * processed and delegated by this thread. + */ +public class RenderControllerThread extends Thread implements LayerView.Listener { + private LinkedBlockingQueue<RenderCommand> queue = new LinkedBlockingQueue<RenderCommand>(); + private GLController controller; + private boolean renderQueued = false; + private int width; + private int height; + + public RenderControllerThread(GLController controller) { + this.controller = controller; + } + + @Override + public void run() { + while (true) { + RenderCommand command; + try { + command = queue.take(); + execute(command); + if (command == RenderCommand.SHUTDOWN) { + return; + } + } catch (InterruptedException exception) { + throw new RuntimeException(exception); + } + } + } + + void execute(RenderCommand command) { + switch (command) { + case SHUTDOWN: + doShutdown(); + break; + case RENDER_FRAME: + doRenderFrame(); + break; + case SIZE_CHANGED: + doSizeChanged(); + break; + case SURFACE_CREATED: + doSurfaceCreated(); + break; + case SURFACE_DESTROYED: + doSurfaceDestroyed(); + break; + } + } + + public void shutdown() { + queue.add(RenderCommand.SHUTDOWN); + } + + @Override + public void compositorCreated() { + + } + + @Override + public void renderRequested() { + synchronized (this) { + if (!renderQueued) { + queue.add(RenderCommand.RENDER_FRAME); + renderQueued = true; + } + } + } + + @Override + public void compositionPauseRequested() { + queue.add(RenderCommand.SURFACE_DESTROYED); + } + + @Override + public void surfaceChanged(int width, int height) { + this.width = width; + this.height = height; + queue.add(RenderCommand.SIZE_CHANGED); + } + + public void surfaceCreated() { + queue.add(RenderCommand.SURFACE_CREATED); + } + + private GLSurfaceView.Renderer getRenderer() { + return controller.getView().getRenderer(); + } + + private void doShutdown() { + controller.disposeGLContext(); + controller = null; + } + + private void doRenderFrame() { + synchronized (this) { + renderQueued = false; + } + if (controller.getEGLSurface() == null) { + return; + } + GLSurfaceView.Renderer renderer = getRenderer(); + if (renderer != null) { + renderer.onDrawFrame(controller.getGL()); + } + controller.swapBuffers(); + } + + private void doSizeChanged() { + GLSurfaceView.Renderer renderer = getRenderer(); + if (renderer != null) { + renderer.onSurfaceChanged(controller.getGL(), width, height); + } + } + + private void doSurfaceCreated() { + if (!controller.hasSurface()) { + controller.initGLContext(); + } + } + + private void doSurfaceDestroyed() { + controller.disposeGLContext(); + } + + public enum RenderCommand { + SHUTDOWN, + RECREATE_SURFACE, + RENDER_FRAME, + SIZE_CHANGED, + SURFACE_CREATED, + SURFACE_DESTROYED, + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/ScrollbarLayer.java b/android/source/src/java/org/mozilla/gecko/gfx/ScrollbarLayer.java new file mode 100644 index 0000000000..7ef8ff0206 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/ScrollbarLayer.java @@ -0,0 +1,451 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.Rect; +import android.graphics.RectF; +import android.opengl.GLES20; + +import org.libreoffice.kit.DirectBufferAllocator; +import org.mozilla.gecko.util.FloatUtils; + +import java.nio.ByteBuffer; +import java.nio.FloatBuffer; + +/** + * Draws a small rect. This is scaled to become a scrollbar. + */ +public class ScrollbarLayer extends TileLayer { + private static String LOGTAG = LayerView.class.getName(); + public static final long FADE_DELAY = 500; // milliseconds before fade-out starts + private static final float FADE_AMOUNT = 0.03f; // how much (as a percent) the scrollbar should fade per frame + + private static final int PADDING = 1; // gap between scrollbar and edge of viewport + private static final int BAR_SIZE = 6; + private static final int CAP_RADIUS = (BAR_SIZE / 2); + + private final boolean mVertical; + private final Bitmap mBitmap; + private final Canvas mCanvas; + private float mOpacity; + + private LayerRenderer mRenderer; + private int mProgram; + private int mPositionHandle; + private int mTextureHandle; + private int mSampleHandle; + private int mTMatrixHandle; + private int mOpacityHandle; + + // Fragment shader used to draw the scroll-bar with opacity + private static final String FRAGMENT_SHADER = + "precision mediump float;\n" + + "varying vec2 vTexCoord;\n" + + "uniform sampler2D sTexture;\n" + + "uniform float uOpacity;\n" + + "void main() {\n" + + " gl_FragColor = texture2D(sTexture, vec2(vTexCoord.x, 1.0 - vTexCoord.y));\n" + + " gl_FragColor.a *= uOpacity;\n" + + "}\n"; + + // Dimensions of the texture image + private static final float TEX_HEIGHT = 8.0f; + private static final float TEX_WIDTH = 8.0f; + + // Texture coordinates for the scrollbar's body + // We take a 1x1 pixel from the center of the image and scale it to become the bar + private static final float[] BODY_TEX_COORDS = { + // x, y + CAP_RADIUS/TEX_WIDTH, CAP_RADIUS/TEX_HEIGHT, + CAP_RADIUS/TEX_WIDTH, (CAP_RADIUS+1)/TEX_HEIGHT, + (CAP_RADIUS+1)/TEX_WIDTH, CAP_RADIUS/TEX_HEIGHT, + (CAP_RADIUS+1)/TEX_WIDTH, (CAP_RADIUS+1)/TEX_HEIGHT + }; + + // Texture coordinates for the top cap of the scrollbar + private static final float[] TOP_CAP_TEX_COORDS = { + // x, y + 0 , 1.0f - CAP_RADIUS/TEX_HEIGHT, + 0 , 1.0f, + BAR_SIZE/TEX_WIDTH, 1.0f - CAP_RADIUS/TEX_HEIGHT, + BAR_SIZE/TEX_WIDTH, 1.0f + }; + + // Texture coordinates for the bottom cap of the scrollbar + private static final float[] BOT_CAP_TEX_COORDS = { + // x, y + 0 , 1.0f - BAR_SIZE/TEX_HEIGHT, + 0 , 1.0f - CAP_RADIUS/TEX_HEIGHT, + BAR_SIZE/TEX_WIDTH, 1.0f - BAR_SIZE/TEX_HEIGHT, + BAR_SIZE/TEX_WIDTH, 1.0f - CAP_RADIUS/TEX_HEIGHT + }; + + // Texture coordinates for the left cap of the scrollbar + private static final float[] LEFT_CAP_TEX_COORDS = { + // x, y + 0 , 1.0f - BAR_SIZE/TEX_HEIGHT, + 0 , 1.0f, + CAP_RADIUS/TEX_WIDTH, 1.0f - BAR_SIZE/TEX_HEIGHT, + CAP_RADIUS/TEX_WIDTH, 1.0f + }; + + // Texture coordinates for the right cap of the scrollbar + private static final float[] RIGHT_CAP_TEX_COORDS = { + // x, y + CAP_RADIUS/TEX_WIDTH, 1.0f - BAR_SIZE/TEX_HEIGHT, + CAP_RADIUS/TEX_WIDTH, 1.0f, + BAR_SIZE/TEX_WIDTH , 1.0f - BAR_SIZE/TEX_HEIGHT, + BAR_SIZE/TEX_WIDTH , 1.0f + }; + + private ScrollbarLayer(LayerRenderer renderer, CairoImage image, boolean vertical, ByteBuffer buffer) { + super(image, TileLayer.PaintMode.NORMAL); + mVertical = vertical; + mRenderer = renderer; + + IntSize size = image.getSize(); + mBitmap = Bitmap.createBitmap(size.width, size.height, Bitmap.Config.ARGB_8888); + mCanvas = new Canvas(mBitmap); + + // Paint a spot to use as the scroll indicator + Paint foregroundPaint = new Paint(); + foregroundPaint.setAntiAlias(true); + foregroundPaint.setStyle(Paint.Style.FILL); + foregroundPaint.setColor(Color.argb(127, 0, 0, 0)); + + mCanvas.drawColor(Color.argb(0, 0, 0, 0), PorterDuff.Mode.CLEAR); + mCanvas.drawCircle(CAP_RADIUS, CAP_RADIUS, CAP_RADIUS, foregroundPaint); + + mBitmap.copyPixelsToBuffer(buffer.asIntBuffer()); + } + + public static ScrollbarLayer create(LayerRenderer renderer, boolean vertical) { + // just create an empty image for now, it will get drawn + // on demand anyway + int imageSize = IntSize.nextPowerOfTwo(BAR_SIZE); + ByteBuffer buffer = DirectBufferAllocator.allocate(imageSize * imageSize * 4); + CairoImage image = new BufferedCairoImage(buffer, imageSize, imageSize, + CairoImage.FORMAT_ARGB32); + return new ScrollbarLayer(renderer, image, vertical, buffer); + } + + private void createProgram() { + int vertexShader = LayerRenderer.loadShader(GLES20.GL_VERTEX_SHADER, + LayerRenderer.DEFAULT_VERTEX_SHADER); + int fragmentShader = LayerRenderer.loadShader(GLES20.GL_FRAGMENT_SHADER, + FRAGMENT_SHADER); + + mProgram = GLES20.glCreateProgram(); + GLES20.glAttachShader(mProgram, vertexShader); // add the vertex shader to program + GLES20.glAttachShader(mProgram, fragmentShader); // add the fragment shader to program + GLES20.glLinkProgram(mProgram); // creates OpenGL program executables + + // Get handles to the shaders' vPosition, aTexCoord, sTexture, and uTMatrix members. + mPositionHandle = GLES20.glGetAttribLocation(mProgram, "vPosition"); + mTextureHandle = GLES20.glGetAttribLocation(mProgram, "aTexCoord"); + mSampleHandle = GLES20.glGetUniformLocation(mProgram, "sTexture"); + mTMatrixHandle = GLES20.glGetUniformLocation(mProgram, "uTMatrix"); + mOpacityHandle = GLES20.glGetUniformLocation(mProgram, "uOpacity"); + } + + private void activateProgram() { + // Add the program to the OpenGL environment + GLES20.glUseProgram(mProgram); + + // Set the transformation matrix + GLES20.glUniformMatrix4fv(mTMatrixHandle, 1, false, + LayerRenderer.DEFAULT_TEXTURE_MATRIX, 0); + + // Enable the arrays from which we get the vertex and texture coordinates + GLES20.glEnableVertexAttribArray(mPositionHandle); + GLES20.glEnableVertexAttribArray(mTextureHandle); + + GLES20.glUniform1i(mSampleHandle, 0); + GLES20.glUniform1f(mOpacityHandle, mOpacity); + } + + private void deactivateProgram() { + GLES20.glDisableVertexAttribArray(mTextureHandle); + GLES20.glDisableVertexAttribArray(mPositionHandle); + GLES20.glUseProgram(0); + } + + /** + * Decrease the opacity of the scrollbar by one frame's worth. + * Return true if the opacity was decreased, or false if the scrollbars + * are already fully faded out. + */ + public boolean fade() { + if (FloatUtils.fuzzyEquals(mOpacity, 0.0f)) { + return false; + } + beginTransaction(); // called on compositor thread + mOpacity = Math.max(mOpacity - FADE_AMOUNT, 0.0f); + endTransaction(); + return true; + } + + /** + * Restore the opacity of the scrollbar to fully opaque. + * Return true if the opacity was changed, or false if the scrollbars + * are already fully opaque. + */ + public boolean unfade() { + if (FloatUtils.fuzzyEquals(mOpacity, 1.0f)) { + return false; + } + beginTransaction(); // called on compositor thread + mOpacity = 1.0f; + endTransaction(); + + return true; + } + + @Override + public void draw(RenderContext context) { + if (!initialized()) + return; + + // Create the shader program, if necessary + if (mProgram == 0) { + createProgram(); + } + + // Enable the shader program + mRenderer.deactivateDefaultProgram(); + activateProgram(); + + GLES20.glEnable(GLES20.GL_BLEND); + GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA); + + Rect rect = RectUtils.round(mVertical + ? getVerticalRect(context) + : getHorizontalRect(context)); + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, getTextureID()); + + float viewWidth = context.viewport.width(); + float viewHeight = context.viewport.height(); + + float top = viewHeight - rect.top; + float bot = viewHeight - rect.bottom; + + // Coordinates for the scrollbar's body combined with the texture coordinates + float[] bodyCoords = { + // x, y, z, texture_x, texture_y + rect.left/viewWidth, bot/viewHeight, 0, + BODY_TEX_COORDS[0], BODY_TEX_COORDS[1], + + rect.left/viewWidth, (bot+rect.height())/viewHeight, 0, + BODY_TEX_COORDS[2], BODY_TEX_COORDS[3], + + (rect.left+rect.width())/viewWidth, bot/viewHeight, 0, + BODY_TEX_COORDS[4], BODY_TEX_COORDS[5], + + (rect.left+rect.width())/viewWidth, (bot+rect.height())/viewHeight, 0, + BODY_TEX_COORDS[6], BODY_TEX_COORDS[7] + }; + + // Get the buffer and handles from the context + FloatBuffer coordBuffer = context.coordBuffer; + int positionHandle = mPositionHandle; + int textureHandle = mTextureHandle; + + // Make sure we are at position zero in the buffer in case other draw methods did not + // clean up after themselves + coordBuffer.position(0); + coordBuffer.put(bodyCoords); + + // Unbind any the current array buffer so we can use client side buffers + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0); + + // Vertex coordinates are x,y,z starting at position 0 into the buffer. + coordBuffer.position(0); + GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false, 20, + coordBuffer); + + // Texture coordinates are texture_x, texture_y starting at position 3 into the buffer. + coordBuffer.position(3); + GLES20.glVertexAttribPointer(textureHandle, 2, GLES20.GL_FLOAT, false, 20, + coordBuffer); + + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + + // Reset the position in the buffer for the next set of vertex and texture coordinates. + coordBuffer.position(0); + + if (mVertical) { + // top endcap + float[] topCap = { + // x, y, z, texture_x, texture_y + rect.left/viewWidth, top/viewHeight, 0, + TOP_CAP_TEX_COORDS[0], TOP_CAP_TEX_COORDS[1], + + rect.left/viewWidth, (top+CAP_RADIUS)/viewHeight, 0, + TOP_CAP_TEX_COORDS[2], TOP_CAP_TEX_COORDS[3], + + (rect.left+BAR_SIZE)/viewWidth, top/viewHeight, 0, + TOP_CAP_TEX_COORDS[4], TOP_CAP_TEX_COORDS[5], + + (rect.left+BAR_SIZE)/viewWidth, (top+CAP_RADIUS)/viewHeight, 0, + TOP_CAP_TEX_COORDS[6], TOP_CAP_TEX_COORDS[7] + }; + + coordBuffer.put(topCap); + + // Vertex coordinates are x,y,z starting at position 0 into the buffer. + coordBuffer.position(0); + GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false, 20, + coordBuffer); + + // Texture coordinates are texture_x, texture_y starting at position 3 into the + // buffer. + coordBuffer.position(3); + GLES20.glVertexAttribPointer(textureHandle, 2, GLES20.GL_FLOAT, false, 20, + coordBuffer); + + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + + // Reset the position in the buffer for the next set of vertex and texture + // coordinates. + coordBuffer.position(0); + + // bottom endcap + float[] botCap = { + // x, y, z, texture_x, texture_y + rect.left/viewWidth, (bot-CAP_RADIUS)/viewHeight, 0, + BOT_CAP_TEX_COORDS[0], BOT_CAP_TEX_COORDS[1], + + rect.left/viewWidth, (bot)/viewHeight, 0, + BOT_CAP_TEX_COORDS[2], BOT_CAP_TEX_COORDS[3], + + (rect.left+BAR_SIZE)/viewWidth, (bot-CAP_RADIUS)/viewHeight, 0, + BOT_CAP_TEX_COORDS[4], BOT_CAP_TEX_COORDS[5], + + (rect.left+BAR_SIZE)/viewWidth, (bot)/viewHeight, 0, + BOT_CAP_TEX_COORDS[6], BOT_CAP_TEX_COORDS[7] + }; + + coordBuffer.put(botCap); + + // Vertex coordinates are x,y,z starting at position 0 into the buffer. + coordBuffer.position(0); + GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false, 20, + coordBuffer); + + // Texture coordinates are texture_x, texture_y starting at position 3 into the + // buffer. + coordBuffer.position(3); + GLES20.glVertexAttribPointer(textureHandle, 2, GLES20.GL_FLOAT, false, 20, + coordBuffer); + + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + + // Reset the position in the buffer for the next set of vertex and texture + // coordinates. + coordBuffer.position(0); + } else { + // left endcap + float[] leftCap = { + // x, y, z, texture_x, texture_y + (rect.left-CAP_RADIUS)/viewWidth, bot/viewHeight, 0, + LEFT_CAP_TEX_COORDS[0], LEFT_CAP_TEX_COORDS[1], + (rect.left-CAP_RADIUS)/viewWidth, (bot+BAR_SIZE)/viewHeight, 0, + LEFT_CAP_TEX_COORDS[2], LEFT_CAP_TEX_COORDS[3], + (rect.left)/viewWidth, bot/viewHeight, 0, LEFT_CAP_TEX_COORDS[4], + LEFT_CAP_TEX_COORDS[5], + (rect.left)/viewWidth, (bot+BAR_SIZE)/viewHeight, 0, + LEFT_CAP_TEX_COORDS[6], LEFT_CAP_TEX_COORDS[7] + }; + + coordBuffer.put(leftCap); + + // Vertex coordinates are x,y,z starting at position 0 into the buffer. + coordBuffer.position(0); + GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false, 20, + coordBuffer); + + // Texture coordinates are texture_x, texture_y starting at position 3 into the + // buffer. + coordBuffer.position(3); + GLES20.glVertexAttribPointer(textureHandle, 2, GLES20.GL_FLOAT, false, 20, + coordBuffer); + + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + + // Reset the position in the buffer for the next set of vertex and texture + // coordinates. + coordBuffer.position(0); + + // right endcap + float[] rightCap = { + // x, y, z, texture_x, texture_y + rect.right/viewWidth, (bot)/viewHeight, 0, + RIGHT_CAP_TEX_COORDS[0], RIGHT_CAP_TEX_COORDS[1], + + rect.right/viewWidth, (bot+BAR_SIZE)/viewHeight, 0, + RIGHT_CAP_TEX_COORDS[2], RIGHT_CAP_TEX_COORDS[3], + + (rect.right+CAP_RADIUS)/viewWidth, (bot)/viewHeight, 0, + RIGHT_CAP_TEX_COORDS[4], RIGHT_CAP_TEX_COORDS[5], + + (rect.right+CAP_RADIUS)/viewWidth, (bot+BAR_SIZE)/viewHeight, 0, + RIGHT_CAP_TEX_COORDS[6], RIGHT_CAP_TEX_COORDS[7] + }; + + coordBuffer.put(rightCap); + + // Vertex coordinates are x,y,z starting at position 0 into the buffer. + coordBuffer.position(0); + GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false, 20, + coordBuffer); + + // Texture coordinates are texture_x, texture_y starting at position 3 into the + // buffer. + coordBuffer.position(3); + GLES20.glVertexAttribPointer(textureHandle, 2, GLES20.GL_FLOAT, false, 20, + coordBuffer); + + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + } + + // Enable the default shader program again + deactivateProgram(); + mRenderer.activateDefaultProgram(); + } + + private RectF getVerticalRect(RenderContext context) { + RectF viewport = context.viewport; + RectF pageRect = context.pageRect; + float barStart = ((viewport.top - pageRect.top) * (viewport.height() / pageRect.height())) + CAP_RADIUS; + float barEnd = ((viewport.bottom - pageRect.top) * (viewport.height() / pageRect.height())) - CAP_RADIUS; + if (barStart > barEnd) { + float middle = (barStart + barEnd) / 2.0f; + barStart = barEnd = middle; + } + float right = viewport.width() - PADDING; + return new RectF(right - BAR_SIZE, barStart, right, barEnd); + } + + private RectF getHorizontalRect(RenderContext context) { + RectF viewport = context.viewport; + RectF pageRect = context.pageRect; + float barStart = ((viewport.left - pageRect.left) * (viewport.width() / pageRect.width())) + CAP_RADIUS; + float barEnd = ((viewport.right - pageRect.left) * (viewport.width() / pageRect.width())) - CAP_RADIUS; + if (barStart > barEnd) { + float middle = (barStart + barEnd) / 2.0f; + barStart = barEnd = middle; + } + float bottom = viewport.height() - PADDING; + return new RectF(barStart, bottom - BAR_SIZE, barEnd, bottom); + } +} + +/* vim:set shiftwidth=4 softtabstop=4 expandtab: */ diff --git a/android/source/src/java/org/mozilla/gecko/gfx/SimpleScaleGestureDetector.java b/android/source/src/java/org/mozilla/gecko/gfx/SimpleScaleGestureDetector.java new file mode 100644 index 0000000000..e89015b5ed --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/SimpleScaleGestureDetector.java @@ -0,0 +1,322 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.PointF; +import android.util.Log; +import android.view.MotionEvent; + +import org.json.JSONException; + +import java.util.LinkedList; +import java.util.ListIterator; +import java.util.Stack; + +/** + * A less buggy, and smoother, replacement for the built-in Android ScaleGestureDetector. + * + * This gesture detector is more reliable than the built-in ScaleGestureDetector because: + * + * - It doesn't assume that pointer IDs are numbered 0 and 1. + * + * - It doesn't attempt to correct for "slop" when resting one's hand on the device. On some + * devices (e.g. the Droid X) this can cause the ScaleGestureDetector to lose track of how many + * pointers are down, with disastrous results (bug 706684). + * + * - Cancelling a zoom into a pan is handled correctly. + * + * - Starting with three or more fingers down, releasing fingers so that only two are down, and + * then performing a scale gesture is handled correctly. + * + * - It doesn't take pressure into account, which results in smoother scaling. + */ +public class SimpleScaleGestureDetector { + private static final String LOGTAG = "ScaleGestureDetector"; + + private SimpleScaleGestureListener mListener; + private long mLastEventTime; + private boolean mScaleResult; + + /* Information about all pointers that are down. */ + private LinkedList<PointerInfo> mPointerInfo; + + /** Creates a new gesture detector with the given listener. */ + public SimpleScaleGestureDetector(SimpleScaleGestureListener listener) { + mListener = listener; + mPointerInfo = new LinkedList<PointerInfo>(); + } + + /** Forward touch events to this function. */ + public void onTouchEvent(MotionEvent event) { + switch (event.getAction() & MotionEvent.ACTION_MASK) { + case MotionEvent.ACTION_DOWN: + // If we get ACTION_DOWN while still tracking any pointers, + // something is wrong. Cancel the current gesture and start over. + if (getPointersDown() > 0) + onTouchEnd(event); + onTouchStart(event); + break; + case MotionEvent.ACTION_POINTER_DOWN: + onTouchStart(event); + break; + case MotionEvent.ACTION_MOVE: + onTouchMove(event); + break; + case MotionEvent.ACTION_POINTER_UP: + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + onTouchEnd(event); + break; + } + } + + private int getPointersDown() { + return mPointerInfo.size(); + } + + private int getActionIndex(MotionEvent event) { + return (event.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) + >> MotionEvent.ACTION_POINTER_INDEX_SHIFT; + } + + private void onTouchStart(MotionEvent event) { + mLastEventTime = event.getEventTime(); + mPointerInfo.addFirst(PointerInfo.create(event, getActionIndex(event))); + if (getPointersDown() == 2) { + sendScaleGesture(EventType.BEGIN); + } + } + + private void onTouchMove(MotionEvent event) { + mLastEventTime = event.getEventTime(); + for (int i = 0; i < event.getPointerCount(); i++) { + PointerInfo pointerInfo = pointerInfoForEventIndex(event, i); + if (pointerInfo != null) { + pointerInfo.populate(event, i); + } + } + + if (getPointersDown() == 2) { + sendScaleGesture(EventType.CONTINUE); + } + } + + private void onTouchEnd(MotionEvent event) { + mLastEventTime = event.getEventTime(); + + int action = event.getAction() & MotionEvent.ACTION_MASK; + boolean isCancel = (action == MotionEvent.ACTION_CANCEL || + action == MotionEvent.ACTION_DOWN); + + int id = event.getPointerId(getActionIndex(event)); + ListIterator<PointerInfo> iterator = mPointerInfo.listIterator(); + while (iterator.hasNext()) { + PointerInfo pointerInfo = iterator.next(); + if (!(isCancel || pointerInfo.getId() == id)) { + continue; + } + + // One of the pointers we were tracking was lifted. Remove its info object from the + // list, recycle it to avoid GC pauses, and send an onScaleEnd() notification if this + // ended the gesture. + iterator.remove(); + pointerInfo.recycle(); + if (getPointersDown() == 1) { + sendScaleGesture(EventType.END); + } + } + } + + /** + * Returns the X coordinate of the focus location (the midpoint of the two fingers). If only + * one finger is down, returns the location of that finger. + */ + public float getFocusX() { + switch (getPointersDown()) { + case 1: + return mPointerInfo.getFirst().getCurrent().x; + case 2: + PointerInfo pointerA = mPointerInfo.getFirst(), pointerB = mPointerInfo.getLast(); + return (pointerA.getCurrent().x + pointerB.getCurrent().x) / 2.0f; + } + + Log.e(LOGTAG, "No gesture taking place in getFocusX()!"); + return 0.0f; + } + + /** + * Returns the Y coordinate of the focus location (the midpoint of the two fingers). If only + * one finger is down, returns the location of that finger. + */ + public float getFocusY() { + switch (getPointersDown()) { + case 1: + return mPointerInfo.getFirst().getCurrent().y; + case 2: + PointerInfo pointerA = mPointerInfo.getFirst(), pointerB = mPointerInfo.getLast(); + return (pointerA.getCurrent().y + pointerB.getCurrent().y) / 2.0f; + } + + Log.e(LOGTAG, "No gesture taking place in getFocusY()!"); + return 0.0f; + } + + /** Returns the most recent distance between the two pointers. */ + public float getCurrentSpan() { + if (getPointersDown() != 2) { + Log.e(LOGTAG, "No gesture taking place in getCurrentSpan()!"); + return 0.0f; + } + + PointerInfo pointerA = mPointerInfo.getFirst(), pointerB = mPointerInfo.getLast(); + return PointUtils.distance(pointerA.getCurrent(), pointerB.getCurrent()); + } + + /** Returns the second most recent distance between the two pointers. */ + public float getPreviousSpan() { + if (getPointersDown() != 2) { + Log.e(LOGTAG, "No gesture taking place in getPreviousSpan()!"); + return 0.0f; + } + + PointerInfo pointerA = mPointerInfo.getFirst(), pointerB = mPointerInfo.getLast(); + PointF a = pointerA.getPrevious(), b = pointerB.getPrevious(); + if (a == null || b == null) { + a = pointerA.getCurrent(); + b = pointerB.getCurrent(); + } + + return PointUtils.distance(a, b); + } + + /** Returns the time of the last event related to the gesture. */ + public long getEventTime() { + return mLastEventTime; + } + + /** Returns true if the scale gesture is in progress and false otherwise. */ + public boolean isInProgress() { + return getPointersDown() == 2; + } + + /* Sends the requested scale gesture notification to the listener. */ + private void sendScaleGesture(EventType eventType) { + switch (eventType) { + case BEGIN: + mScaleResult = mListener.onScaleBegin(this); + break; + case CONTINUE: + if (mScaleResult) { + mListener.onScale(this); + } + break; + case END: + if (mScaleResult) { + mListener.onScaleEnd(this); + } + break; + } + } + + /* + * Returns the pointer info corresponding to the given pointer index, or null if the pointer + * isn't one that's being tracked. + */ + private PointerInfo pointerInfoForEventIndex(MotionEvent event, int index) { + int id = event.getPointerId(index); + for (PointerInfo pointerInfo : mPointerInfo) { + if (pointerInfo.getId() == id) { + return pointerInfo; + } + } + return null; + } + + private enum EventType { + BEGIN, + CONTINUE, + END, + } + + /* Encapsulates information about one of the two fingers involved in the gesture. */ + private static class PointerInfo { + /* A free list that recycles pointer info objects, to reduce GC pauses. */ + private static Stack<PointerInfo> sPointerInfoFreeList; + + private int mId; + private PointF mCurrent, mPrevious; + + private PointerInfo() { + // External users should use create() instead. + } + + /* Creates or recycles a new PointerInfo instance from an event and a pointer index. */ + public static PointerInfo create(MotionEvent event, int index) { + if (sPointerInfoFreeList == null) { + sPointerInfoFreeList = new Stack<PointerInfo>(); + } + + PointerInfo pointerInfo; + if (sPointerInfoFreeList.empty()) { + pointerInfo = new PointerInfo(); + } else { + pointerInfo = sPointerInfoFreeList.pop(); + } + + pointerInfo.populate(event, index); + return pointerInfo; + } + + /* + * Fills in the fields of this instance from the given motion event and pointer index + * within that event. + */ + public void populate(MotionEvent event, int index) { + mId = event.getPointerId(index); + mPrevious = mCurrent; + mCurrent = new PointF(event.getX(index), event.getY(index)); + } + + public void recycle() { + mId = -1; + mPrevious = mCurrent = null; + sPointerInfoFreeList.push(this); + } + + public int getId() { return mId; } + public PointF getCurrent() { return mCurrent; } + public PointF getPrevious() { return mPrevious; } + + @Override + public String toString() { + if (mId == -1) { + return "(up)"; + } + + try { + String prevString; + if (mPrevious == null) { + prevString = "n/a"; + } else { + prevString = PointUtils.toJSON(mPrevious).toString(); + } + + // The current position should always be non-null. + String currentString = PointUtils.toJSON(mCurrent).toString(); + return "id=" + mId + " cur=" + currentString + " prev=" + prevString; + } catch (JSONException e) { + throw new RuntimeException(e); + } + } + } + + public static interface SimpleScaleGestureListener { + public boolean onScale(SimpleScaleGestureDetector detector); + public boolean onScaleBegin(SimpleScaleGestureDetector detector); + public void onScaleEnd(SimpleScaleGestureDetector detector); + } +} + diff --git a/android/source/src/java/org/mozilla/gecko/gfx/SingleTileLayer.java b/android/source/src/java/org/mozilla/gecko/gfx/SingleTileLayer.java new file mode 100644 index 0000000000..0bc2716783 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/SingleTileLayer.java @@ -0,0 +1,154 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Region; +import android.graphics.RegionIterator; +import android.opengl.GLES20; + +import java.nio.FloatBuffer; + +/** + * Encapsulates the logic needed to draw a single textured tile. + * + * TODO: Repeating textures really should be their own type of layer. + */ +public class SingleTileLayer extends TileLayer { + private static final String LOGTAG = "GeckoSingleTileLayer"; + + private Rect mMask; + + // To avoid excessive GC, declare some objects here that would otherwise + // be created and destroyed frequently during draw(). + private final RectF mBounds; + private final RectF mTextureBounds; + private final RectF mViewport; + private final Rect mIntBounds; + private final Rect mSubRect; + private final RectF mSubRectF; + private final Region mMaskedBounds; + private final Rect mCropRect; + private final RectF mObjRectF; + private final float[] mCoords; + + public SingleTileLayer(CairoImage image) { + this(false, image); + } + + public SingleTileLayer(boolean repeat, CairoImage image) { + this(image, repeat ? TileLayer.PaintMode.REPEAT : TileLayer.PaintMode.NORMAL); + } + + public SingleTileLayer(CairoImage image, TileLayer.PaintMode paintMode) { + super(image, paintMode); + + mBounds = new RectF(); + mTextureBounds = new RectF(); + mViewport = new RectF(); + mIntBounds = new Rect(); + mSubRect = new Rect(); + mSubRectF = new RectF(); + mMaskedBounds = new Region(); + mCropRect = new Rect(); + mObjRectF = new RectF(); + mCoords = new float[20]; + } + + /** + * Set an area to mask out when rendering. + */ + public void setMask(Rect aMaskRect) { + mMask = aMaskRect; + } + + @Override + public void draw(RenderContext context) { + // mTextureIDs may be null here during startup if Layer.java's draw method + // failed to acquire the transaction lock and call performUpdates. + if (!initialized()) + return; + + mViewport.set(context.viewport); + + if (repeats()) { + // If we're repeating, we want to adjust the texture bounds so that + // the texture repeats the correct number of times when drawn at + // the size of the viewport. + mBounds.set(getBounds(context)); + mTextureBounds.set(0.0f, 0.0f, mBounds.width(), mBounds.height()); + mBounds.set(0.0f, 0.0f, mViewport.width(), mViewport.height()); + } else if (stretches()) { + // If we're stretching, we just want the bounds and texture bounds + // to fit to the page. + mBounds.set(context.pageRect); + mTextureBounds.set(mBounds); + } else { + mBounds.set(getBounds(context)); + mTextureBounds.set(mBounds); + } + + mBounds.roundOut(mIntBounds); + mMaskedBounds.set(mIntBounds); + if (mMask != null) { + mMaskedBounds.op(mMask, Region.Op.DIFFERENCE); + if (mMaskedBounds.isEmpty()) + return; + } + + // XXX Possible optimisation here, form this array so we can draw it in + // a single call. + RegionIterator i = new RegionIterator(mMaskedBounds); + while (i.next(mSubRect)) { + // Compensate for rounding errors at the edge of the tile caused by + // the roundOut above + mSubRectF.set(Math.max(mBounds.left, (float)mSubRect.left), + Math.max(mBounds.top, (float)mSubRect.top), + Math.min(mBounds.right, (float)mSubRect.right), + Math.min(mBounds.bottom, (float)mSubRect.bottom)); + + // This is the left/top/right/bottom of the rect, relative to the + // bottom-left of the layer, to use for texture coordinates. + mCropRect.set(Math.round(mSubRectF.left - mBounds.left), + Math.round(mBounds.bottom - mSubRectF.top), + Math.round(mSubRectF.right - mBounds.left), + Math.round(mBounds.bottom - mSubRectF.bottom)); + + mObjRectF.set(mSubRectF.left - mViewport.left, + mViewport.bottom - mSubRectF.bottom, + mSubRectF.right - mViewport.left, + mViewport.bottom - mSubRectF.top); + + fillRectCoordBuffer(mCoords, mObjRectF, mViewport.width(), mViewport.height(), + mCropRect, mTextureBounds.width(), mTextureBounds.height()); + + FloatBuffer coordBuffer = context.coordBuffer; + int positionHandle = context.positionHandle; + int textureHandle = context.textureHandle; + + GLES20.glActiveTexture(GLES20.GL_TEXTURE0); + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, getTextureID()); + + // Make sure we are at position zero in the buffer + coordBuffer.position(0); + coordBuffer.put(mCoords); + + // Unbind any the current array buffer so we can use client side buffers + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0); + + // Vertex coordinates are x,y,z starting at position 0 into the buffer. + coordBuffer.position(0); + GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false, 20, coordBuffer); + + // Texture coordinates are texture_x, texture_y starting at position 3 into the buffer. + coordBuffer.position(3); + GLES20.glVertexAttribPointer(textureHandle, 2, GLES20.GL_FLOAT, false, 20, coordBuffer); + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + } + } +} + diff --git a/android/source/src/java/org/mozilla/gecko/gfx/SubTile.java b/android/source/src/java/org/mozilla/gecko/gfx/SubTile.java new file mode 100644 index 0000000000..bdad37195d --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/SubTile.java @@ -0,0 +1,254 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Region; +import android.graphics.RegionIterator; +import android.opengl.GLES20; +import android.util.Log; + +import org.libreoffice.TileIdentifier; + +import java.nio.ByteBuffer; +import java.nio.FloatBuffer; + +public class SubTile extends Layer { + private static String LOGTAG = SubTile.class.getSimpleName(); + public final TileIdentifier id; + + private final RectF mBounds; + private final RectF mTextureBounds; + private final RectF mViewport; + private final Rect mIntBounds; + private final Rect mSubRect; + private final RectF mSubRectF; + private final Region mMaskedBounds; + private final Rect mCropRect; + private final RectF mObjRectF; + private final float[] mCoords; + + public boolean markedForRemoval = false; + + private CairoImage mImage; + private IntSize mSize; + private int[] mTextureIDs; + private boolean mDirtyTile; + + public SubTile(TileIdentifier id) { + super(); + this.id = id; + + mBounds = new RectF(); + mTextureBounds = new RectF(); + mViewport = new RectF(); + mIntBounds = new Rect(); + mSubRect = new Rect(); + mSubRectF = new RectF(); + mMaskedBounds = new Region(); + mCropRect = new Rect(); + mObjRectF = new RectF(); + mCoords = new float[20]; + + mImage = null; + mTextureIDs = null; + mSize = new IntSize(0, 0); + mDirtyTile = false; + } + + public void setImage(CairoImage image) { + if (image.getSize().isPositive()) { + this.mImage = image; + } + } + + public void refreshTileMetrics() { + setPosition(id.getCSSRect()); + } + + public void markForRemoval() { + markedForRemoval = true; + } + + protected int getTextureID() { + return mTextureIDs[0]; + } + + protected boolean initialized() { + return mTextureIDs != null; + } + + @Override + protected void finalize() throws Throwable { + try { + destroyImage(); + cleanTexture(); + } finally { + super.finalize(); + } + } + + private void cleanTexture() { + if (mTextureIDs != null) { + TextureReaper.get().add(mTextureIDs); + mTextureIDs = null; + TextureReaper.get().reap(); + } + } + + public void destroy() { + try { + destroyImage(); + cleanTexture(); + } catch (Exception ex) { + Log.e(LOGTAG, "Error clearing buffers: ", ex); + } + } + + public void destroyImage() { + if (mImage != null) { + mImage.destroy(); + mImage = null; + } + } + + /** + * Invalidates the entire buffer so that it will be uploaded again. Only valid inside a + * transaction. + */ + public void invalidate() { + if (!inTransaction()) { + throw new RuntimeException("invalidate() is only valid inside a transaction"); + } + if (mImage == null) { + return; + } + mDirtyTile = true; + } + + /** + * Remove the texture if the image is of different size than the current uploaded texture. + */ + private void validateTexture() { + IntSize textureSize = mImage.getSize().nextPowerOfTwo(); + + if (!textureSize.equals(mSize)) { + mSize = textureSize; + cleanTexture(); + } + } + + @Override + protected void performUpdates(RenderContext context) { + super.performUpdates(context); + if (mImage == null && !mDirtyTile) { + return; + } + validateTexture(); + uploadNewTexture(); + mDirtyTile = false; + } + + private void uploadNewTexture() { + ByteBuffer imageBuffer = mImage.getBuffer(); + if (imageBuffer == null) { + return; + } + + if (mTextureIDs == null) { + mTextureIDs = new int[1]; + GLES20.glGenTextures(mTextureIDs.length, mTextureIDs, 0); + } + + int cairoFormat = mImage.getFormat(); + CairoGLInfo glInfo = new CairoGLInfo(cairoFormat); + + bindAndSetGLParameters(); + + IntSize bufferSize = mImage.getSize(); + + GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, glInfo.internalFormat, + mSize.width, mSize.height, 0, glInfo.format, glInfo.type, imageBuffer); + + destroyImage(); + } + + private void bindAndSetGLParameters() { + GLES20.glActiveTexture(GLES20.GL_TEXTURE0); + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextureIDs[0]); + + GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); + GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); + } + + @Override + public void draw(RenderContext context) { + // mTextureIDs may be null here during startup if Layer.java's draw method + // failed to acquire the transaction lock and call performUpdates. + if (!initialized()) + return; + + mViewport.set(context.viewport); + + mBounds.set(getBounds(context)); + mTextureBounds.set(mBounds); + + mBounds.roundOut(mIntBounds); + mMaskedBounds.set(mIntBounds); + + // XXX Possible optimisation here, form this array so we can draw it in + // a single call. + RegionIterator iterator = new RegionIterator(mMaskedBounds); + while (iterator.next(mSubRect)) { + // Compensate for rounding errors at the edge of the tile caused by + // the roundOut above + mSubRectF.set(Math.max(mBounds.left, (float) mSubRect.left), + Math.max(mBounds.top, (float) mSubRect.top), + Math.min(mBounds.right, (float) mSubRect.right), + Math.min(mBounds.bottom, (float) mSubRect.bottom)); + + // This is the left/top/right/bottom of the rect, relative to the + // bottom-left of the layer, to use for texture coordinates. + mCropRect.set(Math.round(mSubRectF.left - mBounds.left), + Math.round(mBounds.bottom - mSubRectF.top), + Math.round(mSubRectF.right - mBounds.left), + Math.round(mBounds.bottom - mSubRectF.bottom)); + + mObjRectF.set(mSubRectF.left - mViewport.left, + mViewport.bottom - mSubRectF.bottom, + mSubRectF.right - mViewport.left, + mViewport.bottom - mSubRectF.top); + + fillRectCoordBuffer(mCoords, mObjRectF, mViewport.width(), mViewport.height(), mCropRect, mTextureBounds.width(), mTextureBounds.height()); + + FloatBuffer coordBuffer = context.coordBuffer; + int positionHandle = context.positionHandle; + int textureHandle = context.textureHandle; + + GLES20.glActiveTexture(GLES20.GL_TEXTURE0); + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, getTextureID()); + + // Make sure we are at position zero in the buffer + coordBuffer.position(0); + coordBuffer.put(mCoords); + + // Unbind any the current array buffer so we can use client side buffers + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0); + + // Vertex coordinates are x,y,z starting at position 0 into the buffer. + coordBuffer.position(0); + GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false, 20, coordBuffer); + + // Texture coordinates are texture_x, texture_y starting at position 3 into the buffer. + coordBuffer.position(3); + GLES20.glVertexAttribPointer(textureHandle, 2, GLES20.GL_FLOAT, false, 20, coordBuffer); + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + } + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/SubdocumentScrollHelper.java b/android/source/src/java/org/mozilla/gecko/gfx/SubdocumentScrollHelper.java new file mode 100644 index 0000000000..5a752e3c71 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/SubdocumentScrollHelper.java @@ -0,0 +1,78 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.PointF; +import android.os.Handler; + +class SubdocumentScrollHelper { + private static final String LOGTAG = "GeckoSubdocumentScrollHelper"; + + private final Handler mUiHandler; + + /* This is the amount of displacement we have accepted but not yet sent to JS; this is + * only valid when mOverrideScrollPending is true. */ + private final PointF mPendingDisplacement; + + /* When this is true, we're sending scroll events to JS to scroll the active subdocument. */ + private boolean mOverridePanning; + + /* When this is true, we have received an ack for the last scroll event we sent to JS, and + * are ready to send the next scroll event. Note we only ever have one scroll event inflight + * at a time. */ + private boolean mOverrideScrollAck; + + /* When this is true, we have a pending scroll that we need to send to JS; we were unable + * to send it when it was initially requested because mOverrideScrollAck was not true. */ + private boolean mOverrideScrollPending; + + /* When this is true, the last scroll event we sent actually did some amount of scrolling on + * the subdocument; we use this to decide when we have reached the end of the subdocument. */ + private boolean mScrollSucceeded; + + SubdocumentScrollHelper() { + // mUiHandler will be bound to the UI thread since that's where this constructor runs + mUiHandler = new Handler(); + mPendingDisplacement = new PointF(); + } + + void destroy() { + } + + boolean scrollBy(PointF displacement) { + if (! mOverridePanning) { + return false; + } + + if (! mOverrideScrollAck) { + mOverrideScrollPending = true; + mPendingDisplacement.x += displacement.x; + mPendingDisplacement.y += displacement.y; + return true; + } + + mOverrideScrollAck = false; + mOverrideScrollPending = false; + // clear the |mPendingDisplacement| after serializing |displacement| to + // JSON because they might be the same object + mPendingDisplacement.x = 0; + mPendingDisplacement.y = 0; + + return true; + } + + void cancel() { + mOverridePanning = false; + } + + boolean scrolling() { + return mOverridePanning; + } + + boolean lastScrollSucceeded() { + return mScrollSucceeded; + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/TextLayer.java b/android/source/src/java/org/mozilla/gecko/gfx/TextLayer.java new file mode 100644 index 0000000000..023433a888 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/TextLayer.java @@ -0,0 +1,69 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Typeface; + +import org.libreoffice.kit.DirectBufferAllocator; + +import java.nio.ByteBuffer; + +/** + * Draws text on a layer. This is used for the frame rate meter. + */ +public class TextLayer extends SingleTileLayer { + private final ByteBuffer mBuffer; // this buffer is owned by the BufferedCairoImage + private final IntSize mSize; + + /* + * This awkward pattern is necessary due to Java's restrictions on when one can call superclass + * constructors. + */ + private TextLayer(ByteBuffer buffer, BufferedCairoImage image, IntSize size, String text) { + super(false, image); + mBuffer = buffer; + mSize = size; + renderText(text); + } + + public static TextLayer create(IntSize size, String text) { + ByteBuffer buffer = DirectBufferAllocator.allocate(size.width * size.height * 4); + BufferedCairoImage image = new BufferedCairoImage(buffer, size.width, size.height, + CairoImage.FORMAT_ARGB32); + return new TextLayer(buffer, image, size, text); + } + + public void setText(String text) { + renderText(text); + invalidate(); + } + + private void renderText(String text) { + Bitmap bitmap = Bitmap.createBitmap(mSize.width, mSize.height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + + Paint textPaint = new Paint(); + textPaint.setAntiAlias(true); + textPaint.setColor(Color.WHITE); + textPaint.setFakeBoldText(true); + textPaint.setTextSize(18.0f); + textPaint.setTypeface(Typeface.DEFAULT_BOLD); + float width = textPaint.measureText(text) + 18.0f; + + Paint backgroundPaint = new Paint(); + backgroundPaint.setColor(Color.argb(127, 0, 0, 0)); + canvas.drawRect(0.0f, 0.0f, width, 18.0f + 6.0f, backgroundPaint); + + canvas.drawText(text, 6.0f, 18.0f, textPaint); + + bitmap.copyPixelsToBuffer(mBuffer.asIntBuffer()); + } +} + diff --git a/android/source/src/java/org/mozilla/gecko/gfx/TextureGenerator.java b/android/source/src/java/org/mozilla/gecko/gfx/TextureGenerator.java new file mode 100644 index 0000000000..bccd8968c8 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/TextureGenerator.java @@ -0,0 +1,77 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.opengl.GLES20; +import android.util.Log; + +import java.util.concurrent.ArrayBlockingQueue; + +import javax.microedition.khronos.egl.EGL10; +import javax.microedition.khronos.egl.EGLContext; + +public class TextureGenerator { + private static final String LOGTAG = "TextureGenerator"; + private static final int POOL_SIZE = 5; + + private static TextureGenerator sSharedInstance; + + private ArrayBlockingQueue<Integer> mTextureIds; + private EGLContext mContext; + + private TextureGenerator() { + mTextureIds = new ArrayBlockingQueue<Integer>(POOL_SIZE); + } + + public static TextureGenerator get() { + if (sSharedInstance == null) + sSharedInstance = new TextureGenerator(); + return sSharedInstance; + } + + public synchronized int take() { + try { + // Will block until one becomes available + return mTextureIds.take(); + } catch (InterruptedException e) { + return 0; + } + } + + public synchronized void fill() { + EGL10 egl = (EGL10) EGLContext.getEGL(); + EGLContext context = egl.eglGetCurrentContext(); + + if (mContext != null && mContext != context) { + mTextureIds.clear(); + } + + mContext = context; + + int numNeeded = mTextureIds.remainingCapacity(); + if (numNeeded == 0) + return; + + // Clear existing GL errors + int error; + while ((error = GLES20.glGetError()) != GLES20.GL_NO_ERROR) { + Log.w(LOGTAG, String.format("Clearing GL error: %#x", error)); + } + + int[] textures = new int[numNeeded]; + GLES20.glGenTextures(numNeeded, textures, 0); + + error = GLES20.glGetError(); + if (error != GLES20.GL_NO_ERROR) { + Log.e(LOGTAG, String.format("Failed to generate textures: %#x", error), new Exception()); + return; + } + + for (int i = 0; i < numNeeded; i++) { + mTextureIds.offer(textures[i]); + } + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/TextureReaper.java b/android/source/src/java/org/mozilla/gecko/gfx/TextureReaper.java new file mode 100644 index 0000000000..1a8a504597 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/TextureReaper.java @@ -0,0 +1,62 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.opengl.GLES20; +import android.util.Log; + +import java.util.ArrayList; + +/** + * Manages a list of dead tiles, so we don't leak resources. + */ +public class TextureReaper { + private static TextureReaper sSharedInstance; + private ArrayList<Integer> mDeadTextureIDs = new ArrayList<Integer>(); + private static final String LOGTAG = TextureReaper.class.getSimpleName(); + + private TextureReaper() { + } + + public static TextureReaper get() { + if (sSharedInstance == null) { + sSharedInstance = new TextureReaper(); + } + return sSharedInstance; + } + + public void add(int[] textureIDs) { + for (int textureID : textureIDs) { + add(textureID); + } + } + + public synchronized void add(int textureID) { + mDeadTextureIDs.add(textureID); + } + + public synchronized void reap() { + int numTextures = mDeadTextureIDs.size(); + // Adreno 200 will generate INVALID_VALUE if len == 0 is passed to glDeleteTextures, + // even though it's not supposed to. + if (numTextures == 0) + return; + + int[] deadTextureIDs = new int[numTextures]; + for (int i = 0; i < numTextures; i++) { + Integer id = mDeadTextureIDs.get(i); + if (id == null) { + deadTextureIDs[i] = 0; + Log.e(LOGTAG, "Dead texture id is null"); + } else { + deadTextureIDs[i] = mDeadTextureIDs.get(i); + } + } + mDeadTextureIDs.clear(); + + GLES20.glDeleteTextures(deadTextureIDs.length, deadTextureIDs, 0); + } +}
\ No newline at end of file diff --git a/android/source/src/java/org/mozilla/gecko/gfx/TileLayer.java b/android/source/src/java/org/mozilla/gecko/gfx/TileLayer.java new file mode 100644 index 0000000000..3d0ff1fede --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/TileLayer.java @@ -0,0 +1,176 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.Rect; +import android.opengl.GLES20; +import android.util.Log; + +import java.nio.ByteBuffer; + +/** + * Base class for tile layers, which encapsulate the logic needed to draw textured tiles in OpenGL + * ES. + */ +public abstract class TileLayer extends Layer { + private static final String LOGTAG = "GeckoTileLayer"; + + private final Rect mDirtyRect; + private IntSize mSize; + private int[] mTextureIDs; + + protected final CairoImage mImage; + + public CairoImage getImage() { + return mImage; + } + + public enum PaintMode { NORMAL, REPEAT, STRETCH }; + private PaintMode mPaintMode; + + public TileLayer(CairoImage image, PaintMode paintMode) { + super(image == null ? null : image.getSize()); + + mPaintMode = paintMode; + mImage = image; + mSize = new IntSize(0, 0); + mDirtyRect = new Rect(); + } + + protected boolean repeats() { return mPaintMode == PaintMode.REPEAT; } + protected boolean stretches() { return mPaintMode == PaintMode.STRETCH; } + protected int getTextureID() { return mTextureIDs[0]; } + protected boolean initialized() { return mImage != null && mTextureIDs != null; } + + @Override + protected void finalize() throws Throwable { + try { + if (mTextureIDs != null) + TextureReaper.get().add(mTextureIDs); + } finally { + super.finalize(); + } + } + + public void destroy() { + try { + if (mImage != null) { + mImage.destroy(); + } + } catch (Exception ex) { + Log.e(LOGTAG, "error clearing buffers: ", ex); + } + } + + public void setPaintMode(PaintMode mode) { + mPaintMode = mode; + } + + /** + * Invalidates the entire buffer so that it will be uploaded again. Only valid inside a + * transaction. + */ + public void invalidate() { + if (!inTransaction()) + throw new RuntimeException("invalidate() is only valid inside a transaction"); + IntSize bufferSize = mImage.getSize(); + mDirtyRect.set(0, 0, bufferSize.width, bufferSize.height); + } + + private void validateTexture() { + /* Calculate the ideal texture size. This must be a power of two if + * the texture is repeated or OpenGL ES 2.0 isn't supported, as + * OpenGL ES 2.0 is required for NPOT texture support (without + * extensions), but doesn't support repeating NPOT textures. + * + * XXX Currently, we don't pick a GLES 2.0 context, so always round. + */ + IntSize textureSize = mImage.getSize().nextPowerOfTwo(); + + if (!textureSize.equals(mSize)) { + mSize = textureSize; + + // Delete the old texture + if (mTextureIDs != null) { + TextureReaper.get().add(mTextureIDs); + mTextureIDs = null; + + // Free the texture immediately, so we don't incur a + // temporarily increased memory usage. + TextureReaper.get().reap(); + } + } + } + + @Override + protected void performUpdates(RenderContext context) { + super.performUpdates(context); + + // Reallocate the texture if the size has changed + validateTexture(); + + // Don't do any work if the image has an invalid size. + if (!mImage.getSize().isPositive()) + return; + + // If we haven't allocated a texture, assume the whole region is dirty + if (mTextureIDs == null) { + uploadFullTexture(); + } else { + uploadDirtyRect(mDirtyRect); + } + + mDirtyRect.setEmpty(); + } + + private void uploadFullTexture() { + IntSize bufferSize = mImage.getSize(); + uploadDirtyRect(new Rect(0, 0, bufferSize.width, bufferSize.height)); + } + + private void uploadDirtyRect(Rect dirtyRect) { + // If we have nothing to upload, just return for now + if (dirtyRect.isEmpty()) + return; + + // It's possible that the buffer will be null, check for that and return + ByteBuffer imageBuffer = mImage.getBuffer(); + if (imageBuffer == null) + return; + + if (mTextureIDs == null) { + mTextureIDs = new int[1]; + GLES20.glGenTextures(mTextureIDs.length, mTextureIDs, 0); + } + + int cairoFormat = mImage.getFormat(); + CairoGLInfo glInfo = new CairoGLInfo(cairoFormat); + + bindAndSetGLParameters(); + + // XXX TexSubImage2D is too broken to rely on Adreno, and very slow + // on other chipsets, so we always upload the entire buffer. + IntSize bufferSize = mImage.getSize(); + + GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, glInfo.internalFormat, mSize.width, + mSize.height, 0, glInfo.format, glInfo.type, imageBuffer); + + } + + private void bindAndSetGLParameters() { + GLES20.glActiveTexture(GLES20.GL_TEXTURE0); + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextureIDs[0]); + GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, + GLES20.GL_LINEAR); + GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, + GLES20.GL_LINEAR); + + int repeatMode = repeats() ? GLES20.GL_REPEAT : GLES20.GL_CLAMP_TO_EDGE; + GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, repeatMode); + GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, repeatMode); + } +} + diff --git a/android/source/src/java/org/mozilla/gecko/gfx/TouchEventHandler.java b/android/source/src/java/org/mozilla/gecko/gfx/TouchEventHandler.java new file mode 100644 index 0000000000..1c227de20b --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/TouchEventHandler.java @@ -0,0 +1,306 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.content.Context; +import android.os.SystemClock; +import android.util.Log; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; + +import java.util.LinkedList; +import java.util.Queue; + +/** + * This class handles incoming touch events from the user and sends them to + * listeners in Gecko and/or performs the "default action" (asynchronous pan/zoom + * behaviour. EVERYTHING IN THIS CLASS MUST RUN ON THE UI THREAD. + * + * In the following code/comments, a "block" of events refers to a contiguous + * sequence of events that starts with a DOWN or POINTER_DOWN and goes up to + * but not including the next DOWN or POINTER_DOWN event. + * + * "Dispatching" an event refers to performing the default actions for the event, + * which at our level of abstraction just means sending it off to the gesture + * detectors and the pan/zoom controller. + * + * If an event is "default-prevented" that means one or more listeners in Gecko + * has called preventDefault() on the event, which means that the default action + * for that event should not occur. Usually we care about a "block" of events being + * default-prevented, which means that the DOWN/POINTER_DOWN event that started + * the block, or the first MOVE event following that, were prevent-defaulted. + * + * A "default-prevented notification" is when we here in Java-land receive a notification + * from gecko as to whether or not a block of events was default-prevented. This happens + * at some point after the first or second event in the block is processed in Gecko. + * This code assumes we get EXACTLY ONE default-prevented notification for each block + * of events. + * + * Note that even if all events are default-prevented, we still send specific types + * of notifications to the pan/zoom controller. The notifications are needed + * to respond to user actions a timely manner regardless of default-prevention, + * and fix issues like bug 749384. + */ +public final class TouchEventHandler { + private static final String LOGTAG = "GeckoTouchEventHandler"; + + // The time limit for listeners to respond with preventDefault on touchevents + // before we begin panning the page + private final int EVENT_LISTENER_TIMEOUT = 200; + + private final View mView; + private final GestureDetector mGestureDetector; + private final SimpleScaleGestureDetector mScaleGestureDetector; + private final JavaPanZoomController mPanZoomController; + + // the queue of events that we are holding on to while waiting for a preventDefault + // notification + private final Queue<MotionEvent> mEventQueue; + private final ListenerTimeoutProcessor mListenerTimeoutProcessor; + + // whether or not we should wait for touch listeners to respond (this state is + // per-tab and is updated when we switch tabs). + private boolean mWaitForTouchListeners; + + // true if we should hold incoming events in our queue. this is re-set for every + // block of events, this is cleared once we find out if the block has been + // default-prevented or not (or we time out waiting for that). + private boolean mHoldInQueue; + + // true if we should dispatch incoming events to the gesture detector and the pan/zoom + // controller. if this is false, then the current block of events has been + // default-prevented, and we should not dispatch these events (although we'll still send + // them to gecko listeners). + private boolean mDispatchEvents; + + // this next variable requires some explanation. strap yourself in. + // + // for each block of events, we do two things: (1) send the events to gecko and expect + // exactly one default-prevented notification in return, and (2) kick off a delayed + // ListenerTimeoutProcessor that triggers in case we don't hear from the listener in + // a timely fashion. + // since events are constantly coming in, we need to be able to handle more than one + // block of events in the queue. + // + // this means that there are ordering restrictions on these that we can take advantage of, + // and need to abide by. blocks of events in the queue will always be in the order that + // the user generated them. default-prevented notifications we get from gecko will be in + // the same order as the blocks of events in the queue. the ListenerTimeoutProcessors that + // have been posted will also fire in the same order as the blocks of events in the queue. + // HOWEVER, we may get multiple default-prevented notifications interleaved with multiple + // ListenerTimeoutProcessor firings, and that interleaving is not predictable. + // + // therefore, we need to make sure that for each block of events, we process the queued + // events exactly once, either when we get the default-prevented notification, or when the + // timeout expires (whichever happens first). there is no way to associate the + // default-prevented notification with a particular block of events other than via ordering, + // + // so what we do to accomplish this is to track a "processing balance", which is the number + // of default-prevented notifications that we have received, minus the number of ListenerTimeoutProcessors + // that have fired. (think "balance" as in teeter-totter balance). this value is: + // - zero when we are in a state where the next default-prevented notification we expect + // to receive and the next ListenerTimeoutProcessor we expect to fire both correspond to + // the next block of events in the queue. + // - positive when we are in a state where we have received more default-prevented notifications + // than ListenerTimeoutProcessors. This means that the next default-prevented notification + // does correspond to the block at the head of the queue, but the next n ListenerTimeoutProcessors + // need to be ignored as they are for blocks we have already processed. (n is the absolute value + // of the balance.) + // - negative when we are in a state where we have received more ListenerTimeoutProcessors than + // default-prevented notifications. This means that the next ListenerTimeoutProcessor that + // we receive does correspond to the block at the head of the queue, but the next n + // default-prevented notifications need to be ignored as they are for blocks we have already + // processed. (n is the absolute value of the balance.) + private int mProcessingBalance; + + TouchEventHandler(Context context, View view, JavaPanZoomController panZoomController) { + mView = view; + + mEventQueue = new LinkedList<MotionEvent>(); + mPanZoomController = panZoomController; + mGestureDetector = new GestureDetector(context, mPanZoomController); + mScaleGestureDetector = new SimpleScaleGestureDetector(mPanZoomController); + mListenerTimeoutProcessor = new ListenerTimeoutProcessor(); + mDispatchEvents = true; + + mGestureDetector.setOnDoubleTapListener(mPanZoomController); + } + + void destroy() { + } + + /* This function MUST be called on the UI thread */ + public boolean handleEvent(MotionEvent event) { + if (isDownEvent(event)) { + // this is the start of a new block of events! whee! + mHoldInQueue = mWaitForTouchListeners; + + // Set mDispatchEvents to true so that we are guaranteed to either queue these + // events or dispatch them. The only time we should not do either is once we've + // heard back from content to preventDefault this block. + mDispatchEvents = true; + if (mHoldInQueue) { + // if the new block we are starting is the current block (i.e. there are no + // other blocks waiting in the queue, then we should let the pan/zoom controller + // know we are waiting for the touch listeners to run + if (mEventQueue.isEmpty()) { + mPanZoomController.startingNewEventBlock(event, true); + } + } else { + // we're not going to be holding this block of events in the queue, but we need + // a marker of some sort so that the processEventBlock loop deals with the blocks + // in the right order as notifications come in. we use a single null event in + // the queue as a placeholder for a block of events that has already been dispatched. + mEventQueue.add(null); + mPanZoomController.startingNewEventBlock(event, false); + } + + // set the timeout so that we dispatch these events and update mProcessingBalance + // if we don't get a default-prevented notification + mView.postDelayed(mListenerTimeoutProcessor, EVENT_LISTENER_TIMEOUT); + } + + // if we need to hold the events, add it to the queue. if we need to dispatch + // it directly, do that. it is possible that both mHoldInQueue and mDispatchEvents + // are false, in which case we are processing a block of events that we know + // has been default-prevented. in that case we don't keep the events as we don't + // need them (but we still pass them to the gecko listener). + if (mHoldInQueue) { + mEventQueue.add(MotionEvent.obtain(event)); + } else if (mDispatchEvents) { + dispatchEvent(event); + } else if (touchFinished(event)) { + mPanZoomController.preventedTouchFinished(); + } + + return true; + } + + /** + * This function is how gecko sends us a default-prevented notification. It is called + * once gecko knows definitively whether the block of events has had preventDefault + * called on it (either on the initial down event that starts the block, or on + * the first event following that down event). + * + * This function MUST be called on the UI thread. + */ + public void handleEventListenerAction(boolean allowDefaultAction) { + if (mProcessingBalance > 0) { + // this event listener that triggered this took too long, and the corresponding + // ListenerTimeoutProcessor runnable already ran for the event in question. the + // block of events this is for has already been processed, so we don't need to + // do anything here. + } else { + processEventBlock(allowDefaultAction); + } + mProcessingBalance--; + } + + /* This function MUST be called on the UI thread. */ + public void setWaitForTouchListeners(boolean aValue) { + mWaitForTouchListeners = aValue; + } + + private boolean isDownEvent(MotionEvent event) { + int action = (event.getAction() & MotionEvent.ACTION_MASK); + return (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN); + } + + private boolean touchFinished(MotionEvent event) { + int action = (event.getAction() & MotionEvent.ACTION_MASK); + return (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL); + } + + /** + * Dispatch the event to the gesture detectors and the pan/zoom controller. + */ + private void dispatchEvent(MotionEvent event) { + if (mGestureDetector.onTouchEvent(event)) { + return; + } + mScaleGestureDetector.onTouchEvent(event); + if (mScaleGestureDetector.isInProgress()) { + return; + } + mPanZoomController.handleEvent(event); + } + + /** + * Process the block of events at the head of the queue now that we know + * whether it has been default-prevented or not. + */ + private void processEventBlock(boolean allowDefaultAction) { + if (!allowDefaultAction) { + // if the block has been default-prevented, cancel whatever stuff we had in + // progress in the gesture detector and pan zoom controller + long now = SystemClock.uptimeMillis(); + dispatchEvent(MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL, 0, 0, 0)); + } + + if (mEventQueue.isEmpty()) { + Log.e(LOGTAG, "Unexpected empty event queue in processEventBlock!", new Exception()); + return; + } + + // the odd loop condition is because the first event in the queue will + // always be a DOWN or POINTER_DOWN event, and we want to process all + // the events in the queue starting at that one, up to but not including + // the next DOWN or POINTER_DOWN event. + + MotionEvent event = mEventQueue.poll(); + while (true) { + // event being null here is valid and represents a block of events + // that has already been dispatched. + + if (event != null) { + // for each event we process, only dispatch it if the block hasn't been + // default-prevented. + if (allowDefaultAction) { + dispatchEvent(event); + } else if (touchFinished(event)) { + mPanZoomController.preventedTouchFinished(); + } + } + if (mEventQueue.isEmpty()) { + // we have processed the backlog of events, and are all caught up. + // now we can set clear the hold flag and set the dispatch flag so + // that the handleEvent() function can do the right thing for all + // remaining events in this block (which is still ongoing) without + // having to put them in the queue. + mHoldInQueue = false; + mDispatchEvents = allowDefaultAction; + break; + } + event = mEventQueue.peek(); + if (event == null || isDownEvent(event)) { + // we have finished processing the block we were interested in. + // now we wait for the next call to processEventBlock + if (event != null) { + mPanZoomController.startingNewEventBlock(event, true); + } + break; + } + // pop the event we peeked above, as it is still part of the block and + // we want to keep processing + mEventQueue.remove(); + } + } + + private class ListenerTimeoutProcessor implements Runnable { + /* This MUST be run on the UI thread */ + public void run() { + if (mProcessingBalance < 0) { + // gecko already responded with default-prevented notification, and so + // the block of events this ListenerTimeoutProcessor corresponds to have + // already been removed from the queue. + } else { + processEventBlock(true); + } + mProcessingBalance++; + } + } +} diff --git a/android/source/src/java/org/mozilla/gecko/gfx/ViewportMetrics.java b/android/source/src/java/org/mozilla/gecko/gfx/ViewportMetrics.java new file mode 100644 index 0000000000..f8b5c2e055 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/gfx/ViewportMetrics.java @@ -0,0 +1,173 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.PointF; +import android.graphics.RectF; +import android.util.DisplayMetrics; + +import org.json.JSONException; +import org.json.JSONObject; + +/** + * ViewportMetrics manages state and contains some utility functions related to + * the page viewport for the Gecko layer client to use. + */ +public class ViewportMetrics { + private static final String LOGTAG = "GeckoViewportMetrics"; + + private RectF mPageRect; + private RectF mCssPageRect; + private RectF mViewportRect; + private float mZoomFactor; + + public ViewportMetrics(DisplayMetrics metrics) { + mPageRect = new RectF(0, 0, metrics.widthPixels, metrics.heightPixels); + mCssPageRect = new RectF(0, 0, metrics.widthPixels, metrics.heightPixels); + mViewportRect = new RectF(0, 0, metrics.widthPixels, metrics.heightPixels); + mZoomFactor = 1.0f; + } + + public ViewportMetrics(ViewportMetrics viewport) { + mPageRect = new RectF(viewport.getPageRect()); + mCssPageRect = new RectF(viewport.getCssPageRect()); + mViewportRect = new RectF(viewport.getViewport()); + mZoomFactor = viewport.getZoomFactor(); + } + + public ViewportMetrics(ImmutableViewportMetrics viewport) { + mPageRect = new RectF(viewport.pageRectLeft, + viewport.pageRectTop, + viewport.pageRectRight, + viewport.pageRectBottom); + mCssPageRect = new RectF(viewport.cssPageRectLeft, + viewport.cssPageRectTop, + viewport.cssPageRectRight, + viewport.cssPageRectBottom); + mViewportRect = new RectF(viewport.viewportRectLeft, + viewport.viewportRectTop, + viewport.viewportRectRight, + viewport.viewportRectBottom); + mZoomFactor = viewport.zoomFactor; + } + + public ViewportMetrics(JSONObject json) throws JSONException { + float x = (float)json.getDouble("x"); + float y = (float)json.getDouble("y"); + float width = (float)json.getDouble("width"); + float height = (float)json.getDouble("height"); + float pageLeft = (float)json.getDouble("pageLeft"); + float pageTop = (float)json.getDouble("pageTop"); + float pageRight = (float)json.getDouble("pageRight"); + float pageBottom = (float)json.getDouble("pageBottom"); + float cssPageLeft = (float)json.getDouble("cssPageLeft"); + float cssPageTop = (float)json.getDouble("cssPageTop"); + float cssPageRight = (float)json.getDouble("cssPageRight"); + float cssPageBottom = (float)json.getDouble("cssPageBottom"); + float zoom = (float)json.getDouble("zoom"); + + mPageRect = new RectF(pageLeft, pageTop, pageRight, pageBottom); + mCssPageRect = new RectF(cssPageLeft, cssPageTop, cssPageRight, cssPageBottom); + mViewportRect = new RectF(x, y, x + width, y + height); + mZoomFactor = zoom; + } + + public ViewportMetrics(float x, float y, float width, float height, + float pageLeft, float pageTop, float pageRight, float pageBottom, + float cssPageLeft, float cssPageTop, float cssPageRight, float cssPageBottom, + float zoom) { + mPageRect = new RectF(pageLeft, pageTop, pageRight, pageBottom); + mCssPageRect = new RectF(cssPageLeft, cssPageTop, cssPageRight, cssPageBottom); + mViewportRect = new RectF(x, y, x + width, y + height); + mZoomFactor = zoom; + } + + public PointF getOrigin() { + return new PointF(mViewportRect.left, mViewportRect.top); + } + + public FloatSize getSize() { + return new FloatSize(mViewportRect.width(), mViewportRect.height()); + } + + public RectF getViewport() { + return mViewportRect; + } + + public RectF getCssViewport() { + return RectUtils.scale(mViewportRect, 1/mZoomFactor); + } + + public RectF getPageRect() { + return mPageRect; + } + + public RectF getCssPageRect() { + return mCssPageRect; + } + + public float getZoomFactor() { + return mZoomFactor; + } + + public void setPageRect(RectF pageRect, RectF cssPageRect) { + mPageRect = pageRect; + mCssPageRect = cssPageRect; + } + + public void setViewport(RectF viewport) { + mViewportRect = viewport; + } + + public void setOrigin(PointF origin) { + mViewportRect.set(origin.x, origin.y, + origin.x + mViewportRect.width(), + origin.y + mViewportRect.height()); + } + + public void setSize(FloatSize size) { + mViewportRect.right = mViewportRect.left + size.width; + mViewportRect.bottom = mViewportRect.top + size.height; + } + + public void setZoomFactor(float zoomFactor) { + mZoomFactor = zoomFactor; + } + + public String toJSON() { + // Round off height and width. Since the height and width are the size of the screen, it + // makes no sense to send non-integer coordinates to Gecko. + int height = Math.round(mViewportRect.height()); + int width = Math.round(mViewportRect.width()); + + StringBuffer sb = new StringBuffer(512); + sb.append("{ \"x\" : ").append(mViewportRect.left) + .append(", \"y\" : ").append(mViewportRect.top) + .append(", \"width\" : ").append(width) + .append(", \"height\" : ").append(height) + .append(", \"pageLeft\" : ").append(mPageRect.left) + .append(", \"pageTop\" : ").append(mPageRect.top) + .append(", \"pageRight\" : ").append(mPageRect.right) + .append(", \"pageBottom\" : ").append(mPageRect.bottom) + .append(", \"cssPageLeft\" : ").append(mCssPageRect.left) + .append(", \"cssPageTop\" : ").append(mCssPageRect.top) + .append(", \"cssPageRight\" : ").append(mCssPageRect.right) + .append(", \"cssPageBottom\" : ").append(mCssPageRect.bottom) + .append(", \"zoom\" : ").append(mZoomFactor) + .append(" }"); + return sb.toString(); + } + + @Override + public String toString() { + StringBuffer buff = new StringBuffer(256); + buff.append("v=").append(mViewportRect.toString()) + .append(" p=").append(mPageRect.toString()) + .append(" c=").append(mCssPageRect.toString()) + .append(" z=").append(mZoomFactor); + return buff.toString(); + } +} diff --git a/android/source/src/java/org/mozilla/gecko/util/FloatUtils.java b/android/source/src/java/org/mozilla/gecko/util/FloatUtils.java new file mode 100644 index 0000000000..a48266c573 --- /dev/null +++ b/android/source/src/java/org/mozilla/gecko/util/FloatUtils.java @@ -0,0 +1,41 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.util; + +import android.graphics.PointF; + +public final class FloatUtils { + private FloatUtils() {} + + public static boolean fuzzyEquals(float a, float b) { + return (Math.abs(a - b) < 1e-6); + } + + public static boolean fuzzyEquals(PointF a, PointF b) { + return fuzzyEquals(a.x, b.x) && fuzzyEquals(a.y, b.y); + } + + /* + * Returns the value that represents a linear transition between `from` and `to` at time `t`, + * which is on the scale [0, 1). Thus with t = 0.0f, this returns `from`; with t = 1.0f, this + * returns `to`; with t = 0.5f, this returns the value halfway from `from` to `to`. + */ + public static float interpolate(float from, float to, float t) { + return from + (to - from) * t; + } + + /** + * Returns 'value', clamped so that it isn't any lower than 'low', and it + * isn't any higher than 'high'. + */ + public static float clamp(float value, float low, float high) { + if (high < low) { + throw new IllegalArgumentException( + "clamp called with invalid parameters (" + high + " < " + low + ")" ); + } + return Math.max(low, Math.min(high, value)); + } +} |