commit af88bf2e8df5023b3390fdbb965236e23a784f6c
Author: Leszek Koltunski <leszek@koltunski.pl>
Date:   Tue Oct 27 00:15:58 2020 +0000

    New 'tutorial' activity.

diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index 614091b9..5602d235 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -30,5 +30,7 @@
                 <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
         </activity>
+
+        <activity android:name="org.distorted.tutorial.TutorialActivity" android:screenOrientation="portrait"/>
     </application>
 </manifest>
diff --git a/src/main/java/org/distorted/dialogs/RubikDialogInfo.java b/src/main/java/org/distorted/dialogs/RubikDialogInfo.java
index 1a024804..8762d67f 100644
--- a/src/main/java/org/distorted/dialogs/RubikDialogInfo.java
+++ b/src/main/java/org/distorted/dialogs/RubikDialogInfo.java
@@ -21,6 +21,7 @@ package org.distorted.dialogs;
 
 import android.app.Dialog;
 import android.content.DialogInterface;
+import android.content.Intent;
 import android.os.Bundle;
 import android.util.DisplayMetrics;
 import android.util.TypedValue;
@@ -37,8 +38,8 @@ import androidx.fragment.app.FragmentActivity;
 
 import org.distorted.main.R;
 import org.distorted.main.RubikActivity;
-import org.distorted.main.RubikPreRender;
 import org.distorted.objects.TwistyObject;
+import org.distorted.tutorial.TutorialActivity;
 
 ///////////////////////////////////////////////////////////////////////////////////////////////////
 
@@ -73,7 +74,11 @@ public class RubikDialogInfo extends AppCompatDialogFragment
       @Override
       public void onClick(DialogInterface dialog, int which)
         {
+        RubikActivity ract = (RubikActivity)getContext();
+        Intent myIntent = new Intent(ract, TutorialActivity.class);
+        //myIntent.putExtra("", value); //Optional parameters
 
+        if( ract!=null ) ract.startActivity(myIntent);
         }
       });
 
diff --git a/src/main/java/org/distorted/effects/BaseEffect.java b/src/main/java/org/distorted/effects/BaseEffect.java
index 772bac22..3d37932a 100644
--- a/src/main/java/org/distorted/effects/BaseEffect.java
+++ b/src/main/java/org/distorted/effects/BaseEffect.java
@@ -29,7 +29,6 @@ import org.distorted.effects.solve.SolveEffect;
 import org.distorted.effects.win.WinEffect;
 import org.distorted.library.main.DistortedScreen;
 import org.distorted.main.R;
-import org.distorted.main.RubikPreRender;
 
 ///////////////////////////////////////////////////////////////////////////////////////////////////
 
@@ -197,17 +196,17 @@ public class BaseEffect
 
   ////////////////////////////////////////////////////////////////////////////////
 
-    public long startEffect(DistortedScreen screen, RubikPreRender pre) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException
+    public long startEffect(DistortedScreen screen, EffectController cont) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException
       {
       Method method1 = mClass.getDeclaredMethod("create", int.class);
 
       Object value1 = method1.invoke(null,mCurrentType);
       BaseEffect baseEffect = (BaseEffect)value1;
 
-      Method method2 = mClass.getDeclaredMethod("start", int.class, DistortedScreen.class, RubikPreRender.class);
+      Method method2 = mClass.getDeclaredMethod("start", int.class, DistortedScreen.class, EffectController.class);
 
       Integer translated = translatePos(mCurrentPos)+1;
-      Object value2 = method2.invoke(baseEffect,translated,screen,pre);
+      Object value2 = method2.invoke(baseEffect,translated,screen,cont);
       return (Long)value2;
       }
 
diff --git a/src/main/java/org/distorted/effects/EffectController.java b/src/main/java/org/distorted/effects/EffectController.java
new file mode 100644
index 00000000..a68e5223
--- /dev/null
+++ b/src/main/java/org/distorted/effects/EffectController.java
@@ -0,0 +1,34 @@
+///////////////////////////////////////////////////////////////////////////////////////////////////
+// Copyright 2020 Leszek Koltunski                                                               //
+//                                                                                               //
+// This file is part of Magic Cube.                                                              //
+//                                                                                               //
+// Magic Cube is free software: you can redistribute it and/or modify                            //
+// it under the terms of the GNU General Public License as published by                          //
+// the Free Software Foundation, either version 2 of the License, or                             //
+// (at your option) any later version.                                                           //
+//                                                                                               //
+// Magic Cube is distributed in the hope that it will be useful,                                 //
+// but WITHOUT ANY WARRANTY; without even the implied warranty of                                //
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                                 //
+// GNU General Public License for more details.                                                  //
+//                                                                                               //
+// You should have received a copy of the GNU General Public License                             //
+// along with Magic Cube.  If not, see <http://www.gnu.org/licenses/>.                           //
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+package org.distorted.effects;
+
+import org.distorted.library.message.EffectListener;
+import org.distorted.main.RubikPreRender;
+import org.distorted.objects.TwistyObject;
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+public interface EffectController extends EffectListener
+  {
+  void addRotation(RubikPreRender.ActionFinishedListener listener, int axis, int rowBitmap, int angle, long duration);
+  TwistyObject getOldObject();
+  TwistyObject getObject();
+  int getNumScrambles();
+  }
diff --git a/src/main/java/org/distorted/effects/objectchange/ObjectChangeEffect.java b/src/main/java/org/distorted/effects/objectchange/ObjectChangeEffect.java
index 3277e87f..37a84bc0 100644
--- a/src/main/java/org/distorted/effects/objectchange/ObjectChangeEffect.java
+++ b/src/main/java/org/distorted/effects/objectchange/ObjectChangeEffect.java
@@ -24,7 +24,7 @@ import org.distorted.library.effect.Effect;
 import org.distorted.library.main.DistortedEffects;
 import org.distorted.library.main.DistortedScreen;
 import org.distorted.library.message.EffectListener;
-import org.distorted.main.RubikPreRender;
+import org.distorted.effects.EffectController;
 import org.distorted.objects.TwistyObject;
 
 import java.lang.reflect.Method;
@@ -66,7 +66,7 @@ public abstract class ObjectChangeEffect extends BaseEffect implements EffectLis
       }
     }
 
-  private EffectListener mListener;
+  private EffectController mController;
   private int mDuration;
   private int[] mEffectReturned;
   private int[] mCubeEffectNumber, mNodeEffectNumber;
@@ -144,7 +144,7 @@ public abstract class ObjectChangeEffect extends BaseEffect implements EffectLis
                 assignEffects(1);
                 mScreen.attach(mObject[1]);
                 break;
-        case 1: mListener.effectFinished(FAKE_EFFECT_ID);
+        case 1: mController.effectFinished(FAKE_EFFECT_ID);
                 break;
         }
       }
@@ -224,13 +224,13 @@ public abstract class ObjectChangeEffect extends BaseEffect implements EffectLis
 ///////////////////////////////////////////////////////////////////////////////////////////////////
 
   @SuppressWarnings("unused")
-  public long start(int duration, DistortedScreen screen, RubikPreRender pre)
+  public long start(int duration, DistortedScreen screen, EffectController cont)
     {
-    mScreen   = screen;
-    mObject[0]= pre.getOldObject();
-    mObject[1]= pre.getObject();
-    mListener = pre;
-    mDuration = duration;
+    mScreen    = screen;
+    mObject[0] = cont.getOldObject();
+    mObject[1] = cont.getObject();
+    mController= cont;
+    mDuration  = duration;
 
     if( mObject[0]!=null )
       {
diff --git a/src/main/java/org/distorted/effects/scramble/ScrambleEffect.java b/src/main/java/org/distorted/effects/scramble/ScrambleEffect.java
index da634a1a..38bf43c8 100644
--- a/src/main/java/org/distorted/effects/scramble/ScrambleEffect.java
+++ b/src/main/java/org/distorted/effects/scramble/ScrambleEffect.java
@@ -25,6 +25,7 @@ import org.distorted.library.main.DistortedEffects;
 import org.distorted.library.main.DistortedScreen;
 import org.distorted.library.message.EffectListener;
 import org.distorted.main.RubikPreRender;
+import org.distorted.effects.EffectController;
 import org.distorted.objects.TwistyObject;
 
 import java.lang.reflect.Method;
@@ -66,7 +67,7 @@ public abstract class ScrambleEffect extends BaseEffect implements EffectListene
   public static final int START_AXIS = -2;
   public static final int STOP_AXIS  = -1;
 
-  private RubikPreRender mPreRender;
+  private EffectController mController;
   private int mEffectReturned;
   private int mNumDoubleScramblesLeft, mNumScramblesLeft;
   private int mLastRotAxis, mLastRow;
@@ -152,7 +153,7 @@ public abstract class ScrambleEffect extends BaseEffect implements EffectListene
         android.util.Log.e("effect", "ERROR: "+mNumDoubleScramblesLeft);
         }
 
-      mPreRender.addRotation(this, mLastRotAxis, rowBitmap, angle*(360/mBasicAngle), durationMillis);
+      mController.addRotation(this, mLastRotAxis, rowBitmap, angle*(360/mBasicAngle), durationMillis);
       }
     else
       {
@@ -160,7 +161,7 @@ public abstract class ScrambleEffect extends BaseEffect implements EffectListene
 
       if( mEffectReturned == mCubeEffectNumber+mNodeEffectNumber )
         {
-        mPreRender.effectFinished(FAKE_EFFECT_ID);
+        mController.effectFinished(FAKE_EFFECT_ID);
         }
       }
     }
@@ -264,7 +265,7 @@ public abstract class ScrambleEffect extends BaseEffect implements EffectListene
 
           if( mNumScramblesLeft==0 )
             {
-            mPreRender.effectFinished(FAKE_EFFECT_ID);
+            mController.effectFinished(FAKE_EFFECT_ID);
             }
           }
 
@@ -287,7 +288,7 @@ public abstract class ScrambleEffect extends BaseEffect implements EffectListene
 
           if( mNumScramblesLeft==0 )
             {
-            mPreRender.effectFinished(FAKE_EFFECT_ID);
+            mController.effectFinished(FAKE_EFFECT_ID);
             }
           }
 
@@ -299,16 +300,16 @@ public abstract class ScrambleEffect extends BaseEffect implements EffectListene
 ///////////////////////////////////////////////////////////////////////////////////////////////////
 
   @SuppressWarnings("unused")
-  public long start(int duration, DistortedScreen screen, RubikPreRender pre)
+  public long start(int duration, DistortedScreen screen, EffectController cont)
     {
-    mObject     = pre.getObject();
-    mPreRender  = pre;
+    mObject    = cont.getObject();
+    mController= cont;
 
     mObject.solve();
 
     mBasicAngle = mObject.getBasicAngle();
 
-    int numScrambles = pre.getNumScrambles();
+    int numScrambles = cont.getNumScrambles();
     int dura = (int)(duration*Math.pow(numScrambles,0.6f));
     createBaseEffects(dura,numScrambles);
     createEffects    (dura,numScrambles);
diff --git a/src/main/java/org/distorted/effects/solve/SolveEffect.java b/src/main/java/org/distorted/effects/solve/SolveEffect.java
index e3077b10..98bf2e78 100644
--- a/src/main/java/org/distorted/effects/solve/SolveEffect.java
+++ b/src/main/java/org/distorted/effects/solve/SolveEffect.java
@@ -24,7 +24,7 @@ import org.distorted.library.effect.Effect;
 import org.distorted.library.main.DistortedEffects;
 import org.distorted.library.main.DistortedScreen;
 import org.distorted.library.message.EffectListener;
-import org.distorted.main.RubikPreRender;
+import org.distorted.effects.EffectController;
 import org.distorted.objects.TwistyObject;
 
 import java.lang.reflect.Method;
@@ -63,7 +63,7 @@ public abstract class SolveEffect extends BaseEffect implements EffectListener
       }
     }
 
-  private EffectListener mListener;
+  private EffectController mController;
   private int mDuration;
   private int mEffectReturned;
   private int[] mCubeEffectNumber, mNodeEffectNumber;
@@ -134,7 +134,7 @@ public abstract class SolveEffect extends BaseEffect implements EffectListener
               createEffectsPhase1(mDuration);
               assignEffects(mPhase);
               break;
-      case 1: mListener.effectFinished(FAKE_EFFECT_ID);
+      case 1: mController.effectFinished(FAKE_EFFECT_ID);
               break;
       }
     }
@@ -197,12 +197,12 @@ public abstract class SolveEffect extends BaseEffect implements EffectListener
 ///////////////////////////////////////////////////////////////////////////////////////////////////
 
   @SuppressWarnings("unused")
-  public long start(int duration, DistortedScreen screen, RubikPreRender pre)
+  public long start(int duration, DistortedScreen screen, EffectController cont)
     {
-    mScreen   = screen;
-    mObject   = pre.getObject();
-    mListener = pre;
-    mDuration = duration;
+    mScreen    = screen;
+    mObject    = cont.getObject();
+    mController= cont;
+    mDuration  = duration;
 
     createEffectsPhase0(mDuration);
     assignEffects(mPhase);
diff --git a/src/main/java/org/distorted/effects/win/WinEffect.java b/src/main/java/org/distorted/effects/win/WinEffect.java
index 4265f901..b65565e3 100644
--- a/src/main/java/org/distorted/effects/win/WinEffect.java
+++ b/src/main/java/org/distorted/effects/win/WinEffect.java
@@ -24,7 +24,7 @@ import org.distorted.library.effect.Effect;
 import org.distorted.library.main.DistortedEffects;
 import org.distorted.library.main.DistortedScreen;
 import org.distorted.library.message.EffectListener;
-import org.distorted.main.RubikPreRender;
+import org.distorted.effects.EffectController;
 import org.distorted.objects.TwistyObject;
 
 import java.lang.reflect.Method;
@@ -62,7 +62,7 @@ public abstract class WinEffect extends BaseEffect implements EffectListener
       }
     }
 
-  private EffectListener mListener;
+  private EffectController mController;
   private int mDuration;
   private int mEffectReturned;
   private int mCubeEffectNumber, mNodeEffectNumber;
@@ -142,7 +142,7 @@ public abstract class WinEffect extends BaseEffect implements EffectListener
 
       if( effectID == id )
         {
-        if( ++mEffectReturned == total ) mListener.effectFinished(FAKE_EFFECT_ID);
+        if( ++mEffectReturned == total ) mController.effectFinished(FAKE_EFFECT_ID);
         mObject.remove(id);
         return;
         }
@@ -153,7 +153,7 @@ public abstract class WinEffect extends BaseEffect implements EffectListener
 
       if( effectID == id )
         {
-        if( ++mEffectReturned == total ) mListener.effectFinished(FAKE_EFFECT_ID);
+        if( ++mEffectReturned == total ) mController.effectFinished(FAKE_EFFECT_ID);
         mObject.getEffects().abortById(id);
         return;
         }
@@ -163,12 +163,12 @@ public abstract class WinEffect extends BaseEffect implements EffectListener
 ///////////////////////////////////////////////////////////////////////////////////////////////////
 
   @SuppressWarnings("unused")
-  public long start(int duration, DistortedScreen screen, RubikPreRender pre)
+  public long start(int duration, DistortedScreen screen, EffectController cont)
     {
-    mScreen   = screen;
-    mObject   = pre.getObject();
-    mListener = pre;
-    mDuration = duration;
+    mScreen    = screen;
+    mObject    = cont.getObject();
+    mController= cont;
+    mDuration  = duration;
 
     createEffects(mDuration);
     assignEffects();
diff --git a/src/main/java/org/distorted/main/RubikPreRender.java b/src/main/java/org/distorted/main/RubikPreRender.java
index 3795d658..28650ce4 100644
--- a/src/main/java/org/distorted/main/RubikPreRender.java
+++ b/src/main/java/org/distorted/main/RubikPreRender.java
@@ -37,8 +37,8 @@ import com.google.firebase.analytics.FirebaseAnalytics;
 import org.distorted.dialogs.RubikDialogNewRecord;
 import org.distorted.dialogs.RubikDialogSolved;
 import org.distorted.effects.BaseEffect;
+import org.distorted.effects.EffectController;
 import org.distorted.effects.scramble.ScrambleEffect;
-import org.distorted.library.message.EffectListener;
 import org.distorted.objects.TwistyObject;
 import org.distorted.objects.ObjectList;
 import org.distorted.scores.RubikScores;
@@ -48,7 +48,7 @@ import org.distorted.states.RubikStateSolving;
 
 ///////////////////////////////////////////////////////////////////////////////////////////////////
 
-public class RubikPreRender implements EffectListener
+public class RubikPreRender implements EffectController
   {
   public interface ActionFinishedListener
     {
diff --git a/src/main/java/org/distorted/tutorial/TutorialActivity.java b/src/main/java/org/distorted/tutorial/TutorialActivity.java
new file mode 100644
index 00000000..5a2b5ba0
--- /dev/null
+++ b/src/main/java/org/distorted/tutorial/TutorialActivity.java
@@ -0,0 +1,325 @@
+///////////////////////////////////////////////////////////////////////////////////////////////////
+// Copyright 2019 Leszek Koltunski                                                               //
+//                                                                                               //
+// This file is part of Magic Cube.                                                              //
+//                                                                                               //
+// Magic Cube is free software: you can redistribute it and/or modify                            //
+// it under the terms of the GNU General Public License as published by                          //
+// the Free Software Foundation, either version 2 of the License, or                             //
+// (at your option) any later version.                                                           //
+//                                                                                               //
+// Magic Cube is distributed in the hope that it will be useful,                                 //
+// but WITHOUT ANY WARRANTY; without even the implied warranty of                                //
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                                 //
+// GNU General Public License for more details.                                                  //
+//                                                                                               //
+// You should have received a copy of the GNU General Public License                             //
+// along with Magic Cube.  If not, see <http://www.gnu.org/licenses/>.                           //
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+package org.distorted.tutorial;
+
+import android.os.Build;
+import android.os.Bundle;
+import android.util.DisplayMetrics;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.webkit.WebView;
+import android.widget.LinearLayout;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.google.firebase.analytics.FirebaseAnalytics;
+
+import org.distorted.dialogs.RubikDialogError;
+import org.distorted.main.R;
+import org.distorted.objects.ObjectList;
+import org.distorted.objects.TwistyObject;
+import org.distorted.states.RubikStatePlay;
+import org.distorted.states.StateList;
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+public class TutorialActivity extends AppCompatActivity
+{
+    public static final float DIALOG_BUTTON_SIZE  = 0.06f;
+    public static final float MENU_BIG_TEXT_SIZE  = 0.05f;
+
+    public static final int FLAGS =  View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+                                   | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+                                   | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+                                   | View.SYSTEM_UI_FLAG_FULLSCREEN
+                                   | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
+
+    public static final int FLAGS2=  View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+                                   | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
+
+    private boolean mIsLocked;
+    private FirebaseAnalytics mFirebaseAnalytics;
+    private static int mScreenWidth, mScreenHeight;
+    private int mCurrentApiVersion;
+    private WebView mWebView;
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    @Override
+    protected void onCreate(Bundle savedState)
+      {
+      super.onCreate(savedState);
+      setTheme(R.style.CustomActivityThemeNoActionBar);
+      setContentView(R.layout.tutorial);
+
+      mIsLocked = false;
+      mFirebaseAnalytics = FirebaseAnalytics.getInstance(this);
+
+      DisplayMetrics displaymetrics = new DisplayMetrics();
+      getWindowManager().getDefaultDisplay().getMetrics(displaymetrics);
+      mScreenWidth =displaymetrics.widthPixels;
+      mScreenHeight=displaymetrics.heightPixels;
+
+      hideNavigationBar();
+      cutoutHack();
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    private void hideNavigationBar()
+      {
+      mCurrentApiVersion = Build.VERSION.SDK_INT;
+
+      if(mCurrentApiVersion >= Build.VERSION_CODES.KITKAT)
+        {
+        final View decorView = getWindow().getDecorView();
+
+        decorView.setSystemUiVisibility(FLAGS);
+
+        decorView.setOnSystemUiVisibilityChangeListener(new View.OnSystemUiVisibilityChangeListener()
+          {
+          @Override
+          public void onSystemUiVisibilityChange(int visibility)
+            {
+            if((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0)
+              {
+              decorView.setSystemUiVisibility(FLAGS);
+              }
+            }
+          });
+        }
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    @Override
+    public void onAttachedToWindow()
+      {
+      super.onAttachedToWindow();
+
+      final float RATIO = 0.10f;
+      float width = getScreenWidthInPixels();
+      LinearLayout layout = findViewById(R.id.rightBar);
+      ViewGroup.LayoutParams params = layout.getLayoutParams();
+      params.width = (int)(width*RATIO);
+      layout.setLayoutParams(params);
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+// do not avoid cutouts
+
+    private void cutoutHack()
+      {
+      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
+        {
+        getWindow().getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
+        }
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    @Override
+    public void onWindowFocusChanged(boolean hasFocus)
+      {
+      super.onWindowFocusChanged(hasFocus);
+
+      if(mCurrentApiVersion >= Build.VERSION_CODES.KITKAT && hasFocus)
+        {
+        getWindow().getDecorView().setSystemUiVisibility(FLAGS);
+        }
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+    
+    @Override
+    protected void onPause() 
+      {
+      super.onPause();
+      TutorialSurfaceView view = findViewById(R.id.tutorialSurfaceView);
+      view.onPause();
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+    
+    @Override
+    protected void onResume() 
+      {
+      super.onResume();
+      TutorialSurfaceView view = findViewById(R.id.tutorialSurfaceView);
+      view.onResume();
+      view.initialize();
+
+      boolean success = false;
+      RubikStatePlay play = (RubikStatePlay) StateList.PLAY.getStateClass();
+      int object = play.getObject();
+      int size   = play.getSize();
+
+      if( object>=0 && object< ObjectList.NUM_OBJECTS )
+        {
+        ObjectList obj = ObjectList.getObject(object);
+        int[] sizes = obj.getSizes();
+        int sizeIndex = ObjectList.getSizeIndex(object,size);
+
+        if( sizeIndex>=0 && sizeIndex<sizes.length )
+          {
+          success = true;
+          view.getPreRender().changeObject(obj,size);
+          }
+        }
+/*
+      if( !success )
+        {
+        ObjectList obj = ObjectList.getObject(RubikStatePlay.DEF_OBJECT);
+        int s = RubikStatePlay.DEF_SIZE;
+
+        play.setObjectAndSize(this,obj,s);
+        view.getPreRender().changeObject(obj,s);
+        }
+ */
+      }
+    
+///////////////////////////////////////////////////////////////////////////////////////////////////
+    
+    @Override
+    protected void onDestroy() 
+      {
+      super.onDestroy();
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    void OpenGLError()
+      {
+      RubikDialogError errDiag = new RubikDialogError();
+      errDiag.show(getSupportFragmentManager(), null);
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+// PUBLIC API
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    public FirebaseAnalytics getAnalytics()
+      {
+      return mFirebaseAnalytics;
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    public TwistyObject getObject()
+      {
+      TutorialSurfaceView view = findViewById(R.id.rubikSurfaceView);
+      TutorialPreRender pre = view.getPreRender();
+      return pre.getObject();
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    public int getScreenWidthInPixels()
+      {
+      return mScreenWidth;
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    public int getScreenHeightInPixels()
+      {
+      return mScreenHeight;
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    public TutorialPreRender getPreRender()
+      {
+      TutorialSurfaceView view = findViewById(R.id.tutorialSurfaceView);
+      return view.getPreRender();
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    public static int getDrawableSize()
+      {
+      if( mScreenHeight<1000 )
+        {
+        return 0;
+        }
+      if( mScreenHeight<1600 )
+        {
+        return 1;
+        }
+      if( mScreenHeight<1900 )
+        {
+        return 2;
+        }
+
+      return 3;
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    public static int getDrawable(int small, int medium, int big, int huge)
+      {
+      int size = getDrawableSize();
+
+      switch(size)
+        {
+        case 0 : return small;
+        case 1 : return medium;
+        case 2 : return big;
+        default: return huge;
+        }
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    public boolean isVertical()
+      {
+      TutorialSurfaceView view = findViewById(R.id.tutorialSurfaceView);
+      return view.isVertical();
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    public void toggleLock()
+      {
+      mIsLocked = !mIsLocked;
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    public boolean isLocked()
+      {
+      StateList state = StateList.getCurrentState();
+
+      if( state== StateList.PLAY || state== StateList.READ || state== StateList.SOLV )
+        {
+        return mIsLocked;
+        }
+
+      return false;
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    public boolean retLocked()
+      {
+      return mIsLocked;
+      }
+}
diff --git a/src/main/java/org/distorted/tutorial/TutorialPreRender.java b/src/main/java/org/distorted/tutorial/TutorialPreRender.java
new file mode 100644
index 00000000..88071911
--- /dev/null
+++ b/src/main/java/org/distorted/tutorial/TutorialPreRender.java
@@ -0,0 +1,386 @@
+///////////////////////////////////////////////////////////////////////////////////////////////////
+// Copyright 2020 Leszek Koltunski                                                               //
+//                                                                                               //
+// This file is part of Magic Cube.                                                              //
+//                                                                                               //
+// Magic Cube is free software: you can redistribute it and/or modify                            //
+// it under the terms of the GNU General Public License as published by                          //
+// the Free Software Foundation, either version 2 of the License, or                             //
+// (at your option) any later version.                                                           //
+//                                                                                               //
+// Magic Cube is distributed in the hope that it will be useful,                                 //
+// but WITHOUT ANY WARRANTY; without even the implied warranty of                                //
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                                 //
+// GNU General Public License for more details.                                                  //
+//                                                                                               //
+// You should have received a copy of the GNU General Public License                             //
+// along with Magic Cube.  If not, see <http://www.gnu.org/licenses/>.                           //
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+package org.distorted.tutorial;
+
+import android.content.Context;
+import android.content.res.Resources;
+
+import org.distorted.effects.BaseEffect;
+import org.distorted.effects.EffectController;
+import org.distorted.objects.ObjectList;
+import org.distorted.objects.TwistyObject;
+import org.distorted.main.RubikPreRender.ActionFinishedListener;
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+public class TutorialPreRender implements EffectController
+  {
+  private ActionFinishedListener mAddActionListener;
+  private TutorialSurfaceView mView;
+  private boolean mFinishRotation, mRemoveRotation, mAddRotation,
+                  mSetQuat, mChangeObject, mSetupObject, mSolveObject,
+                  mInitializeObject, mResetAllTextureMaps, mRemovePatternRotation;
+  private boolean mCanPlay;
+  private boolean mIsSolved;
+  private ObjectList mNextObject;
+  private int mNextSize;
+  private long mRotationFinishedID;
+  private int mScreenWidth;
+  private int[][] mNextMoves;
+  private TwistyObject mOldObject, mNewObject;
+  private int mAddRotationAxis, mAddRotationRowBitmap, mAddRotationAngle;
+  private long mAddRotationDuration;
+  private long mAddRotationID, mRemoveRotationID;
+  private int mNearestAngle;
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  TutorialPreRender(TutorialSurfaceView view)
+    {
+    mView = view;
+
+    mFinishRotation = false;
+    mRemoveRotation = false;
+    mAddRotation    = false;
+    mSetQuat        = false;
+    mChangeObject   = false;
+    mSetupObject    = false;
+    mSolveObject    = false;
+    mCanPlay        = true;
+    mOldObject      = null;
+    mNewObject      = null;
+    mScreenWidth    = 0;
+
+    mRemovePatternRotation= false;
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  private void createObjectNow(ObjectList object, int size, int[][] moves)
+    {
+    if( mOldObject!=null ) mOldObject.releaseResources();
+    mOldObject = mNewObject;
+
+    Context con = mView.getContext();
+    Resources res = con.getResources();
+
+    mNewObject = object.create(size, mView.getQuat(), moves, res, mScreenWidth);
+
+    if( mNewObject!=null )
+      {
+      mNewObject.createTexture();
+      mView.setMovement(object.getObjectMovementClass());
+
+      if( mScreenWidth!=0 )
+        {
+        mNewObject.recomputeScaleFactor(mScreenWidth);
+        }
+
+      mIsSolved = mNewObject.isSolved();
+      }
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  private void doEffectNow(BaseEffect.Type type)
+    {
+    try
+      {
+      type.startEffect(mView.getRenderer().getScreen(),this);
+      }
+    catch( Exception ex )
+      {
+      mCanPlay= true;
+      }
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  private void removePatternRotation()
+    {
+    mRemovePatternRotation = true;
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  private void removePatternRotationNow()
+    {
+    mRemovePatternRotation=false;
+    mNewObject.removeRotationNow();
+    mAddActionListener.onActionFinished(mRemoveRotationID);
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  private void removeRotationNow()
+    {
+    mRemoveRotation=false;
+    mNewObject.removeRotationNow();
+
+    boolean solved = mNewObject.isSolved();
+
+    if( solved && !mIsSolved )
+      {
+      doEffectNow( BaseEffect.Type.WIN );
+      }
+    else
+      {
+      mCanPlay = true;
+      }
+
+    mIsSolved = solved;
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  private void removeRotation()
+    {
+    mRemoveRotation = true;
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  private void addRotationNow()
+    {
+    mAddRotation = false;
+    mAddRotationID = mNewObject.addNewRotation( mAddRotationAxis, mAddRotationRowBitmap,
+                                                mAddRotationAngle, mAddRotationDuration, this);
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  private void finishRotationNow()
+    {
+    mFinishRotation = false;
+    mCanPlay        = false;
+    mRotationFinishedID = mNewObject.finishRotationNow(this, mNearestAngle);
+
+    if( mRotationFinishedID==0 ) // failed to add effect - should never happen
+      {
+      mCanPlay   = true;
+      }
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  private void changeObjectNow()
+    {
+    mChangeObject = false;
+
+    if ( mNewObject==null || mNewObject.getObjectList()!=mNextObject || mNewObject.getNumLayers()!=mNextSize)
+      {
+      mCanPlay  = false;
+      createObjectNow(mNextObject, mNextSize, null);
+      doEffectNow( BaseEffect.Type.SIZECHANGE );
+      }
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  private void setupObjectNow()
+    {
+    mSetupObject = false;
+
+    if ( mNewObject==null || mNewObject.getObjectList()!=mNextObject || mNewObject.getNumLayers()!=mNextSize)
+      {
+      mCanPlay  = false;
+      createObjectNow(mNextObject, mNextSize, mNextMoves);
+      doEffectNow( BaseEffect.Type.SIZECHANGE );
+      }
+    else
+      {
+      mNewObject.initializeObject(mNextMoves);
+      }
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  private void solveObjectNow()
+    {
+    mSolveObject = false;
+    mCanPlay     = false;
+    doEffectNow( BaseEffect.Type.SOLVE );
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  private void initializeObjectNow()
+    {
+    mInitializeObject = false;
+    mNewObject.initializeObject(mNextMoves);
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  private void resetAllTextureMapsNow()
+    {
+    mResetAllTextureMaps = false;
+
+    if( mNewObject!=null ) mNewObject.resetAllTextureMaps();
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  private void setQuatNow()
+    {
+    mSetQuat = false;
+    mView.setQuat();
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+//
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  void setScreenSize(int width)
+    {
+    if( mNewObject!=null )
+      {
+      mNewObject.createTexture();
+      mNewObject.recomputeScaleFactor(width);
+      }
+
+    mScreenWidth  = width;
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  void finishRotation(int nearestAngle)
+    {
+    mNearestAngle   = nearestAngle;
+    mFinishRotation = true;
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  void changeObject(ObjectList object, int size)
+    {
+    if( size>0 )
+      {
+      mChangeObject = true;
+      mNextObject = object;
+      mNextSize   = size;
+      }
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  void setQuatOnNextRender()
+    {
+    mSetQuat = true;
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  void preRender()
+    {
+    if( mSetQuat               ) setQuatNow();
+    if( mFinishRotation        ) finishRotationNow();
+    if( mRemoveRotation        ) removeRotationNow();
+    if( mChangeObject          ) changeObjectNow();
+    if( mSetupObject           ) setupObjectNow();
+    if( mSolveObject           ) solveObjectNow();
+    if( mAddRotation           ) addRotationNow();
+    if( mInitializeObject      ) initializeObjectNow();
+    if( mResetAllTextureMaps   ) resetAllTextureMapsNow();
+    if( mRemovePatternRotation ) removePatternRotationNow();
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+// PUBLIC API
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  public void addRotation(ActionFinishedListener listener, int axis, int rowBitmap, int angle, long duration)
+    {
+    mAddRotation = true;
+
+    mAddActionListener    = listener;
+    mAddRotationAxis      = axis;
+    mAddRotationRowBitmap = rowBitmap;
+    mAddRotationAngle     = angle;
+    mAddRotationDuration  = duration;
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  public void initializeObject(int[][] moves)
+    {
+    mInitializeObject = true;
+    mNextMoves = moves;
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  public int getNumScrambles()
+    {
+    return 0;
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  public void solveObject()
+    {
+    if( mCanPlay )
+      {
+      mSolveObject = true;
+      }
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  public void resetAllTextureMaps()
+    {
+    mResetAllTextureMaps = true;
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  public TwistyObject getObject()
+    {
+    return mNewObject;
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  public TwistyObject getOldObject()
+    {
+    return null;
+    }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+  public void effectFinished(final long effectID)
+    {
+    if( effectID == mRotationFinishedID )
+      {
+      mRotationFinishedID = 0;
+      removeRotation();
+      }
+    else if( effectID == mAddRotationID )
+      {
+      mAddRotationID = 0;
+      mRemoveRotationID = effectID;
+      removePatternRotation();
+      }
+    else
+      {
+      mCanPlay   = true;
+      }
+    }
+  }
diff --git a/src/main/java/org/distorted/tutorial/TutorialRenderer.java b/src/main/java/org/distorted/tutorial/TutorialRenderer.java
new file mode 100644
index 00000000..c2a2424f
--- /dev/null
+++ b/src/main/java/org/distorted/tutorial/TutorialRenderer.java
@@ -0,0 +1,81 @@
+///////////////////////////////////////////////////////////////////////////////////////////////////
+// Copyright 2019 Leszek Koltunski                                                               //
+//                                                                                               //
+// This file is part of Magic Cube.                                                              //
+//                                                                                               //
+// Magic Cube is free software: you can redistribute it and/or modify                            //
+// it under the terms of the GNU General Public License as published by                          //
+// the Free Software Foundation, either version 2 of the License, or                             //
+// (at your option) any later version.                                                           //
+//                                                                                               //
+// Magic Cube is distributed in the hope that it will be useful,                                 //
+// but WITHOUT ANY WARRANTY; without even the implied warranty of                                //
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                                 //
+// GNU General Public License for more details.                                                  //
+//                                                                                               //
+// You should have received a copy of the GNU General Public License                             //
+// along with Magic Cube.  If not, see <http://www.gnu.org/licenses/>.                           //
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+package org.distorted.tutorial;
+
+import android.opengl.GLSurfaceView;
+
+import org.distorted.library.main.DistortedScreen;
+
+import javax.microedition.khronos.egl.EGLConfig;
+import javax.microedition.khronos.opengles.GL10;
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+public class TutorialRenderer implements GLSurfaceView.Renderer
+{
+   private TutorialSurfaceView mView;
+   private DistortedScreen mScreen;
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+   TutorialRenderer(TutorialSurfaceView v)
+     {
+     final float BRIGHTNESS = 0.30f;
+
+     mView = v;
+     mScreen = new DistortedScreen();
+     mScreen.glClearColor(BRIGHTNESS, BRIGHTNESS, BRIGHTNESS, 1.0f);
+     }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+   @Override
+   public void onDrawFrame(GL10 glUnused)
+     {
+     long time = System.currentTimeMillis();
+     mView.getPreRender().preRender();
+     mScreen.render(time);
+     }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+   @Override
+   public void onSurfaceChanged(GL10 glUnused, int width, int height)
+      {
+      mScreen.resize(width,height);
+      mView.setScreenSize(width,height);
+      mView.getPreRender().setScreenSize(width);
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+   @Override
+   public void onSurfaceCreated(GL10 glUnused, EGLConfig config)
+      {
+
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+   DistortedScreen getScreen()
+     {
+     return mScreen;
+     }
+}
diff --git a/src/main/java/org/distorted/tutorial/TutorialSurfaceView.java b/src/main/java/org/distorted/tutorial/TutorialSurfaceView.java
new file mode 100644
index 00000000..679d66e4
--- /dev/null
+++ b/src/main/java/org/distorted/tutorial/TutorialSurfaceView.java
@@ -0,0 +1,710 @@
+///////////////////////////////////////////////////////////////////////////////////////////////////
+// Copyright 2019 Leszek Koltunski                                                               //
+//                                                                                               //
+// This file is part of Magic Cube.                                                              //
+//                                                                                               //
+// Magic Cube is free software: you can redistribute it and/or modify                            //
+// it under the terms of the GNU General Public License as published by                          //
+// the Free Software Foundation, either version 2 of the License, or                             //
+// (at your option) any later version.                                                           //
+//                                                                                               //
+// Magic Cube is distributed in the hope that it will be useful,                                 //
+// but WITHOUT ANY WARRANTY; without even the implied warranty of                                //
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the                                 //
+// GNU General Public License for more details.                                                  //
+//                                                                                               //
+// You should have received a copy of the GNU General Public License                             //
+// along with Magic Cube.  If not, see <http://www.gnu.org/licenses/>.                           //
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+package org.distorted.tutorial;
+
+import android.app.ActivityManager;
+import android.content.Context;
+import android.content.pm.ConfigurationInfo;
+import android.opengl.GLES30;
+import android.opengl.GLSurfaceView;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.view.MotionEvent;
+
+import com.google.firebase.crashlytics.FirebaseCrashlytics;
+
+import org.distorted.library.type.Static2D;
+import org.distorted.library.type.Static4D;
+import org.distorted.objects.Movement;
+import org.distorted.objects.TwistyObject;
+import org.distorted.states.RubikStateSolving;
+import org.distorted.states.StateList;
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+public class TutorialSurfaceView extends GLSurfaceView
+{
+    private static final int NUM_SPEED_PROBES = 10;
+    private static final int INVALID_POINTER_ID = -1;
+
+    // Moving the finger from the middle of the vertical screen to the right edge will rotate a
+    // given face by SWIPING_SENSITIVITY/2 degrees.
+    private final static int SWIPING_SENSITIVITY  = 240;
+    // Moving the finger by 0.3 of an inch will start a Rotation.
+    private final static float ROTATION_SENSITIVITY = 0.3f;
+
+    private final Static4D CAMERA_POINT = new Static4D(0, 0, 1, 0);
+
+    private TutorialRenderer mRenderer;
+    private TutorialPreRender mPreRender;
+    private Movement mMovement;
+    private boolean mDragging, mBeginningRotation, mContinuingRotation;
+    private int mScreenWidth, mScreenHeight, mScreenMin;
+
+    private float mRotAngle, mInitDistance;
+    private int mPtrID1, mPtrID2;
+    private float mX, mY;
+    private float mStartRotX, mStartRotY;
+    private float mAxisX, mAxisY;
+    private float mRotationFactor;
+    private int mCurrentAxis, mCurrentRow;
+    private float mCurrentAngle, mCurrRotSpeed;
+    private float[] mLastX;
+    private float[] mLastY;
+    private long[] mLastT;
+    private int mFirstIndex, mLastIndex;
+    private int mDensity;
+
+    private static Static4D mQuat= new Static4D(-0.25189602f,0.3546389f,0.009657208f,0.90038127f);
+    private static Static4D mTemp= new Static4D(0,0,0,1);
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    void setScreenSize(int width, int height)
+      {
+      mScreenWidth = width;
+      mScreenHeight= height;
+
+      mScreenMin = Math.min(width, height);
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    boolean isVertical()
+      {
+      return mScreenHeight>mScreenWidth;
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    TutorialRenderer getRenderer()
+      {
+      return mRenderer;
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    TutorialPreRender getPreRender()
+      {
+      return mPreRender;
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    void setQuat()
+      {
+      mQuat.set(mTemp);
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    Static4D getQuat()
+      {
+      return mQuat;
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    void setMovement(Movement movement)
+      {
+      mMovement = movement;
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    private Static4D quatFromDrag(float dragX, float dragY)
+      {
+      float axisX = dragY;  // inverted X and Y - rotation axis is perpendicular to (dragX,dragY)
+      float axisY = dragX;  // Why not (-dragY, dragX) ? because Y axis is also inverted!
+      float axisZ = 0;
+      float axisL = (float)Math.sqrt(axisX*axisX + axisY*axisY + axisZ*axisZ);
+
+      if( axisL>0 )
+        {
+        axisX /= axisL;
+        axisY /= axisL;
+        axisZ /= axisL;
+
+        float ratio = axisL;
+        ratio = ratio - (int)ratio;     // the cos() is only valid in (0,Pi)
+
+        float cosA = (float)Math.cos(Math.PI*ratio);
+        float sinA = (float)Math.sqrt(1-cosA*cosA);
+
+        return new Static4D(axisX*sinA, axisY*sinA, axisZ*sinA, cosA);
+        }
+
+      return new Static4D(0f, 0f, 0f, 1f);
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+// cast the 3D axis we are currently rotating along (which is already casted to the surface of the
+// currently touched face AND converted into a 4D vector - fourth 0) to a 2D in-screen-surface axis
+
+    private void computeCurrentAxis(Static4D axis)
+      {
+      Static4D result = rotateVectorByQuat(axis, mQuat);
+
+      mAxisX =result.get0();
+      mAxisY =result.get1();
+
+      float len = (float)Math.sqrt(mAxisX*mAxisX + mAxisY*mAxisY);
+      mAxisX /= len;
+      mAxisY /= len;
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+// return quat1*quat2
+
+    public static Static4D quatMultiply( Static4D quat1, Static4D quat2 )
+      {
+      float qx = quat1.get0();
+      float qy = quat1.get1();
+      float qz = quat1.get2();
+      float qw = quat1.get3();
+
+      float rx = quat2.get0();
+      float ry = quat2.get1();
+      float rz = quat2.get2();
+      float rw = quat2.get3();
+
+      float tx = rw*qx - rz*qy + ry*qz + rx*qw;
+      float ty = rw*qy + rz*qx + ry*qw - rx*qz;
+      float tz = rw*qz + rz*qw - ry*qx + rx*qy;
+      float tw = rw*qw - rz*qz - ry*qy - rx*qx;
+
+      return new Static4D(tx,ty,tz,tw);
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+// rotate 'vector' by quat  ( i.e. return quat*vector*(quat^-1) )
+
+    public static Static4D rotateVectorByQuat(Static4D vector, Static4D quat)
+      {
+      float qx = quat.get0();
+      float qy = quat.get1();
+      float qz = quat.get2();
+      float qw = quat.get3();
+
+      Static4D quatInverted= new Static4D(-qx,-qy,-qz,qw);
+      Static4D tmp = quatMultiply(quat,vector);
+
+      return quatMultiply(tmp,quatInverted);
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+// rotate 'vector' by quat^(-1)  ( i.e. return (quat^-1)*vector*quat )
+
+    public static Static4D rotateVectorByInvertedQuat(Static4D vector, Static4D quat)
+      {
+      float qx = quat.get0();
+      float qy = quat.get1();
+      float qz = quat.get2();
+      float qw = quat.get3();
+
+      Static4D quatInverted= new Static4D(-qx,-qy,-qz,qw);
+      Static4D tmp = quatMultiply(quatInverted,vector);
+
+      return quatMultiply(tmp,quat);
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    private void addSpeedProbe(float x, float y)
+      {
+      long currTime = System.currentTimeMillis();
+      boolean theSame = mLastIndex==mFirstIndex;
+
+      mLastIndex++;
+      if( mLastIndex>=NUM_SPEED_PROBES ) mLastIndex=0;
+
+      mLastT[mLastIndex] = currTime;
+      mLastX[mLastIndex] = x;
+      mLastY[mLastIndex] = y;
+
+      if( mLastIndex==mFirstIndex)
+        {
+        mFirstIndex++;
+        if( mFirstIndex>=NUM_SPEED_PROBES ) mFirstIndex=0;
+        }
+
+      if( theSame )
+        {
+        mLastT[mFirstIndex] = currTime;
+        mLastX[mFirstIndex] = x;
+        mLastY[mFirstIndex] = y;
+        }
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    private void computeCurrentSpeedInInchesPerSecond()
+      {
+      long firstTime = mLastT[mFirstIndex];
+      long lastTime  = mLastT[mLastIndex];
+      float fX = mLastX[mFirstIndex];
+      float fY = mLastY[mFirstIndex];
+      float lX = mLastX[mLastIndex];
+      float lY = mLastY[mLastIndex];
+
+      long timeDiff = lastTime-firstTime;
+
+      mLastIndex = 0;
+      mFirstIndex= 0;
+
+      mCurrRotSpeed = timeDiff>0 ? 1000*retFingerDragDistanceInInches(fX,fY,lX,lY)/timeDiff : 0;
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    private float retFingerDragDistanceInInches(float xFrom, float yFrom, float xTo, float yTo)
+      {
+      float xDist = mScreenWidth*(xFrom-xTo);
+      float yDist = mScreenHeight*(yFrom-yTo);
+      float distInPixels = (float)Math.sqrt(xDist*xDist + yDist*yDist);
+
+      return distInPixels/mDensity;
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    private void setUpDragOrRotate(float x, float y)
+      {
+        Static4D touchPoint = new Static4D(x, y, 0, 0);
+        Static4D rotatedTouchPoint= rotateVectorByInvertedQuat(touchPoint, mQuat);
+        Static4D rotatedCamera= rotateVectorByInvertedQuat(CAMERA_POINT, mQuat);
+
+        if( mMovement!=null && mMovement.faceTouched(rotatedTouchPoint,rotatedCamera) )
+          {
+          mDragging           = false;
+          mContinuingRotation = false;
+          mBeginningRotation  = true;
+          }
+        else
+          {
+          final TutorialActivity act = (TutorialActivity)getContext();
+          mDragging           = !act.isLocked();
+          mContinuingRotation = false;
+          mBeginningRotation  = false;
+          }
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    private void drag(MotionEvent event, float x, float y)
+      {
+      if( mPtrID1!=INVALID_POINTER_ID && mPtrID2!=INVALID_POINTER_ID)
+        {
+        int pointer = event.findPointerIndex(mPtrID2);
+        float pX,pY;
+
+        try
+          {
+          pX = event.getX(pointer);
+          pY = event.getY(pointer);
+          }
+        catch(IllegalArgumentException ex)
+          {
+          mPtrID1=INVALID_POINTER_ID;
+          mPtrID2=INVALID_POINTER_ID;
+
+          FirebaseCrashlytics crashlytics = FirebaseCrashlytics.getInstance();
+          crashlytics.setCustomKey("DragError", "pointer="+pointer );
+          crashlytics.recordException(ex);
+
+          return;
+          }
+
+        float x2 = (pX - mScreenWidth*0.5f)/mScreenMin;
+        float y2 = (mScreenHeight*0.5f -pY)/mScreenMin;
+
+        float angleNow = getAngle(x,y,x2,y2);
+        float angleDiff = angleNow-mRotAngle;
+        float sinA =-(float)Math.sin(angleDiff);
+        float cosA = (float)Math.cos(angleDiff);
+
+        Static4D dragQuat = quatMultiply(new Static4D(0,0,sinA,cosA), mQuat);
+        mTemp.set(dragQuat);
+
+        mRotAngle = angleNow;
+
+        float distNow  = (float)Math.sqrt( (x-x2)*(x-x2) + (y-y2)*(y-y2) );
+        float distQuot = mInitDistance<0 ? 1.0f : distNow/ mInitDistance;
+        mInitDistance = distNow;
+
+        TwistyObject object = mPreRender.getObject();
+        if( object!=null ) object.setObjectRatio(distQuot);
+        }
+      else
+        {
+        Static4D dragQuat = quatMultiply(quatFromDrag(mX-x,y-mY), mQuat);
+        mTemp.set(dragQuat);
+        }
+
+      mPreRender.setQuatOnNextRender();
+      mX = x;
+      mY = y;
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    private void finishRotation()
+      {
+      computeCurrentSpeedInInchesPerSecond();
+      int angle = mPreRender.getObject().computeNearestAngle(mCurrentAngle, mCurrRotSpeed);
+      mPreRender.finishRotation(angle);
+
+////////////
+// TODO
+      if( angle!=0 )
+        {
+        if( StateList.getCurrentState()== StateList.SOLV )
+          {
+          RubikStateSolving solving = (RubikStateSolving) StateList.SOLV.getStateClass();
+          solving.addMove(mCurrentAxis, mCurrentRow, angle);
+          }
+        }
+///////////
+
+      mContinuingRotation = false;
+      mBeginningRotation  = false;
+      mDragging           = true;
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    private void continueRotation(float x, float y)
+      {
+      float dx = x-mStartRotX;
+      float dy = y-mStartRotY;
+      float alpha = dx*mAxisX + dy*mAxisY;
+      float x2 = dx - alpha*mAxisX;
+      float y2 = dy - alpha*mAxisY;
+
+      float len = (float)Math.sqrt(x2*x2 + y2*y2);
+
+      // we have the length of 1D vector 'angle', now the direction:
+      float tmp = mAxisY==0 ? -mAxisX*y2 : mAxisY*x2;
+
+      float angle = (tmp>0 ? 1:-1)*len*mRotationFactor;
+      mCurrentAngle = SWIPING_SENSITIVITY*angle;
+      mPreRender.getObject().continueRotation(mCurrentAngle);
+
+      addSpeedProbe(x2,y2);
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    private void beginRotation(float x, float y)
+      {
+      mStartRotX = x;
+      mStartRotY = y;
+
+      TwistyObject object = mPreRender.getObject();
+      int numLayers = object.getNumLayers();
+
+      Static4D touchPoint2 = new Static4D(x, y, 0, 0);
+      Static4D rotatedTouchPoint2= rotateVectorByInvertedQuat(touchPoint2, mQuat);
+      Static2D res = mMovement.newRotation(numLayers,rotatedTouchPoint2);
+
+      mCurrentAxis = (int)res.get0();
+      mCurrentRow  = (int)res.get1();
+
+      computeCurrentAxis( mMovement.getCastedRotAxis(mCurrentAxis) );
+      mRotationFactor = mMovement.returnRotationFactor(numLayers,mCurrentRow);
+
+      object.beginNewRotation( mCurrentAxis, mCurrentRow );
+
+      addSpeedProbe(x,y);
+
+      mBeginningRotation = false;
+      mContinuingRotation= true;
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    private float getAngle(float x1, float y1, float x2, float y2)
+      {
+      return (float) Math.atan2(y1-y2, x1-x2);
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    private void actionMove(MotionEvent event)
+      {
+      int pointer = event.findPointerIndex(mPtrID1 != INVALID_POINTER_ID ? mPtrID1:mPtrID2);
+
+      if( pointer<0 ) return;
+
+      float pX = event.getX(pointer);
+      float pY = event.getY(pointer);
+
+      float x = (pX - mScreenWidth*0.5f)/mScreenMin;
+      float y = (mScreenHeight*0.5f -pY)/mScreenMin;
+
+      if( mBeginningRotation )
+        {
+        if( retFingerDragDistanceInInches(mX,mY,x,y) > ROTATION_SENSITIVITY )
+          {
+          beginRotation(x,y);
+          }
+        }
+      else if( mContinuingRotation )
+        {
+        continueRotation(x,y);
+        }
+      else if( mDragging )
+        {
+        drag(event,x,y);
+        }
+      else
+        {
+        setUpDragOrRotate(x,y);
+        }
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    private void actionDown(MotionEvent event)
+      {
+      mPtrID1 = event.getPointerId(0);
+
+      float x = event.getX();
+      float y = event.getY();
+
+      mX = (x - mScreenWidth*0.5f)/mScreenMin;
+      mY = (mScreenHeight*0.5f -y)/mScreenMin;
+
+      setUpDragOrRotate(mX,mY);
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    private void actionUp(MotionEvent event)
+      {
+      mPtrID1 = INVALID_POINTER_ID;
+      mPtrID2 = INVALID_POINTER_ID;
+
+      if( mContinuingRotation )
+        {
+        finishRotation();
+        }
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    private void actionDown2(MotionEvent event)
+      {
+      int index = event.getActionIndex();
+
+      if( mPtrID1==INVALID_POINTER_ID )
+        {
+        mPtrID1 = event.getPointerId(index);
+        float x = event.getX();
+        float y = event.getY();
+
+        if( mPtrID2 != INVALID_POINTER_ID )
+          {
+          int pointer = event.findPointerIndex(mPtrID2);
+
+          try
+            {
+            float x2 = event.getX(pointer);
+            float y2 = event.getY(pointer);
+
+            mRotAngle = getAngle(x,-y,x2,-y2);
+            mInitDistance = -1;
+            }
+          catch(IllegalArgumentException ex)
+            {
+            mPtrID1=INVALID_POINTER_ID;
+            mPtrID2=INVALID_POINTER_ID;
+
+            FirebaseCrashlytics crashlytics = FirebaseCrashlytics.getInstance();
+            crashlytics.setCustomKey("DragError", "pointer="+pointer );
+            crashlytics.recordException(ex);
+
+            return;
+            }
+          }
+
+        mX = (x - mScreenWidth*0.5f)/mScreenMin;
+        mY = (mScreenHeight*0.5f -y)/mScreenMin;
+        }
+      else if( mPtrID2==INVALID_POINTER_ID )
+        {
+        mPtrID2 = event.getPointerId(index);
+
+        float x = event.getX();
+        float y = event.getY();
+
+        if( mPtrID2 != INVALID_POINTER_ID )
+          {
+          int pointer = event.findPointerIndex(mPtrID2);
+
+          try
+            {
+            float x2 = event.getX(pointer);
+            float y2 = event.getY(pointer);
+
+            mRotAngle = getAngle(x,-y,x2,-y2);
+            mInitDistance = -1;
+            }
+          catch(IllegalArgumentException ex)
+            {
+            mPtrID1=INVALID_POINTER_ID;
+            mPtrID2=INVALID_POINTER_ID;
+
+            FirebaseCrashlytics crashlytics = FirebaseCrashlytics.getInstance();
+            crashlytics.setCustomKey("DragError", "pointer="+pointer );
+            crashlytics.recordException(ex);
+
+            return;
+            }
+          }
+
+        if( mBeginningRotation || mContinuingRotation )
+          {
+          mX = (x - mScreenWidth*0.5f)/mScreenMin;
+          mY = (mScreenHeight*0.5f -y)/mScreenMin;
+          }
+        }
+
+      if( mBeginningRotation )
+        {
+        mContinuingRotation = false;
+        mBeginningRotation  = false;
+        mDragging           = true;
+        }
+      else if( mContinuingRotation )
+        {
+        finishRotation();
+        }
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    private void actionUp2(MotionEvent event)
+      {
+      int index = event.getActionIndex();
+
+      if( index==event.findPointerIndex(mPtrID1) )
+        {
+        mPtrID1 = INVALID_POINTER_ID;
+        int pointer = event.findPointerIndex(mPtrID2);
+
+        if( pointer>=0 )
+          {
+          float x1 = event.getX(pointer);
+          float y1 = event.getY(pointer);
+
+          mX = (x1 - mScreenWidth*0.5f)/mScreenMin;
+          mY = (mScreenHeight*0.5f -y1)/mScreenMin;
+          }
+        }
+      else if( index==event.findPointerIndex(mPtrID2) )
+        {
+        mPtrID2 = INVALID_POINTER_ID;
+        }
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    void initialize()
+      {
+      mPtrID1 = INVALID_POINTER_ID;
+      mPtrID2 = INVALID_POINTER_ID;
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+// PUBLIC API
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    public TutorialSurfaceView(Context context, AttributeSet attrs)
+      {
+      super(context,attrs);
+
+      if(!isInEditMode())
+        {
+        mCurrRotSpeed= 0.0f;
+
+        mLastX = new float[NUM_SPEED_PROBES];
+        mLastY = new float[NUM_SPEED_PROBES];
+        mLastT = new long[NUM_SPEED_PROBES];
+        mFirstIndex =0;
+        mLastIndex  =0;
+
+        mRenderer  = new TutorialRenderer(this);
+        mPreRender = new TutorialPreRender(this);
+
+        TutorialActivity act = (TutorialActivity)context;
+        DisplayMetrics dm = new DisplayMetrics();
+        act.getWindowManager().getDefaultDisplay().getMetrics(dm);
+
+        mDensity = dm.densityDpi;
+
+        final ActivityManager activityManager= (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
+
+        try
+          {
+          final ConfigurationInfo configurationInfo = activityManager.getDeviceConfigurationInfo();
+          int esVersion = configurationInfo.reqGlEsVersion>>16;
+          setEGLContextClientVersion(esVersion);
+          setRenderer(mRenderer);
+          }
+        catch(Exception ex)
+          {
+          act.OpenGLError();
+
+          String shading = GLES30.glGetString(GLES30.GL_SHADING_LANGUAGE_VERSION);
+          String version = GLES30.glGetString(GLES30.GL_VERSION);
+          String vendor  = GLES30.glGetString(GLES30.GL_VENDOR);
+          String renderer= GLES30.glGetString(GLES30.GL_RENDERER);
+
+          FirebaseCrashlytics crashlytics = FirebaseCrashlytics.getInstance();
+          crashlytics.setCustomKey("GLSL Version"  , shading );
+          crashlytics.setCustomKey("GLversion"     , version );
+          crashlytics.setCustomKey("GL Vendor "    , vendor  );
+          crashlytics.setCustomKey("GLSLrenderer"  , renderer);
+          crashlytics.recordException(ex);
+          }
+        }
+      }
+
+///////////////////////////////////////////////////////////////////////////////////////////////////
+
+    @Override
+    public boolean onTouchEvent(MotionEvent event)
+      {
+      int action = event.getActionMasked();
+
+      switch(action)
+         {
+         case MotionEvent.ACTION_DOWN        : actionDown(event) ; break;
+         case MotionEvent.ACTION_MOVE        : actionMove(event) ; break;
+         case MotionEvent.ACTION_UP          : actionUp(event)   ; break;
+         case MotionEvent.ACTION_POINTER_DOWN: actionDown2(event); break;
+         case MotionEvent.ACTION_POINTER_UP  : actionUp2(event)  ; break;
+         }
+
+      return true;
+      }
+}
+
diff --git a/src/main/res/layout/tutorial.xml b/src/main/res/layout/tutorial.xml
new file mode 100644
index 00000000..a40090b0
--- /dev/null
+++ b/src/main/res/layout/tutorial.xml
@@ -0,0 +1,38 @@
+<?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="match_parent"
+    android:layout_marginLeft="0dp"
+    android:layout_marginRight="0dp"
+    android:background="@android:color/transparent"
+    android:orientation="vertical" >
+
+    <WebView
+        android:id="@+id/videoview"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1"/>
+
+    <LinearLayout
+        android:id="@+id/lowerPart"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:gravity="center"
+        android:layout_weight="1"
+        android:orientation="horizontal"
+        android:background="@android:color/transparent">
+
+        <org.distorted.tutorial.TutorialSurfaceView
+           android:id="@+id/tutorialSurfaceView"
+           android:layout_width="fill_parent"
+           android:layout_height="fill_parent"/>
+
+        <LinearLayout
+           android:id="@+id/rightBar"
+           android:layout_width="200dp"
+           android:layout_height="match_parent"
+           android:orientation="vertical"
+           android:background="@android:color/transparent">
+        </LinearLayout>
+    </LinearLayout>
+</LinearLayout>
\ No newline at end of file
