diff options
Diffstat (limited to 'platform_tools/android/apps/skar_java/src/main/java/com/google/skar/examples/helloskar/rendering')
3 files changed, 638 insertions, 0 deletions
diff --git a/platform_tools/android/apps/skar_java/src/main/java/com/google/skar/examples/helloskar/rendering/BackgroundRenderer.java b/platform_tools/android/apps/skar_java/src/main/java/com/google/skar/examples/helloskar/rendering/BackgroundRenderer.java new file mode 100644 index 0000000000..b6b5a859b8 --- /dev/null +++ b/platform_tools/android/apps/skar_java/src/main/java/com/google/skar/examples/helloskar/rendering/BackgroundRenderer.java @@ -0,0 +1,190 @@ +/* + * Copyright 2017 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.skar.examples.helloskar.rendering; + +import android.content.Context; +import android.opengl.GLES11Ext; +import android.opengl.GLES20; +import android.opengl.GLSurfaceView; + +import com.google.ar.core.Frame; +import com.google.ar.core.Session; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; + +/** + * This class renders the AR background from camera feed. It creates and hosts the texture given to + * ARCore to be filled with the camera image. + */ +public class BackgroundRenderer { + private static final String TAG = BackgroundRenderer.class.getSimpleName(); + + // Shader names. + private static final String VERTEX_SHADER_NAME = "shaders/screenquad.vert"; + private static final String FRAGMENT_SHADER_NAME = "shaders/screenquad.frag"; + + private static final int COORDS_PER_VERTEX = 3; + private static final int TEXCOORDS_PER_VERTEX = 2; + private static final int FLOAT_SIZE = 4; + + private FloatBuffer quadVertices; + private FloatBuffer quadTexCoord; + private FloatBuffer quadTexCoordTransformed; + + private int quadProgram; + + private int quadPositionParam; + private int quadTexCoordParam; + private int textureId = -1; + + public BackgroundRenderer() { + } + + public int getTextureId() { + return textureId; + } + + /** + * Allocates and initializes OpenGL resources needed by the background renderer. Must be called on + * the OpenGL thread, typically in {@link GLSurfaceView.Renderer#onSurfaceCreated(GL10, + * EGLConfig)}. + * + * @param context Needed to access shader source. + */ + public void createOnGlThread(Context context) throws IOException { + // Generate the background texture. + int[] textures = new int[1]; + GLES20.glGenTextures(1, textures, 0); + textureId = textures[0]; + int textureTarget = GLES11Ext.GL_TEXTURE_EXTERNAL_OES; + GLES20.glBindTexture(textureTarget, textureId); + GLES20.glTexParameteri(textureTarget, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); + GLES20.glTexParameteri(textureTarget, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); + GLES20.glTexParameteri(textureTarget, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST); + GLES20.glTexParameteri(textureTarget, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_NEAREST); + + int numVertices = 4; + if (numVertices != QUAD_COORDS.length / COORDS_PER_VERTEX) { + throw new RuntimeException("Unexpected number of vertices in BackgroundRenderer."); + } + + ByteBuffer bbVertices = ByteBuffer.allocateDirect(QUAD_COORDS.length * FLOAT_SIZE); + bbVertices.order(ByteOrder.nativeOrder()); + quadVertices = bbVertices.asFloatBuffer(); + quadVertices.put(QUAD_COORDS); + quadVertices.position(0); + + ByteBuffer bbTexCoords = + ByteBuffer.allocateDirect(numVertices * TEXCOORDS_PER_VERTEX * FLOAT_SIZE); + bbTexCoords.order(ByteOrder.nativeOrder()); + quadTexCoord = bbTexCoords.asFloatBuffer(); + quadTexCoord.put(QUAD_TEXCOORDS); + quadTexCoord.position(0); + + ByteBuffer bbTexCoordsTransformed = + ByteBuffer.allocateDirect(numVertices * TEXCOORDS_PER_VERTEX * FLOAT_SIZE); + bbTexCoordsTransformed.order(ByteOrder.nativeOrder()); + quadTexCoordTransformed = bbTexCoordsTransformed.asFloatBuffer(); + + int vertexShader = + ShaderUtil.loadGLShader(TAG, context, GLES20.GL_VERTEX_SHADER, VERTEX_SHADER_NAME); + int fragmentShader = + ShaderUtil.loadGLShader(TAG, context, GLES20.GL_FRAGMENT_SHADER, FRAGMENT_SHADER_NAME); + + quadProgram = GLES20.glCreateProgram(); + GLES20.glAttachShader(quadProgram, vertexShader); + GLES20.glAttachShader(quadProgram, fragmentShader); + GLES20.glLinkProgram(quadProgram); + GLES20.glUseProgram(quadProgram); + + ShaderUtil.checkGLError(TAG, "Program creation"); + + quadPositionParam = GLES20.glGetAttribLocation(quadProgram, "a_Position"); + quadTexCoordParam = GLES20.glGetAttribLocation(quadProgram, "a_TexCoord"); + + ShaderUtil.checkGLError(TAG, "Program parameters"); + } + + /** + * Draws the AR background image. The image will be drawn such that virtual content rendered with + * the matrices provided by {@link com.google.ar.core.Camera#getViewMatrix(float[], int)} and + * {@link com.google.ar.core.Camera#getProjectionMatrix(float[], int, float, float)} will + * accurately follow static physical objects. This must be called <b>before</b> drawing virtual + * content. + * + * @param frame The last {@code Frame} returned by {@link Session#update()}. + */ + public void draw(Frame frame) { + // If display rotation changed (also includes view size change), we need to re-query the uv + // coordinates for the screen rect, as they may have changed as well. + if (frame.hasDisplayGeometryChanged()) { + frame.transformDisplayUvCoords(quadTexCoord, quadTexCoordTransformed); + } + + // No need to test or write depth, the screen quad has arbitrary depth, and is expected + // to be drawn first. + GLES20.glDisable(GLES20.GL_DEPTH_TEST); + GLES20.glDepthMask(false); + + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId); + + GLES20.glUseProgram(quadProgram); + + // Set the vertex positions. + GLES20.glVertexAttribPointer( + quadPositionParam, COORDS_PER_VERTEX, GLES20.GL_FLOAT, false, 0, quadVertices); + + // Set the texture coordinates. + GLES20.glVertexAttribPointer( + quadTexCoordParam, + TEXCOORDS_PER_VERTEX, + GLES20.GL_FLOAT, + false, + 0, + quadTexCoordTransformed); + + // Enable vertex arrays + GLES20.glEnableVertexAttribArray(quadPositionParam); + GLES20.glEnableVertexAttribArray(quadTexCoordParam); + + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + + // Disable vertex arrays + GLES20.glDisableVertexAttribArray(quadPositionParam); + GLES20.glDisableVertexAttribArray(quadTexCoordParam); + + // Restore the depth state for further drawing. + GLES20.glDepthMask(true); + GLES20.glEnable(GLES20.GL_DEPTH_TEST); + + ShaderUtil.checkGLError(TAG, "Draw"); + } + + private static final float[] QUAD_COORDS = + new float[]{ + -1.0f, -1.0f, 0.0f, -1.0f, +1.0f, 0.0f, +1.0f, -1.0f, 0.0f, +1.0f, +1.0f, 0.0f, + }; + + private static final float[] QUAD_TEXCOORDS = + new float[]{ + 0.0f, 1.0f, + 0.0f, 0.0f, + 1.0f, 1.0f, + 1.0f, 0.0f, + }; +} diff --git a/platform_tools/android/apps/skar_java/src/main/java/com/google/skar/examples/helloskar/rendering/DrawManager.java b/platform_tools/android/apps/skar_java/src/main/java/com/google/skar/examples/helloskar/rendering/DrawManager.java new file mode 100644 index 0000000000..2ef85b049e --- /dev/null +++ b/platform_tools/android/apps/skar_java/src/main/java/com/google/skar/examples/helloskar/rendering/DrawManager.java @@ -0,0 +1,349 @@ +package com.google.skar.examples.helloskar.rendering; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.BitmapShader; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.RectF; +import android.graphics.Shader; +import android.opengl.Matrix; + +import com.google.ar.core.Plane; +import com.google.ar.core.PointCloud; +import com.google.ar.core.Pose; +import com.google.ar.core.TrackingState; +import com.google.skar.CanvasMatrixUtil; +import com.google.skar.PaintUtil; +import com.google.skar.SkARFingerPainting; +import java.io.IOException; +import java.nio.FloatBuffer; +import java.util.ArrayList; +import java.util.Collection; + +/** + * Sample class that handles drawing different types of geometry using the matrices provided + * by ARCore. The matrices are handled by SkARMatrix in order to be passed to the drawing + * Canvas. + */ + +public class DrawManager { + private float[] projectionMatrix = new float[16]; + private float[] viewMatrix = new float[16]; + private float viewportWidth; + private float viewportHeight; + private ColorFilter lightFilter; + private BitmapShader planeShader; + public ArrayList<float[]> modelMatrices = new ArrayList<>(); + public SkARFingerPainting fingerPainting = new SkARFingerPainting(false); + + public void updateViewport(float width, float height) { + viewportWidth = width; + viewportHeight = height; + } + + public void updateProjectionMatrix(float[] projectionMatrix) { + this.projectionMatrix = projectionMatrix; + } + + public void updateViewMatrix(float[] viewMatrix) { + this.viewMatrix = viewMatrix; + } + + public void updateLightColorFilter(float[] colorCorr) { + lightFilter = PaintUtil.createLightCorrectionColorFilter(colorCorr); + } + + // Sample function for drawing a circle + public void drawCircle(Canvas canvas) { + if (modelMatrices.isEmpty()) { + return; + } + Paint p = new Paint(); + p.setColorFilter(lightFilter); + p.setARGB(180, 100, 0, 0); + + canvas.save(); + android.graphics.Matrix m = CanvasMatrixUtil.createPerspectiveMatrix(modelMatrices.get(0), + viewMatrix, projectionMatrix, viewportWidth, viewportHeight); + canvas.setMatrix(m); + + canvas.drawCircle(0, 0, 0.1f, p); + canvas.restore(); + } + + // Sample function for drawing an animated round rect + public void drawAnimatedRoundRect(Canvas canvas, float radius) { + if (modelMatrices.isEmpty()) { + return; + } + Paint p = new Paint(); + p.setColorFilter(lightFilter); + p.setARGB(180, 100, 0, 100); + + canvas.save(); + canvas.setMatrix(CanvasMatrixUtil.createPerspectiveMatrix(modelMatrices.get(0), + viewMatrix, projectionMatrix, viewportWidth, viewportHeight)); + canvas.drawRoundRect(0,0, 0.5f, 0.5f, radius, radius, p); + canvas.restore(); + } + + // Sample function for drawing a rect + public void drawRect(Canvas canvas) { + if (modelMatrices.isEmpty()) { + return; + } + Paint p = new Paint(); + p.setColorFilter(lightFilter); + p.setARGB(180, 0, 0, 255); + canvas.save(); + canvas.setMatrix(CanvasMatrixUtil.createPerspectiveMatrix(modelMatrices.get(0), + viewMatrix, projectionMatrix, viewportWidth, viewportHeight)); + RectF rect = new RectF(0, 0, 0.2f, 0.2f); + canvas.drawRect(rect, p); + canvas.restore(); + } + + // Sample function for drawing text on a canvas + public void drawText(Canvas canvas, String text) { + if (modelMatrices.isEmpty()) { + return; + } + Paint p = new Paint(); + float textSize = 100; + p.setColorFilter(lightFilter); + p.setARGB(255, 0, 255, 0); + p.setTextSize(textSize); + + float[] scaleMatrix = getTextScaleMatrix(textSize); + float[] rotateMatrix = CanvasMatrixUtil.createXYtoXZRotationMatrix(); + float[][] matrices = { scaleMatrix, rotateMatrix, modelMatrices.get(0), viewMatrix, + projectionMatrix, + CanvasMatrixUtil.createViewportMatrix(viewportWidth, viewportHeight)}; + + canvas.save(); + canvas.setMatrix(CanvasMatrixUtil.createMatrixFrom4x4(CanvasMatrixUtil.multiplyMatrices4x4(matrices))); + canvas.drawText(text, 0, 0, p); + canvas.restore(); + } + + public void drawFingerPainting(Canvas canvas) { + // Build the path before rendering + fingerPainting.buildPath(); + + // If path empty, return + if (fingerPainting.getPaths().isEmpty()) { + return; + } + + // Get finger painting model matrix + float[] model = fingerPainting.getModelMatrix(); + float[] in = new float[16]; + Matrix.setIdentityM(in, 0); + Matrix.translateM(in, 0, model[12], model[13], model[14]); + + float[] initRot = CanvasMatrixUtil.createXYtoXZRotationMatrix(); + + float[] scale = new float[16]; + float s = 0.001f; + Matrix.setIdentityM(scale, 0); + Matrix.scaleM(scale, 0, s, s, s); + + // Matrix = mvpv + float[][] matrices = {scale, initRot, in, viewMatrix, projectionMatrix, CanvasMatrixUtil.createViewportMatrix(viewportWidth, viewportHeight)}; + android.graphics.Matrix mvpv = CanvasMatrixUtil.createMatrixFrom4x4(CanvasMatrixUtil.multiplyMatrices4x4(matrices)); + + // Paint set up + Paint p = new Paint(); + p.setStyle(Paint.Style.STROKE); + p.setStrokeWidth(30f); + p.setAlpha(120); + + for (Path path : fingerPainting.getPaths()) { + if (path.isEmpty()) { + continue; + } + p.setColor(fingerPainting.getPathColor(path)); + + // Scaling issues appear to happen when drawing a Path and transforming the Canvas + // directly with a matrix on Android versions less than P. Ideally we would + // switch true to be (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + + if (true) { + // Transform applied through canvas + canvas.save(); + canvas.setMatrix(mvpv); + canvas.drawPath(path, p); + canvas.restore(); + } else { + // Transform path directly + Path pathDst = new Path(); + path.transform(mvpv, pathDst); + + // Draw dest path + canvas.save(); + canvas.setMatrix(new android.graphics.Matrix()); + canvas.drawPath(pathDst, p); + canvas.restore(); + } + } + + } + + // Sample function for drawing the AR point cloud + public void drawPointCloud(Canvas canvas, PointCloud cloud) { + FloatBuffer points = cloud.getPoints(); + int numberOfPoints = points.remaining() / 4; + + float[][] matrices = {viewMatrix, projectionMatrix, CanvasMatrixUtil.createViewportMatrix(viewportWidth, viewportHeight)}; + float[] vpv = CanvasMatrixUtil.multiplyMatrices4x4(matrices); + + float[] pointsToDraw = new float[numberOfPoints * 2]; + for (int i = 0; i < numberOfPoints; i++) { + float[] point = {points.get(i * 4), points.get(i * 4 + 1), points.get(i * 4 + 2), 1}; + float[] result = CanvasMatrixUtil.multiplyMatrixVector(vpv, point, true); + pointsToDraw[i * 2] = result[0]; + pointsToDraw[i * 2 + 1] = result[1]; + } + + Paint p = new Paint(); + p.setARGB(220, 20, 232, 255); + p.setStrokeCap(Paint.Cap.SQUARE); + p.setStrokeWidth(6.0f); + + canvas.save(); + float[] id = new float[16]; + Matrix.setIdentityM(id, 0); + android.graphics.Matrix identity = CanvasMatrixUtil.createMatrixFrom4x4(id); + canvas.setMatrix(identity); + canvas.drawPoints(pointsToDraw, p); + canvas.restore(); + } + + + // Sample function for drawing AR planes + public void drawPlanes(Canvas canvas, Pose cameraPose, Collection<Plane> allPlanes) { + if (allPlanes.size() <= 0) { + return; + } + + for (Plane plane : allPlanes) { + Plane subsumePlane = plane.getSubsumedBy(); + if (plane.getTrackingState() != TrackingState.TRACKING || subsumePlane != null) { + continue; + } + + float distance = calculateDistanceToPlane(plane.getCenterPose(), cameraPose); + if (distance < 0) { // Plane is back-facing. + continue; + } + + // Get plane model matrix + float[] model = new float[16]; + plane.getCenterPose().toMatrix(model, 0); + + // Initial rotation + float[] initRot = CanvasMatrixUtil.createXYtoXZRotationMatrix(); + + // Matrix = mvpv + float[][] matrices = {initRot, model, viewMatrix, projectionMatrix, CanvasMatrixUtil.createViewportMatrix(viewportWidth, viewportHeight)}; + android.graphics.Matrix mvpv = CanvasMatrixUtil.createMatrixFrom4x4(CanvasMatrixUtil.multiplyMatrices4x4(matrices)); + + drawPlaneAsPath(canvas, mvpv, plane); + } + } + + // Helper function that draws an AR plane using a path + private void drawPlaneAsPath(Canvas canvas, android.graphics.Matrix mvpv, Plane plane) { + int vertsSize = plane.getPolygon().limit() / 2; + FloatBuffer polygon = plane.getPolygon(); + polygon.rewind(); + + // Build source path from polygon data + Path pathSrc = new Path(); + pathSrc.moveTo(polygon.get(0), polygon.get(1)); + for (int i = 1; i < vertsSize; i++) { + pathSrc.lineTo(polygon.get(i * 2), polygon.get(i * 2 + 1)); + } + pathSrc.close(); + + // Set up paint + Paint p = new Paint(); + + if (false) { + //p.setShader(planeShader); + p.setColorFilter(new PorterDuffColorFilter(Color.argb(0.4f, 1, 0, 0), + PorterDuff.Mode.SRC_ATOP)); + } + + p.setColor(Color.RED); + p.setAlpha(100); + p.setStrokeWidth(0.01f); + p.setStyle(Paint.Style.STROKE); + + + if (true) { + // Shader local matrix + android.graphics.Matrix lm = new android.graphics.Matrix(); + lm.setScale(0.00005f, 0.00005f); + planeShader.setLocalMatrix(lm); + // Draw dest path + canvas.save(); + canvas.setMatrix(mvpv); + canvas.drawPath(pathSrc, p); + canvas.restore(); + } else { + // Build destination path by transforming source path + Path pathDst = new Path(); + pathSrc.transform(mvpv, pathDst); + + // Shader local matrix + android.graphics.Matrix lm = new android.graphics.Matrix(); + lm.setScale(0.00005f, 0.00005f); + lm.postConcat(mvpv); + planeShader.setLocalMatrix(lm); + + // Draw dest path + canvas.save(); + canvas.setMatrix(new android.graphics.Matrix()); + canvas.drawPath(pathDst, p); + canvas.restore(); + } + } + + public void initializePlaneShader(Context context, String gridDistanceTextureName) throws IOException { + // Read the texture. + Bitmap planeTexture = + BitmapFactory.decodeStream(context.getAssets().open(gridDistanceTextureName)); + // Set up the shader + planeShader = new BitmapShader(planeTexture, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT); + planeShader.setLocalMatrix(new android.graphics.Matrix()); + } + + private float[] getTextScaleMatrix(float size) { + float scaleFactor = 1 / (size * 10); + float[] initScale = new float[16]; + android.opengl.Matrix.setIdentityM(initScale, 0); + android.opengl.Matrix.scaleM(initScale, 0, scaleFactor, scaleFactor, scaleFactor); + return initScale; + } + + public static float calculateDistanceToPlane(Pose planePose, Pose cameraPose) { + float[] normal = new float[3]; + float cameraX = cameraPose.tx(); + float cameraY = cameraPose.ty(); + float cameraZ = cameraPose.tz(); + // Get transformed Y axis of plane's coordinate system. + planePose.getTransformedAxis(1, 1.0f, normal, 0); + // Compute dot product of plane's normal with vector from camera to plane center. + return (cameraX - planePose.tx()) * normal[0] + + (cameraY - planePose.ty()) * normal[1] + + (cameraZ - planePose.tz()) * normal[2]; + } +} diff --git a/platform_tools/android/apps/skar_java/src/main/java/com/google/skar/examples/helloskar/rendering/ShaderUtil.java b/platform_tools/android/apps/skar_java/src/main/java/com/google/skar/examples/helloskar/rendering/ShaderUtil.java new file mode 100644 index 0000000000..ce33a45966 --- /dev/null +++ b/platform_tools/android/apps/skar_java/src/main/java/com/google/skar/examples/helloskar/rendering/ShaderUtil.java @@ -0,0 +1,99 @@ +/* + * Copyright 2017 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.skar.examples.helloskar.rendering; + +import android.content.Context; +import android.opengl.GLES20; +import android.util.Log; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; + +/** + * Shader helper functions. + */ +public class ShaderUtil { + /** + * Converts a raw text file, saved as a resource, into an OpenGL ES shader. + * + * @param type The type of shader we will be creating. + * @param filename The filename of the asset file about to be turned into a shader. + * @return The shader object handler. + */ + public static int loadGLShader(String tag, Context context, int type, String filename) + throws IOException { + String code = readRawTextFileFromAssets(context, filename); + int shader = GLES20.glCreateShader(type); + GLES20.glShaderSource(shader, code); + GLES20.glCompileShader(shader); + + // Get the compilation status. + final int[] compileStatus = new int[1]; + GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0); + + // If the compilation failed, delete the shader. + if (compileStatus[0] == 0) { + Log.e(tag, "Error compiling shader: " + GLES20.glGetShaderInfoLog(shader)); + GLES20.glDeleteShader(shader); + shader = 0; + } + + if (shader == 0) { + throw new RuntimeException("Error creating shader."); + } + + return shader; + } + + /** + * Checks if we've had an error inside of OpenGL ES, and if so what that error is. + * + * @param label Label to report in case of error. + * @throws RuntimeException If an OpenGL error is detected. + */ + public static void checkGLError(String tag, String label) { + int lastError = GLES20.GL_NO_ERROR; + // Drain the queue of all errors. + int error; + while ((error = GLES20.glGetError()) != GLES20.GL_NO_ERROR) { + Log.e(tag, label + ": glError " + error); + lastError = error; + } + if (lastError != GLES20.GL_NO_ERROR) { + throw new RuntimeException(label + ": glError " + lastError); + } + } + + /** + * Converts a raw text file into a string. + * + * @param filename The filename of the asset file about to be turned into a shader. + * @return The context of the text file, or null in case of error. + */ + private static String readRawTextFileFromAssets(Context context, String filename) + throws IOException { + try (InputStream inputStream = context.getAssets().open(filename); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append("\n"); + } + return sb.toString(); + } + } +} |