Sharing content to your app – Android Sharesheet

This time let’s take a look how to receive data into Android application based on Capacitor. This post is post #3 of series around sharing content. You can find previous post and read how to share a post to iOS and how to customize share view in iOS.

tldr; If you want to grab the code, you can download it here from branch post-3

Manifest & Intent

First thing to do is to add Intent definitions to Manifest file. Similar to the iOS version the intent definition can define if it accepts single or multiple items as well as type of each item: text, image or video.

Let’s add an intent that will accept a single image. Go to app/src/main/AndroidManifest.xml and add inside your main activity.

<intent-filter>
  <action android:name="android.intent.action.SEND" />
  <category android:name="android.intent.category.DEFAULT" />
  <data android:mimeType="image/*" />
</intent-filter>

And that already makes the app visible in the share sheet!

If you click the app, it will just open it without doing anything special. Now we need to code the intent. Let’s create a Kotlin class called IntentHandler to handle the data and just invoke it from MainActivity.

You can also create Java class instead of Kotlin

If you’re having problems with adding Kotlin files to Capacitor project, check Android Setup in this post
// IntentHandler.kt
package com.example.app

import android.content.ContentResolver
import android.content.Intent
import android.net.Uri
import android.util.Log
import java.io.IOException


class IntentHandler {
  private val intent: Intent
  private val contentResolver: ContentResolver
  private val plugin: ShareSheetPlugin

  constructor(intent: Intent, contentResolver: ContentResolver, plugin: ShareSheetPlugin){
    this.intent = intent
    this.contentResolver = contentResolver
    this.plugin = plugin
  }

  fun handle() {
    if (this.intent.action != Intent.ACTION_SEND){
      // Ignore, we will support one case for now
      return;
    }
    val imageUri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
    if (imageUri == null) {
      Log.e("Capacitor share extension", "Invalid image")
      return;
    }
    try {
      plugin.notifyImageReceived(imageUri.toString())
    } catch (e: IOException) {
      e.printStackTrace()
    }
  }
}

Contrary to what we have done in iOS version, this time let’s pass the URI and handle all of the work inside Ionic. Alternatively you could create a new Activity with its own UI and once it’s completed the result could be passed to the Ionic part.

Next step is to add a plugin to pass over the results to Ionic part.

// ShareSheetPlugin.kt
package com.example.app

import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.annotation.CapacitorPlugin

@CapacitorPlugin(name = "ShareSheetPlugin")
class ShareSheetPlugin : Plugin() {
  fun notifyImageReceived(uri: String) {
    val ret = JSObject()
    ret.put("uri", uri)
    notifyListeners("shared_image_received", ret)
  }
}

Plugin implementation doesn’t expose any CapMethods, but rather we use events. This way Ionic app will listen to the events as observables and react if any is invoked.

Finally we add a handle when onCreate or onNewIntent is invoked in MainActivity

// MainActivity.java
package com.example.app;

import android.content.Intent;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;

public class MainActivity extends BridgeActivity {
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    registerPlugin(ShareSheetPlugin.class);

    super.onCreate(savedInstanceState);
    var intent = getIntent();
    if (intent != null){
      var plugin = (ShareSheetPlugin)bridge.getPlugin("ShareSheetPlugin").getInstance();
      new IntentHandler(intent, this.getContentResolver(), plugin).handle();
    }
  }

  @Override
  protected void onNewIntent(Intent intent) {
    super.onNewIntent(intent);
    var plugin = (ShareSheetPlugin)bridge.getPlugin("ShareSheetPlugin").getInstance();
    new IntentHandler(intent, this.getContentResolver(), plugin).handle();
  }
}

One execution is in onCreate method when the app is being created. The second method onNewIntent is called once a new intent is incoming to the app. An intent in our case could be when the app is selected from the Share Sheet.

Handle data in Ionic

There wasn’t actually much work to add a support for Android Share Sheet. Let’s extend it and let user pick up from a list of categories as it was done in previous post. We have two options here: create a separate Activity and fill it with data as was done with iOS example or pass the data to the app using Ionic events and handle it from Ionic. This time let’s push more work to Ionic so we’ll use the second option.

Since the model differs from iOS once, I’ll add different components, services and models for Android. Since this blog posts purpose is to show possibilities I think that’s fine, in real scenario prefer one method to keep the code in line between platforms.

Firstly we add a service to handle plugin communication between Ionic and Android Native.

// android-share-sheet-plugin.service.ts
import { Injectable } from '@angular/core';
import {
  Capacitor,
  PluginListenerHandle,
  PluginResultData,
  PluginResultError,
  registerPlugin,
} from '@capacitor/core';
import { Observable, Subject } from 'rxjs';

const _pluginName: string = 'ShareSheetPlugin';

export class AndroidSharedImage {
  uri: string = '';
}

export interface AndroidShareSheetPlugin {}
const ShareSheetPlugin = registerPlugin<AndroidShareSheetPlugin>(_pluginName);

@Injectable({
  providedIn: 'root',
})
export class AndroidShareSheetPluginService {
  private readonly _shareReceived = 'shared_image_received';
  private _share$: Subject<AndroidSharedImage> | null = null;
  private _shareHandler: PluginListenerHandle | null = null;

  watch(): Observable<AndroidSharedImage> {
    if (!this._share$) {
      this._share$ = new Subject<AndroidSharedImage>();
    } else {
      return this._share$;
    }
    if (
      Capacitor.isPluginAvailable(_pluginName) &&
      typeof Capacitor.addListener !== 'undefined'
    ) {
      this._shareHandler = Capacitor.addListener(
        _pluginName,
        this._shareReceived,
        (data: PluginResultData, error?: PluginResultError) => {
          const result: AndroidSharedImage = {
            uri: data['uri'],
          };
          this._share$?.next(result);
        }
      );
    }
    return this._share$;
  }

  async unsubscribe() {
    this._shareHandler?.remove();
    this._share$?.complete();
    this._share$ = null;
  }
}

Note that we don’t use any methods here. We simply create an event listener that will watch over event ’share_image_received’. If it occurs it will push the result to subject.

For the services we also need some place to handle storage and filesystem. Let’s use simple preferences to keep example short.

// android-storage.service.ts 
import { Injectable } from '@angular/core';
import { Directory, Filesystem } from '@capacitor/filesystem';
import { Preferences } from '@capacitor/preferences';
import * as uuid from 'uuid';

export class AndroidCategory {
  id: number = -1;
  name: string = '';
  images: string[] = [];
}

@Injectable({
  providedIn: 'root',
})
export class AndroidStorageService {
  constructor() {
    Filesystem.mkdir({
      path: 'images',
      directory: Directory.Data,
    })
      .then(() => {})
      .catch(() => {});
  }

  async getCategories(): Promise<AndroidCategory[]> {
    const categoriesPreference = await Preferences.get({
      key: 'categories',
    });
    let categories = [];
    try {
      categories = JSON.parse(categoriesPreference.value as string);
    } catch (e) {
      categories = [];
    }

    return categories;
  }

  async saveFile(source: string) {
    const filename = `images/${uuid.v4()}.jpg`;
    const read = await Filesystem.readFile({
      path: source,
    });

    const result = await Filesystem.writeFile({
      path: filename,
      directory: Directory.Data,
      data: read.data,
    });
    return result.uri;
  }

  async setCategories(categories: AndroidCategory[]) {
    await Preferences.set({
      key: 'categories',
      value: JSON.stringify(categories),
    });
  }
}

That will be responsible to save the categories and also to save file in local file system once it will be selected.

Android share modal

If the event occurs and the share took place we will open a modal inside the app to show possible options for the user. In this case it will be just a select with categories, where to save. This can be extend as needed.

<!-- android-share-handler.modal.html-->
<ion-header [translucent]="true">
  <ion-header>
    <ion-toolbar>
      <ion-buttons slot="start">
        <ion-button (click)="cancel()">Cancel</ion-button>
      </ion-buttons>
      <ion-buttons slot="end">
        <ion-button (click)="save()">save</ion-button>
      </ion-buttons>
      <ion-title>Save image</ion-title>
    </ion-toolbar>
  </ion-header>
</ion-header>

<ion-content [fullscreen]="true">
  <div id="container">
    <div>
      <ion-img [src]="imageUri"></ion-img>
    </div>
    <ion-item>
      <ion-select [(ngModel)]="categorySelected">
        <ion-select-option *ngFor="let category of categories" [value]="category.id">{{category.name}}</ion-select-option>
      </ion-select>
    </ion-item>
  </div>
</ion-content>

The logic behind this is pretty straightforward. We need to convert the uri using Ionic.WebView.convertFileSrc to properly display it from HTML. If the category is selected, and save is clicked then the image is pull from resource and copy to app data folder.

// android-share-handler.modal.ts
import { AndroidStorageService } from './../../../services/android-storage.service';
import { ModalController } from '@ionic/angular';
import { Component, Input } from '@angular/core';
import { AndroidCategory } from 'src/app/services/android-storage.service';

@Component({
  templateUrl: './android-share-handler.modal.html',
})
export class AndroidShareHandlerModal {
  @Input() uri: string = '';
  @Input() categories: AndroidCategory[] = [];
  imageUri: string = '';
  categorySelected: number = 0;
  constructor(
    private modal: ModalController,
    private androidStorageService: AndroidStorageService
  ) {}

  async ngOnInit() {
    if (!this.categories || this.categories.length === 0) {
      await this.modal.dismiss();
      return;
    }
    this.imageUri = (<any>window).Ionic.WebView.convertFileSrc(this.uri);
    this.categorySelected = this.categories[0].id;
  }

  async save() {
    const result = await this.androidStorageService.saveFile(this.uri);
    const categoryIndex = this.categories.findIndex(
      (c) => c.id === this.categorySelected
    );
    this.categories[categoryIndex].images.push(result);
    await this.androidStorageService.setCategories(this.categories);
    await this.modal.dismiss();
  }

  async cancel() {
    await this.modal.dismiss();
  }
}

Finally we add a dedicated page for Android to display the categories

<ion-item>
  <ion-label>Add category</ion-label>
  <ion-input [(ngModel)]="categoryName"></ion-input>
  <ion-button (click)="add()">Add</ion-button>
</ion-item>

<ion-list *ngIf="categories">
  <div *ngFor="let category of categories">
    <ion-item>
      {{category.name}} <ion-button (click)="remove(category.id)">Remove</ion-button>
    </ion-item>
    <div *ngFor="let item of category.images">
      <img [src]="item | imgNativeSrc"/>
    </div>
  </div>
</ion-list>
// img-native-src.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'imgNativeSrc',
})
export class ImgNativeSrcPipe implements PipeTransform {
  transform(url: string) {
    return (<any>window).Ionic.WebView.convertFileSrc(url);
  }
}

// android-categories.component.ts
import { ChangeDetectorRef, Component } from '@angular/core';
import { ModalController } from '@ionic/angular';
import {
  AndroidSharedImage,
  AndroidShareSheetPluginService,
} from 'src/app/services/android-share-sheet-plugin.service';
import {
  AndroidCategory,
  AndroidStorageService,
} from 'src/app/services/android-storage.service';
import { AndroidShareHandlerModal } from './android-share-handler/android-share-handler.modal';

@Component({
  templateUrl: 'android-categories.component.html',
  selector: 'rd-android-categories',
})
export class AndroidCategoriesComponent {
  categories: AndroidCategory[] = [];
  categoryName = '';
  sharedImage: string | null = null;
  url: string | null = null;
  constructor(
    private shareSheetPluginService: AndroidShareSheetPluginService,
    private androidStorageManager: AndroidStorageService,
    private modalController: ModalController,
    private cd: ChangeDetectorRef
  ) {}

  async ngOnInit() {
    await this.load();
    this.shareSheetPluginService
      .watch()
      .subscribe(async (data: AndroidSharedImage) => {
        if (data && data.uri) {
          const modal = await this.modalController.create({
            component: AndroidShareHandlerModal,
            componentProps: {
              uri: data.uri,
              categories: this.categories,
            },
          });
          modal.onDidDismiss().then(async () => {
            await this.load();
          });
          await modal.present();
        }
      });
  }

  async add() {
    if (!this.categoryName || this.categoryName.length < 1) {
      return;
    }
    const lastId =
      this.categories && this.categories.length > 0
        ? this.categories.sort((a, b) => (a.id >= b.id ? -1 : 1))[0].id + 1
        : 1;
    this.categories.push({
      id: lastId,
      name: this.categoryName,
      images: [],
    });
    await this.androidStorageManager.setCategories(this.categories);
    this.categoryName = '';
  }

  async load() {
    this.categories = await this.androidStorageManager.getCategories();
    this.cd.detectChanges();
  }

  async remove(id: number) {
    this.categories = this.categories.filter((c) => c.id !== id);
    await this.androidStorageManager.setCategories(this.categories);
  }
}

Most interesting part of the above code lies in the ngOnInit. Once we initialize the component we set it to watch over the events from plugin. If any occurs, then we display a modal with data from event.

Summary

Sharing data to app is as easy for Android as it is in iOS. As shown in the series, it also works well with Ionic application. Furthermore as shown in this post, the process of manipulating shared object could be passed to the Ionic app and taken from there.

If you are interested in the topic on iOS you can refer to below post:

Sharing Content To iOS Capacitor App

Customizing View for iOS Share View


Opublikowano

w

, ,

przez