Работа с PDF. Чтение PDF-файлов

Android не всегда умел работать с PDF-файлами. Вплоть до версии Android 4.4 KitKat (API 19) у нас не было возможности просматривать PDF-файлы, кроме как используя сторонние приложения, такие как Google Drive PDF Viewer или другой нативный ридер на устройстве.

Начиная с Android 5.0 Lolipop (API 21) появилось API под названием PDFRenderer, с помощью которого можно выводить содержимое PDF-файлов. В этой статье мы создадим приложение, которое открывает PDF-файлы на устройстве для чтения.

Создадим новый проект, указав для него минимальную версию SDK Android 5.0 Lolipop. Для навигации приложения и дальнейшей работы с ним выберем Navigation Drawer Activity.

Шаг 1. Создание списка PDF-файлов

Перед тем, как начать работу, нужно предоставить приложению разрешения. Для этого в AndoridManifest.xml добавим следующие разрешения на чтение и запись.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="ru.androidtools.pdfreader">

  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
  <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
  ...

На MainActivity мы будем получать список PDF-файлов, которые находятся в хранилище устройства. Для этого на файле разметки content_main.xml добавим компонент ListView.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_margin="10dp"
    android:orientation="vertical"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context="ru.androidtools.pdfreader.MainActivity"
    >

  <ListView
      android:id="@+id/listView"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:divider="@drawable/divider"
      />

</LinearLayout>

Также нужно создать разметку для элемента в списке. Создадим в res/layout файл разметки list_item.xml и добавим в него следующий код.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    >

  <TextView
      android:id="@+id/txtFileName"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:padding="10dp"
      android:textColor="#000000"
      android:textSize="16sp"
      />

</LinearLayout>

Найденные файлы нужно сохранять в модель, чтобы затем передать в список. Для этого создадим класс PdfFile, в котором будут определены 2 поля (имя файла и путь), а также сеттеры и геттеры для изменения полей.

public class PdfFile {
  private String fileName;
  private String filePath;

  PdfFile(String fileName, String filePath) {
    this.fileName = fileName;
    this.filePath = filePath;
  }

  public String getFileName() {
    return fileName;
  }

  public void setFileName(String fileName) {
    this.fileName = fileName;
  }

  public String getFilePath() {
    return filePath;
  }

  public void setFilePath(String filePath) {
    this.filePath = filePath;
  }
}

После этого в коде активности перед началом работы нужно проверить версию Android. Если версия ниже Android 6.0 M — инициализируем создание списка, в противном случае нужно проверить разрешения.

private ListView listView;
private static final int REQUEST_PERMISSION = 1;

@Override protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.activity_main);
  
  ...

  listView = findViewById(R.id.listView);

  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
    checkPermission();
  } else {
    initViews();
  }
}

Метод checkPermission() проверяет наличие разрешения на чтение внешнего хранилища. Если разрешения имеются, то инициализируем создание списка, иначе запрашиваем разрешения. Пользователю будет показан диалог, предлагающий предоставить приложению разрешение, результат этого вернётся в метод onRequestPermissionsResult() активности.

private static final int REQUEST_PERMISSION = 1;

...

private void checkPermission() {
  if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
      == PackageManager.PERMISSION_GRANTED
      && ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
      == PackageManager.PERMISSION_GRANTED) {
    initViews();
  } else {
    ActivityCompat.requestPermissions(this, new String[] {
        Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE
    }, REQUEST_PERMISSION);
  }
}

public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
    @NonNull int[] grantResults) {
  switch (requestCode) {
    case REQUEST_PERMISSION: {
      if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
        initViews();
      } else {
        // в разрешении отказано (в первый раз, когда чекбокс "Больше не спрашивать" ещё не показывается)
        if (ActivityCompat.shouldShowRequestPermissionRationale(this,
            Manifest.permission.READ_EXTERNAL_STORAGE)) {
          finish();
        }
        // в разрешении отказано (выбрано "Больше не спрашивать")
        else {
          // показываем диалог, сообщающий о важности разрешения
          AlertDialog.Builder builder = new AlertDialog.Builder(this);
          builder.setMessage(
              "Вы отказались предоставлять разрешение на чтение хранилища.\n\nЭто необходимо для работы приложения."
                  + "\n\n"
                  + "Нажмите \"Предоставить\", чтобы предоставить приложению разрешения.")
              // при согласии откроется окно настроек, в котором пользователю нужно будет вручную предоставить разрешения
              .setPositiveButton("Предоставить", new DialogInterface.OnClickListener() {
                public void onClick(DialogInterface dialog, int id) {
                  finish();
                  Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
                      Uri.fromParts("package", getPackageName(), null));
                  intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                  startActivity(intent);
                }
              })
              // закрываем приложение
              .setNegativeButton("Отказаться", new DialogInterface.OnClickListener() {
                public void onClick(DialogInterface dialog, int id) {
                  finish();
                }
              });
          builder.setCancelable(false);
          builder.create().show();
        }
      }
      break;
    }
  }
}

В методе initViews() нам нужно инициализировать список. Для этого вызываем внутри метод initList(), который будет рекурсивно вызываться до тех пор, пока не будет проверен каждый каталог. В результате выполнения initList() будет заполнен список найденными PDF-файлами, который затем будет передан в адаптер. В адаптере переопределяются методы в соответствии с тем, что нам нужно, в частности в методе getView() мы получаем созданную для элемента разметку и устанавливаем в поле имя файла.

private ArrayList list = new ArrayList<>();

...

private BaseAdapter adapter = new BaseAdapter() {
  @Override public int getCount() {
    return list.size();
  }

  @Override public PdfFile getItem(int i) {
    return list.get(i);
  }

  @Override public long getItemId(int i) {
    return i;
  }

  @Override public View getView(int i, View view, ViewGroup viewGroup) {
    View v = view;
    if (v == null) {
      v = getLayoutInflater().inflate(R.layout.list_item, viewGroup, false);
    }

    PdfFile pdfFile = getItem(i);
    TextView name = v.findViewById(R.id.txtFileName);
    name.setText(pdfFile.getFileName());
    return v;
  }
};

private void initViews() {
  // получаем путь до внешнего хранилища
  String path = Environment.getExternalStorageDirectory().getAbsolutePath();
  initList(path);
  // устанавливаем адаптер в ListView
  listView.setAdapter(adapter);
  // когда пользователь выбирает PDF-файл из списка, открываем активность для просмотра
  listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
    @Override public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
      Intent intent = new Intent(MainActivity.this, PdfActivity.class);
      intent.putExtra("keyName", list.get(i).getFileName());
      intent.putExtra("fileName", list.get(i).getFilePath());
      startActivity(intent);
    }
  });
}

private void initList(String path) {
  try {
    File file = new File(path);
    File[] fileList = file.listFiles();
    String fileName;
    for (File f : fileList) {
      if (f.isDirectory()) {
        initList(f.getAbsolutePath());
      } else {
        fileName = f.getName();
        if (fileName.endsWith(".pdf")) {
          list.add(new PdfFile(fileName, f.getAbsolutePath()));
        }
      }
    }
  } catch (Exception e) {
    e.printStackTrace();
  }
}

Шаг 2. Создание активности для просмотра

Здесь мы создадим активность, в которой будет просматриваться выбранный PDF-файл. Отрендеренная страница PDF будет загружаться в компонент ImageView. Также здесь будут располагаться кнопки переключения страниц и увеличения/уменьшения масштаба страницы.

Добавим Empty Activity, назвав PdfActivity, и в коде разметки активности добавим следующий код.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".PdfActivity"
    >

  <ScrollView
      android:layout_width="match_parent"
      android:layout_height="0dp"
      android:layout_weight="1"
      >

    <HorizontalScrollView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        >

      <ImageView
          android:id="@+id/imgView"
          android:layout_width="wrap_content"
          android:layout_height="wrap_content"
          android:background="@android:color/white"
          android:contentDescription="@null"
          />

    </HorizontalScrollView>

  </ScrollView>

  <LinearLayout
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:orientation="horizontal"
      >

    <Button
        android:id="@+id/btnPrevious"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_gravity="center_vertical"
        android:layout_weight="1"
        android:background="@null"
        android:text="Назад"
        android:textColor="@drawable/btn_color"
        style="?android:attr/buttonBarButtonStyle"
        />

    <Button
        android:id="@+id/btnNext"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_gravity="center_vertical"
        android:layout_weight="1"
        android:background="@android:color/white"
        android:text="Вперёд"
        android:textColor="@drawable/btn_color"
        style="?android:attr/buttonBarButtonStyle"
        />

    <ImageButton
        android:id="@+id/zoomout"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_gravity="center_vertical"
        android:layout_margin="0dp"
        android:layout_weight="0.5"
        android:background="@null"
        android:padding="8dp"
        android:src="@drawable/ic_zoom_out_black_24dp"
        />

    <ImageButton
        android:id="@+id/zoomin"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_gravity="center_vertical"
        android:layout_margin="0dp"
        android:layout_weight="0.5"
        android:background="@null"
        android:padding="8dp"
        android:src="@drawable/ic_zoom_in_black_24dp"
        />

  </LinearLayout>

</LinearLayout>

При запуске активности в методе onCreate() получаем имя и путь файла из интента, а также определяем компоненты и устанавливает слушатели на кнопки.

private String path;
private ImageView imgView;
private Button btnPrevious, btnNext;
private int currentPage = 0;
private ImageButton btn_zoomin, btn_zoomout;

@Override protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.activity_pdf);
  if (getSupportActionBar() != null) {
    getSupportActionBar().setDisplayHomeAsUpEnabled(true);
  }

  path = getIntent().getStringExtra("fileName");
  setTitle(getIntent().getStringExtra("keyName"));

  // если в банлде есть номер страницы - забираем его
  if (savedInstanceState != null) {
    currentPage = savedInstanceState.getInt(CURRENT_PAGE, 0);
  }

  imgView = findViewById(R.id.imgView);
  btnPrevious = findViewById(R.id.btnPrevious);
  btnNext = findViewById(R.id.btnNext);
  btn_zoomin = findViewById(R.id.zoomin);
  btn_zoomout = findViewById(R.id.zoomout);
  // устанавливаем слушатели на кнопки
  btnPrevious.setOnClickListener(this);
  btnNext.setOnClickListener(this);
  btn_zoomin.setOnClickListener(this);
  btn_zoomout.setOnClickListener(this);
}

Затем в методе onStart() вызывается метод openPdfRenderer(), в котором происходит получение дескриптора файла для поиска PDF и создание объекта PdfRenderer. После этого вызывается метод displayPage(), который рендерит выбранную страницу.

private PdfRenderer pdfRenderer;
private PdfRenderer.Page curPage;
private ParcelFileDescriptor descriptor;
private float currentZoomLevel = 5;

@Override public void onStart() {
  super.onStart();
  try {
    openPdfRenderer();
    displayPage(currentPage);
  } catch (Exception e) {
    Toast.makeText(this, "PDF-файл защищен паролем.", Toast.LENGTH_SHORT).show();
  }
}

private void openPdfRenderer() {
    File file = new File(path);
    descriptor = null;
    pdfRenderer = null;
    try {
      descriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
      pdfRenderer = new PdfRenderer(descriptor);
    } catch (Exception e) {
      Toast.makeText(this, "Ошибка", Toast.LENGTH_LONG).show();
    }
  }

private void displayPage(int index) {
  if (pdfRenderer.getPageCount() <= index) return;
  // закрываем текущую страницу
  if (curPage != null) curPage.close();
  // открываем нужную страницу
  curPage = pdfRenderer.openPage(index);
  // определяем размеры Bitmap
  int newWidth = (int) (getResources().getDisplayMetrics().widthPixels * curPage.getWidth() / 72
      * currentZoomLevel / 40);
  int newHeight =
      (int) (getResources().getDisplayMetrics().heightPixels * curPage.getHeight() / 72
          * currentZoomLevel / 64);
  Bitmap bitmap = Bitmap.createBitmap(newWidth, newHeight, Bitmap.Config.ARGB_8888);
  Matrix matrix = new Matrix();
  float dpiAdjustedZoomLevel = currentZoomLevel * DisplayMetrics.DENSITY_MEDIUM
      / getResources().getDisplayMetrics().densityDpi;
  matrix.setScale(dpiAdjustedZoomLevel, dpiAdjustedZoomLevel);
  curPage.render(bitmap, null, matrix, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY);
  // отображаем результат рендера
  imgView.setImageBitmap(bitmap);
  // проверяем, нужно ли делать кнопки недоступными
  int pageCount = pdfRenderer.getPageCount();
  btnPrevious.setEnabled(0 != index);
  btnNext.setEnabled(index + 1 < pageCount);
  btn_zoomout.setEnabled(currentZoomLevel != 2);
  btn_zoomin.setEnabled(currentZoomLevel != 12);
}

Что касается метода displayPage(), в нём для каждой страницы, которую нужно отрендерить, открываем эту страницу, рендерим её и закрываем страницу. В результате рендера страница сохраняется в Bitmap с заданными размерами и зумом, после чего загружается в ImageView.

Обработчики кнопок вынесены в onClick(), здесь ничего особенного нет: кнопки «Назад» и «Вперёд» изменяют текущий индекс страницы в нужную сторону и вызывают метод displayPage(), аналогично кнопки зума изменяют уровень зума и вызывают метод displayPage() с текущим индексом.

@Override public void onClick(View v) {
  switch (v.getId()) {
    case R.id.btnPrevious: {
      // получаем индекс предыдущей страницы
      int index = curPage.getIndex() - 1;
      displayPage(index);
      break;
    }
    case R.id.btnNext: {
      // получаем индекс следующей страницы
      int index = curPage.getIndex() + 1;
      displayPage(index);
      break;
    }
    case R.id.zoomout: {
      // уменьшаем зум
      --currentZoomLevel;
      displayPage(curPage.getIndex());
      break;
    }
    case R.id.zoomin: {
      // увеличиваем зум
      ++currentZoomLevel;
      displayPage(curPage.getIndex());
      break;
    }
  }
}

Для того, чтобы при перевороте экрана запомнить текущий индекс страницы, переопределён метод onSaveInstanceBundle(), в котором в бандл помещается индекс страницы. Затем этот индекс забирается в onCreate() активности, что было показано выше.

@Override protected void onSaveInstanceState(Bundle outState) {
  super.onSaveInstanceState(outState);
  if (curPage != null) {
    outState.putInt(CURRENT_PAGE, curPage.getIndex());
  }
}

При завершении работы и закрытии активности нужно закрыть PdfRenderer, для этого в переопределённом методе onStop() вызывается метод closePdfRenderer(), в котором удаляются все данные рендера.

@Override public void onStop() {
  try {
    closePdfRenderer();
  } catch (IOException e) {
    e.printStackTrace();
  }
  super.onStop();
}

private void closePdfRenderer() throws IOException {
  if (curPage != null) curPage.close();
  if (pdfRenderer != null) pdfRenderer.close();
  if (descriptor != null) descriptor.close();
}

В результате получилось простое приложение, способное открывать и показывать PDF-файлы.

Заключение

В этой статье мы рассмотрели, как можно использовать PdfRenderer API для отображения PDF-файлов в приложении. В следующей статье мы рассмотрим, как создать свой PDF-файл.

Читайте также

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *