LoginSignup
3
7

More than 5 years have passed since last update.

[webRTC for android vol2] 繋げてみる

Last updated at Posted at 2017-04-15

実際につなげてみる。
Android - iOS
(※Androidを1つしかもってないので相手はIosです)

WebRTCのビルド〜プロジェクトへの追加は 前回 webRTC for android vol1

TrickleICE は次回 webRTC for android vol3

Ios版とwampサーバーを作る記事は webRTC for ios vol2

成果物

参考

AppRTCDemo

設定

wampのライブラリは jawampa

Gradle

build.gradle(Project-android)
buildscript {
    repositories {
        jcenter()
        maven {
            url 'https://raw.github.com/Matthias247/jawampa/mvn-repo/'
        }
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:2.3.1'
    }
}

allprojects {
    repositories {
        jcenter()
        maven {
            url 'https://raw.github.com/Matthias247/jawampa/mvn-repo/'
        }
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}
build.gradle(Module-app)
apply plugin: 'com.android.application'

android {
    compileSdkVersion 24
    buildToolsVersion "25.0.2"
    defaultConfig {
        applicationId "com.nakadoribooks.webrtcexample"
        minSdkVersion 21
        targetSdkVersion 24
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
    packagingOptions {
        exclude 'META-INF/io.netty.versions.properties'
        exclude 'META-INF/INDEX.LIST'
        exclude 'META-INF/LICENSE'
        exclude 'META-INF/NOTICE'
    }
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    compile(name:'libwebrtc', ext:'aar')
    androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
        exclude group: 'com.android.support', module: 'support-annotations'
    })
    compile 'com.android.support:appcompat-v7:24.2.1'
    compile 'com.android.support.constraint:constraint-layout:1.0.2'
    testCompile 'junit:junit:4.12'
    compile 'ws.wamp.jawampa:jawampa:0.2.0'
    compile 'io.reactivex:rxandroid:0.24.0'
}

repositories{
    flatDir{
        dirs 'libs'
    }
}

AndroidManifest.xml

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

    <uses-feature android:name="android.hardware.camera" />
    <uses-feature android:name="android.hardware.camera.autofocus" />
    <uses-feature
        android:glEsVersion="0x00020000"
        android:required="true" />

    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.CAPTURE_VIDEO_OUTPUT" />
    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

Layout

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout 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"
    tools:context="com.nakadoribooks.webrtcexample.MainActivity">

    <android.support.v7.widget.LinearLayoutCompat
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <RelativeLayout
            xmlns:android="http://schemas.android.com/apk/res/android"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="12">

            <FrameLayout
                android:id="@+id/local_video_layout"
                android:layout_width="100dp"
                android:layout_height="150dp"
                android:layout_marginTop="300dp"
                android:layout_marginLeft="20dp"
                android:background="@color/colorAccent">
                <org.webrtc.SurfaceViewRenderer
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:id="@+id/local_render_view"
                    />
            </FrameLayout>

            <FrameLayout
                android:id="@+id/remote_video_layout"
                android:layout_width="match_parent"
                android:layout_height="350dp"
                android:background="@color/colorPrimary">
                <org.webrtc.SurfaceViewRenderer
                    android:id="@+id/remote_render_view"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent" />
            </FrameLayout>

        </RelativeLayout>

        <android.support.v7.widget.AppCompatTextView
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:text=""
            android:layout_weight="1"
            android:textAlignment="center"
            android:textSize="20dp"
            android:id="@+id/status_label"/>
        <android.support.v7.widget.AppCompatButton
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:text="Connect"
            android:id="@+id/control_button"
            android:textSize="20dp"
            android:layout_weight="2"/>
    </android.support.v7.widget.LinearLayoutCompat>

</android.support.constraint.ConstraintLayout>

実装抜粋

WebRTC

WebRTC.java
// interface -----------------
    public void connect(WebRTCCallbacks callbacks)
    public void startCapture()
    public void createOffer()
    public void receiveOffer(String sdp)
    public void receiveAnswer(String sdp)

// callbaks -----------------

public static interface WebRTCCallbacks{
    void onCreateLocalSdp(String sdp);
    void didReceiveRemoteStream();
}

Wamp

Wamp.java
// interface -------
    public void connect(WampCallbacks callbacks)
    public void publishOffer(String sdp)
    public void publishAnswer(String sdp)

// callbacks --------
 public static interface WampCallbacks{
    void onConnected();
    void onReceiveOffer(String sdp);
    void onReceiveAnswer(String sdp);
}

アプリケーション

MainActivity.java
// state -------
 private enum State{
        Disconnected
        , Connecting
        , Connected
        , Offering
        , ReceivedOffer
        , CreatingAnswer
        , Done
    }

// connect wamp --------
private void connect(){
    wamp.connect(new Wamp.WampCallbacks() {

        @Override
        public void onConnected() {
            changeState(State.Connected);
        }

        @Override
        public void onReceiveOffer(String sdp) {
            if(typeOffer){
                return;
            }
            changeState(State.CreatingAnswer);
            webRTC.receiveOffer(sdp);
        }

        @Override
        public void onReceiveAnswer(String sdp) {
            if(!typeOffer){
                return;
            }

            webRTC.receiveAnswer(sdp);
        }
    });
}

// start webRTC -----
private void startWebRTC(){
    webRTC = new WebRTC(this);
    webRTC.connect(new WebRTC.WebRTCCallbacks() {
        @Override
        public void onCreateLocalSdp(String sdp) {
            if(typeOffer){
                wamp.publishOffer(sdp);
            }else{
                wamp.publishAnswer(sdp);
            }
        }

        @Override
        public void didReceiveRemoteStream() {
            changeState(State.Done);
        }
    });
    webRTC.startCapture();
}

実装詳細

WebRTC

WebRTC.java

package com.nakadoribooks.webrtcexample;

import android.app.Activity;
import android.content.Context;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.WindowManager;

import org.webrtc.*;

import java.util.Arrays;
import java.util.List;

/**
 * Created by kawase on 2017/04/13.
 */

public class WebRTC implements PeerConnection.Observer {

    public static interface WebRTCCallbacks{
        void onCreateLocalSdp(String sdp);
        void didReceiveRemoteStream();
    }

    private static abstract class SkeletalSdpObserver implements SdpObserver{

        private static final String TAG = "SkeletalSdpObserver";

        @Override
        public void onCreateSuccess(SessionDescription sessionDescription) {}
        @Override
        public void onSetSuccess() {}
        @Override
        public void onCreateFailure(String s) {}
        @Override
        public void onSetFailure(String s) {}
    }

    private static final String TAG = "WebRTC";

    private final Activity activity;
    private WebRTCCallbacks callbacks;
    private PeerConnectionFactory factory;
    private PeerConnection peerConnection;
    private MediaStream localStream;
    private VideoCapturer videoCapturer;
    private EglBase eglBase;

    private VideoTrack localVideoTrack;
    private VideoRenderer localRenderer;
    private VideoRenderer remoteRenderer;

    WebRTC(Activity activity){
        this.activity = activity;
    }

    // interface -----------------

    public void connect(WebRTCCallbacks callbacks){
        this.callbacks = callbacks;
        setupPeerConnection();
        setupLocalStream();

        peerConnection.addStream(localStream);
    }

    public void startCapture(){
        _startCapture();
    }

    public void createOffer(){
        _createOffer();
    }

    public void receiveOffer(String sdp){
        _receiveOffer(sdp);
    }

    public void receiveAnswer(String sdp){
        _receiveAnswer(sdp);
    }

    // implements -------------

    private void setupPeerConnection(){
        // rendereContext
        eglBase = EglBase.create();

        // initialize Factory
        PeerConnectionFactory.initializeAndroidGlobals(activity.getApplicationContext(), true);
        PeerConnectionFactory.Options options = new PeerConnectionFactory.Options();
        factory = new PeerConnectionFactory(options);
        factory.setVideoHwAccelerationOptions(eglBase.getEglBaseContext(), eglBase.getEglBaseContext());

        // create PeerConnection
        List<PeerConnection.IceServer> iceServers = Arrays.asList(new PeerConnection.IceServer("stun:stun.l.google.com:19302"));
        peerConnection = factory.createPeerConnection(iceServers, WebRTCUtil.peerConnectionConstraints(), this);
    }

    private void _receiveAnswer(String sdp){
        SessionDescription remoteDescription = new SessionDescription(SessionDescription.Type.ANSWER, sdp);
        peerConnection.setRemoteDescription(new SkeletalSdpObserver() {
            @Override
            public void onSetSuccess() {

            }
        }, remoteDescription);
    }

    private void _receiveOffer(String sdp){
        // setRemoteDescription
        SessionDescription remoteDescription = new SessionDescription(SessionDescription.Type.OFFER, sdp);
        peerConnection.setRemoteDescription(new SkeletalSdpObserver() {
            @Override
            public void onSetSuccess() {

                // createAnswer
                peerConnection.createAnswer(new SkeletalSdpObserver() {
                    @Override
                    public void onCreateSuccess(SessionDescription sessionDescription) {
                        peerConnection.setLocalDescription(new SkeletalSdpObserver() {}, sessionDescription);
                    }
                }, WebRTCUtil.answerConnectionConstraints());

            }
        }, remoteDescription);
    }

    private void _createOffer(){
        peerConnection.createOffer(new SkeletalSdpObserver() {
            @Override
            public void onCreateSuccess(SessionDescription sessionDescription) {
                peerConnection.setLocalDescription(new SkeletalSdpObserver() {}, sessionDescription);
            }
        }, WebRTCUtil.offerConnectionConstraints());
    }

    private void setupLocalStream() {

        localStream = factory.createLocalMediaStream("android_local_stream");

        // videoTrack
        videoCapturer = createCameraCapturer(new Camera2Enumerator(activity));
        VideoSource localVideoSource = factory.createVideoSource(videoCapturer);
        localVideoTrack = factory.createVideoTrack("android_local_videotrack", localVideoSource);
        localStream.addTrack(localVideoTrack);

        // render
        localRenderer = setupRenderer(R.id.local_render_view);
        localVideoTrack.addRenderer(localRenderer);

        // audioTrack
        AudioSource audioSource = factory.createAudioSource(WebRTCUtil.mediaStreamConstraints());
        AudioTrack audioTrack = factory.createAudioTrack("android_local_audiotrack", audioSource);
        localStream.addTrack(audioTrack);
    }

    private void _startCapture(){
        DisplayMetrics displayMetrics = new DisplayMetrics();
        WindowManager windowManager =
                (WindowManager) activity.getApplication().getSystemService(Context.WINDOW_SERVICE);
        windowManager.getDefaultDisplay().getRealMetrics(displayMetrics);
        int videoWidth = displayMetrics.widthPixels;
        int videoHeight = displayMetrics.heightPixels;

        videoCapturer.startCapture(videoWidth, videoHeight, 30);
    }

    private VideoRenderer setupRenderer(int viewId){

        SurfaceViewRenderer renderer = (SurfaceViewRenderer) activity.findViewById(viewId);
        renderer.init(eglBase.getEglBaseContext(), null);
        renderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL);
        renderer.setZOrderMediaOverlay(true);
        renderer.setEnableHardwareScaler(true);

        return new VideoRenderer(renderer);
    }

    private VideoCapturer createCameraCapturer(CameraEnumerator enumerator) {
        return createBackCameraCapturer(enumerator);
    }

    private VideoCapturer createBackCameraCapturer(CameraEnumerator enumerator) {
        final String[] deviceNames = enumerator.getDeviceNames();

        for (String deviceName : deviceNames) {
            if (!enumerator.isFrontFacing(deviceName)) {
                VideoCapturer videoCapturer = enumerator.createCapturer(deviceName, null);

                if (videoCapturer != null) {
                    return videoCapturer;
                }
            }
        }

        return null;
    }

    // PeerConnection.Observer -----

    @Override
    public void onSignalingChange(PeerConnection.SignalingState signalingState) {}
    @Override
    public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) {}
    @Override
    public void onIceConnectionReceivingChange(boolean b) {}
    @Override
    public void onRemoveStream(MediaStream mediaStream) {}
    @Override
    public void onDataChannel(DataChannel dataChannel) {}
    @Override
    public void onRenegotiationNeeded() {}
    @Override
    public void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) {}
    @Override
    public void onIceCandidate(IceCandidate iceCandidate) {}
    @Override
    public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {}

    @Override
    public void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) {
        Log.d(TAG, "onIceGatheringChange");
        if(iceGatheringState == PeerConnection.IceGatheringState.COMPLETE){
            Log.d(TAG, "Complete");
            SessionDescription localSdp = peerConnection.getLocalDescription();
            callbacks.onCreateLocalSdp(localSdp.description);
        }
    }

    @Override
    public void onAddStream(MediaStream mediaStream) {

        if (mediaStream.videoTracks.size() == 0){
            return;
        }

        final VideoTrack remoteVideoTrack = mediaStream.videoTracks.getFirst();
        activity.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                remoteRenderer = setupRenderer(R.id.remote_render_view);
                remoteVideoTrack.addRenderer(remoteRenderer);

                callbacks.didReceiveRemoteStream();
            }
        });

    }
}

Wamp

Wamp.java
package com.nakadoribooks.webrtcexample;

import android.app.Activity;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;

import java.util.concurrent.TimeUnit;

import rx.android.app.AppObservable;
import rx.functions.Action0;
import rx.functions.Action1;
import ws.wamp.jawampa.PubSubData;
import ws.wamp.jawampa.WampClient;
import ws.wamp.jawampa.WampClientBuilder;
import ws.wamp.jawampa.WampError;

/**
 * Created by kawase on 2017/04/15.
 */

public class Wamp {

    public static interface WampCallbacks{
        void onConnected();
        void onReceiveOffer(String sdp);
        void onReceiveAnswer(String sdp);
    }

    private static final String TAG = "Wamp";
    private static final String AnswerTopic = "com.nakadoribook.webrtc.answer";
    private static final String OfferTopic = "com.nakadoribook.webrtc.offer";

    private final Activity activity;
    private WampClient wampClient;
    private WampCallbacks callbacks;

    Wamp(Activity activity){
        this.activity = activity;
    }

    // interface -------

    public void connect(WampCallbacks callbacks){
        this.callbacks = callbacks;
        _connect();
    }

    public void publishOffer(String sdp){
        _publishOffer(sdp);
    }

    public void publishAnswer(String sdp){
        _publishAnswer(sdp);
    }

    // implements --------

    private void _connect(){
        WampClientBuilder builder = new WampClientBuilder();

        try {
//            builder.withUri("ws://192.168.1.2:8000")
            builder.withUri("wss://nakadoribooks-webrtc.herokuapp.com")
                    .withRealm("realm1")
                    .withInfiniteReconnects()
                    .withReconnectInterval(3, TimeUnit.SECONDS);
            wampClient = builder.build();
        } catch (WampError e) {
            return;
        }

        AppObservable.bindActivity(activity, wampClient.statusChanged())
                .subscribe(new Action1<WampClient.Status>() {
                    @Override
                    public void call(final WampClient.Status status) {

                        if (status == WampClient.Status.Connected) {

                            callbacks.onConnected();

                            wampClient.makeSubscription(OfferTopic).subscribe(new Action1<PubSubData>(){
                                @Override
                                public void call(PubSubData arg0) {

                                    JsonNode json = arg0.arguments().get(0);
                                    String sdp = json.get("sdp").asText();

                                    callbacks.onReceiveOffer(sdp);
                                }

                            }, new Action1<Throwable>(){
                                @Override
                                public void call(Throwable arg0) {}
                            });

                            wampClient.makeSubscription(AnswerTopic).subscribe(new Action1<PubSubData>(){
                                @Override
                                public void call(PubSubData arg0) {
                                    JsonNode json = arg0.arguments().get(0);
                                    String sdp = json.get("sdp").asText();

                                    callbacks.onReceiveAnswer(sdp);
                                }

                            }, new Action1<Throwable>(){
                                @Override
                                public void call(Throwable arg0) {}
                            });
                        }
                    }
                }, new Action1<Throwable>() {
                    @Override
                    public void call(final Throwable t) {}
                }, new Action0() {
                    @Override
                    public void call() {}
                });

        wampClient.open();
    }


    public void _publishOffer(String sdp){

        final ObjectMapper mapper = new ObjectMapper();
        ObjectNode node = mapper.createObjectNode();
        node.put("type", "offer");
        node.put("sdp", sdp);
        wampClient.publish(OfferTopic, node);
    }

    public void _publishAnswer(String sdp){
        final ObjectMapper mapper = new ObjectMapper();
        ObjectNode node = mapper.createObjectNode();
        node.put("type", "answer");
        node.put("sdp", sdp);

        wampClient.publish(AnswerTopic, node);
    }

}

アプリケーション

MainActivity.java
package com.nakadoribooks.webrtcexample;

import android.Manifest;
import android.graphics.Color;
import android.support.v4.app.ActivityCompat;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;

public class MainActivity extends AppCompatActivity {

    private enum State{
        Disconnected
        , Connecting
        , Connected
        , Offering
        , ReceivedOffer
        , CreatingAnswer
        , Done
    }

    private static final String TAG = "MainActivity";
    private static final int REQUEST_CODE_CAMERA_PERMISSION = 1;
    private WebRTC webRTC;
    private Wamp wamp;
    private State state = State.Disconnected;

    private Button controlButton;
    private TextView statusText;
    private boolean typeOffer = false;

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

        // getView
        controlButton = (Button) findViewById(R.id.control_button);
        statusText = (TextView) findViewById(R.id.status_label);

        // registerEvent
        findViewById(R.id.control_button).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                onTapButton();
            }
        });

        // wamp
        wamp = new Wamp(this);

        // checkPermission → onRequestPermissionsResult → startWebRTC
        checkPermission();
    }

    @Override
    public void onRequestPermissionsResult(int requestCode,
                                           String permissions[], int[] grantResults) {
        if (requestCode != REQUEST_CODE_CAMERA_PERMISSION)
            return;

        startWebRTC();
    }

    private void checkPermission(){
        String[] permissioins = new String[]{ Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO};
        ActivityCompat.requestPermissions(this, permissioins, REQUEST_CODE_CAMERA_PERMISSION);
    }

    private void onTapButton(){
        if(state == State.Disconnected){
            changeState(State.Connecting);
            connect();
        }else if(state == State.Connected){
            typeOffer = true;
            changeState(State.Offering);
            stateOffering();
            webRTC.createOffer();
        }
    }

    private void connect(){
        wamp.connect(new Wamp.WampCallbacks() {

            @Override
            public void onConnected() {
                changeState(State.Connected);
            }

            @Override
            public void onReceiveOffer(String sdp) {
                if(typeOffer){
                    return;
                }
                changeState(State.CreatingAnswer);
                webRTC.receiveOffer(sdp);
            }

            @Override
            public void onReceiveAnswer(String sdp) {
                if(!typeOffer){
                    return;
                }

                webRTC.receiveAnswer(sdp);
            }
        });
    }

    private void startWebRTC(){

        webRTC = new WebRTC(this);
        webRTC.connect(new WebRTC.WebRTCCallbacks() {
            @Override
            public void onCreateLocalSdp(String sdp) {
                if(typeOffer){
                    wamp.publishOffer(sdp);
                }else{
                    wamp.publishAnswer(sdp);
                }
            }

            @Override
            public void didReceiveRemoteStream() {
                changeState(State.Done);
            }
        });
        webRTC.startCapture();
    }

    // view ---------------

    private void stateConnecting(){
        statusText.setText("Connecting");
        controlButton.setText("Connecting...");
        controlButton.setEnabled(false);
    }

    private void stateConnected(){
        statusText.setText("connected");
        statusText.setTextColor(Color.BLUE);
        controlButton.setText("Send Offer");
        controlButton.setEnabled(true);
    }

    private void stateOffering(){
        statusText.setText("Offering...");
        controlButton.setText("Offering...");
        controlButton.setEnabled(false);
    }

    private void stateReceivedOffer(){
        statusText.setText("ReceivedOffer");
        controlButton.setText("ReceivedOffer");
        controlButton.setEnabled(false);
    }

    private void stateCreatingAnswer(){
        statusText.setText("CreatingAnswer...");
        controlButton.setText("CreatingAnswer...");
        controlButton.setEnabled(false);
    }

    private void stateDone(){
        statusText.setText("OK!");
        controlButton.setText("OK!");
        controlButton.setEnabled(false);
    }

    private void changeState(final State state){
        this.state = state;
        runOnUiThread(new Runnable() {
            @Override
            public void run() {
                switch (state){
                    case Connected:
                        stateConnected();
                        break;
                    case Connecting:
                        stateConnecting();
                        break;
                    case CreatingAnswer:
                        stateCreatingAnswer();
                        break;
                    case ReceivedOffer:
                        stateReceivedOffer();
                        break;
                    case Done:
                        stateDone();
                    default:
                        break;
                }
            }
        });
    }
}

美酒の設計

秋田の地酒 雪の茅舎美酒の設計純米吟醸火入れタイプ1,8L  齋弥酒造
http://www.sudasaketen.com/item/k0003/

美酒の設計 単行本 – 2009/11/26 藤田 千恵子 (著)
http://amzn.asia/efouKSJ

Trickle ICE を予定

3
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
7