Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Contribute to GitLab
Sign in / Register
Toggle navigation
S
SandHook
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
SandHook
Commits
99c556c2
Commit
99c556c2
authored
Apr 12, 2019
by
swift_gan
Browse files
Options
Browse Files
Download
Plain Diff
Merge remote-tracking branch 'origin/master'
parents
9455db22
fc571b2c
Hide whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
507 additions
and
11 deletions
+507
-11
build.gradle
build.gradle
+1
-1
doc.md
doc/doc.md
+4
-3
ParamWrapper.java
.../src/main/java/com/swift/sandhook/utils/ParamWrapper.java
+11
-5
XmlUtils.java
...pat/src/main/java/com/android/internal/util/XmlUtils.java
+14
-0
SELinuxHelper.java
...t/src/main/java/de/robv/android/xposed/SELinuxHelper.java
+59
-0
XSharedPreferences.java
.../main/java/de/robv/android/xposed/XSharedPreferences.java
+66
-2
BaseService.java
...ain/java/de/robv/android/xposed/services/BaseService.java
+179
-0
DirectAccessService.java
.../de/robv/android/xposed/services/DirectAccessService.java
+113
-0
FileResult.java
...main/java/de/robv/android/xposed/services/FileResult.java
+60
-0
No files found.
build.gradle
View file @
99c556c2
...
...
@@ -30,7 +30,7 @@ ext {
userOrg
=
'ganyao114'
groupId
=
'com.swift.sandhook'
repoName
=
'SandHook'
publishVersion
=
'3.
2.0
'
publishVersion
=
'3.
4.6
'
desc
=
'android art hook'
website
=
'https://github.com/ganyao114/SandHook'
licences
=
[
'Apache-2.0'
]
...
...
doc/doc.md
View file @
99c556c2
...
...
@@ -185,6 +185,7 @@ https://github.com/ganyao114/SandVXposed
-
java 层中有类 ArtMethod,Method 与之一对一, Method 中含有 ArtMethod 的引用,而 mirror::ArtMethod 就是 java 层 ArtMethod 的映射。
-
6.0 之后,java ArtMethod 不复存在,被完全隐藏。
-
ArtMethod 是 ART 对 java 方法的实现(核心结构体), Method 只是作用于反射。
----
...
...
@@ -882,7 +883,7 @@ ART 的判断逻辑
那么加上 kAccCompileDontBother 即可。
---
---
-
#### Hook 线程安全
...
...
@@ -895,7 +896,7 @@ ART 的判断逻辑
----
#### StopTheWorld
####
#
StopTheWorld
那么我们需要暂停所有线程,并且等待 GC 完成
幸运的是,ART 等待调试器也需要这一操作,不仅仅是暂停所有线程,还需要等待 GC。
...
...
@@ -956,7 +957,7 @@ void Dbg::ResumeVM() {
最终选择 X17,X16 在跳板中有用到。
---
---
-
### 开始 Hook
#### Inline 与否
...
...
hooklib/src/main/java/com/swift/sandhook/utils/ParamWrapper.java
View file @
99c556c2
...
...
@@ -4,8 +4,14 @@ import com.swift.sandhook.SandHook;
public
class
ParamWrapper
{
private
static
boolean
is64Bit
;
static
{
is64Bit
=
SandHook
.
is64Bit
();
}
public
static
boolean
support
(
Class
objectType
)
{
if
(
SandHook
.
is64Bit
()
)
{
if
(
is64Bit
)
{
return
objectType
!=
float
.
class
&&
objectType
!=
double
.
class
;
}
else
{
return
objectType
!=
float
.
class
&&
objectType
!=
double
.
class
&&
objectType
!=
long
.
class
;
...
...
@@ -13,7 +19,7 @@ public class ParamWrapper {
}
public
static
Object
addressToObject
(
Class
objectType
,
long
address
)
{
if
(
SandHook
.
is64Bit
()
)
{
if
(
is64Bit
)
{
return
addressToObject64
(
objectType
,
address
);
}
else
{
return
addressToObject32
(
objectType
,
(
int
)
address
);
...
...
@@ -67,7 +73,7 @@ public class ParamWrapper {
}
public
static
long
objectToAddress
(
Class
objectType
,
Object
object
)
{
if
(
SandHook
.
is64Bit
()
)
{
if
(
is64Bit
)
{
return
objectToAddress64
(
objectType
,
object
);
}
else
{
return
objectToAddress32
(
objectType
,
object
);
...
...
@@ -87,7 +93,7 @@ public class ParamWrapper {
}
else
if
(
objectType
==
char
.
class
)
{
return
(
char
)
object
;
}
else
if
(
objectType
==
boolean
.
class
)
{
return
object
==
Boolean
.
TRUE
?
1
:
0
;
return
Boolean
.
TRUE
.
equals
(
object
)
?
1
:
0
;
}
else
{
throw
new
RuntimeException
(
"unknown type: "
+
objectType
.
toString
());
}
...
...
@@ -111,7 +117,7 @@ public class ParamWrapper {
}
else
if
(
objectType
==
char
.
class
)
{
return
(
char
)
object
;
}
else
if
(
objectType
==
boolean
.
class
)
{
return
object
==
Boolean
.
TRUE
?
1
:
0
;
return
Boolean
.
TRUE
.
equals
(
object
)
?
1
:
0
;
}
else
{
throw
new
RuntimeException
(
"unknown type: "
+
objectType
.
toString
());
}
...
...
xposedcompat/src/main/java/com/android/internal/util/XmlUtils.java
0 → 100644
View file @
99c556c2
package
com
.
android
.
internal
.
util
;
import
org.xmlpull.v1.XmlPullParserException
;
import
java.io.InputStream
;
import
java.util.HashMap
;
public
class
XmlUtils
{
public
static
final
HashMap
<
String
,
?>
readMapXml
(
InputStream
in
)
throws
XmlPullParserException
,
java
.
io
.
IOException
{
return
null
;
}
}
xposedcompat/src/main/java/de/robv/android/xposed/SELinuxHelper.java
0 → 100644
View file @
99c556c2
package
de
.
robv
.
android
.
xposed
;
import
de.robv.android.xposed.services.BaseService
;
import
de.robv.android.xposed.services.DirectAccessService
;
/**
* A helper to work with (or without) SELinux, abstracting much of its big complexity.
*/
public
final
class
SELinuxHelper
{
private
SELinuxHelper
()
{}
/**
* Determines whether SELinux is disabled or enabled.
*
* @return A boolean indicating whether SELinux is enabled.
*/
public
static
boolean
isSELinuxEnabled
()
{
return
sIsSELinuxEnabled
;
}
/**
* Determines whether SELinux is permissive or enforcing.
*
* @return A boolean indicating whether SELinux is enforcing.
*/
public
static
boolean
isSELinuxEnforced
()
{
return
sIsSELinuxEnabled
;
}
/**
* Gets the security context of the current process.
*
* @return A String representing the security context of the current process.
*/
public
static
String
getContext
()
{
return
null
;
}
/**
* Retrieve the service to be used when accessing files in {@code /data/data/*}.
*
* <p class="caution"><strong>IMPORTANT:</strong> If you call this from the Zygote process,
* don't re-use the result in different process!
*
* @return An instance of the service.
*/
public
static
BaseService
getAppDataFileService
()
{
if
(
sServiceAppDataFile
!=
null
)
return
sServiceAppDataFile
;
return
new
DirectAccessService
();
}
// ----------------------------------------------------------------------------
private
static
boolean
sIsSELinuxEnabled
=
false
;
private
static
BaseService
sServiceAppDataFile
=
new
DirectAccessService
();
}
xposedcompat/src/main/java/de/robv/android/xposed/XSharedPreferences.java
View file @
99c556c2
...
...
@@ -5,12 +5,21 @@ import android.content.Context;
import
android.content.SharedPreferences
;
import
android.os.Environment
;
import
android.preference.PreferenceManager
;
import
android.util.Log
;
import
com.android.internal.util.XmlUtils
;
import
org.xmlpull.v1.XmlPullParserException
;
import
java.io.File
;
import
java.io.FileNotFoundException
;
import
java.io.IOException
;
import
java.util.HashMap
;
import
java.util.Map
;
import
java.util.Set
;
import
de.robv.android.xposed.services.FileResult
;
/**
* This class is basically the same as SharedPreferencesImpl from AOSP, but
* read-only and without listeners support. Instead, it is made to be
...
...
@@ -69,7 +78,13 @@ public final class XSharedPreferences implements SharedPreferences {
*/
@SuppressLint
(
"SetWorldReadable"
)
public
boolean
makeWorldReadable
()
{
return
false
;
if
(!
SELinuxHelper
.
getAppDataFileService
().
hasDirectFileAccess
())
return
false
;
// It doesn't make much sense to make the file readable if we wouldn't be able to access it anyway.
if
(!
mFile
.
exists
())
// Just in case - the file should never be created if it doesn't exist.
return
false
;
return
mFile
.
setReadable
(
true
,
false
);
}
/**
...
...
@@ -97,7 +112,47 @@ public final class XSharedPreferences implements SharedPreferences {
@SuppressWarnings
({
"rawtypes"
,
"unchecked"
})
private
void
loadFromDiskLocked
()
{
if
(
mLoaded
)
{
return
;
}
Map
map
=
null
;
FileResult
result
=
null
;
try
{
result
=
SELinuxHelper
.
getAppDataFileService
().
getFileInputStream
(
mFilename
,
mFileSize
,
mLastModified
);
if
(
result
.
stream
!=
null
)
{
map
=
XmlUtils
.
readMapXml
(
result
.
stream
);
result
.
stream
.
close
();
}
else
{
// The file is unchanged, keep the current values
map
=
mMap
;
}
}
catch
(
XmlPullParserException
e
)
{
Log
.
w
(
TAG
,
"getSharedPreferences"
,
e
);
}
catch
(
FileNotFoundException
ignored
)
{
// SharedPreferencesImpl has a canRead() check, so it doesn't log anything in case the file doesn't exist
}
catch
(
IOException
e
)
{
Log
.
w
(
TAG
,
"getSharedPreferences"
,
e
);
}
finally
{
if
(
result
!=
null
&&
result
.
stream
!=
null
)
{
try
{
result
.
stream
.
close
();
}
catch
(
RuntimeException
rethrown
)
{
throw
rethrown
;
}
catch
(
Exception
ignored
)
{
}
}
}
mLoaded
=
true
;
if
(
map
!=
null
)
{
mMap
=
map
;
mLastModified
=
result
.
mtime
;
mFileSize
=
result
.
size
;
}
else
{
mMap
=
new
HashMap
<>();
}
notifyAll
();
}
/**
...
...
@@ -116,7 +171,16 @@ public final class XSharedPreferences implements SharedPreferences {
* <p><strong>Warning:</strong> With enforcing SELinux, this call might be quite expensive.
*/
public
synchronized
boolean
hasFileChanged
()
{
return
false
;
try
{
FileResult
result
=
SELinuxHelper
.
getAppDataFileService
().
statFile
(
mFilename
);
return
mLastModified
!=
result
.
mtime
||
mFileSize
!=
result
.
size
;
}
catch
(
FileNotFoundException
ignored
)
{
// SharedPreferencesImpl doesn't log anything in case the file doesn't exist
return
true
;
}
catch
(
IOException
e
)
{
Log
.
w
(
TAG
,
"hasFileChanged"
,
e
);
return
true
;
}
}
private
void
awaitLoadedLocked
()
{
...
...
xposedcompat/src/main/java/de/robv/android/xposed/services/BaseService.java
0 → 100644
View file @
99c556c2
package
de
.
robv
.
android
.
xposed
.
services
;
import
java.io.ByteArrayInputStream
;
import
java.io.FileNotFoundException
;
import
java.io.IOException
;
import
java.io.InputStream
;
import
de.robv.android.xposed.SELinuxHelper
;
/**
* General definition of a file access service provided by the Xposed framework.
*
* <p>References to a concrete subclass should generally be retrieved from {@link SELinuxHelper}.
*/
public
abstract
class
BaseService
{
/** Flag for {@link #checkFileAccess}: Read access. */
public
static
final
int
R_OK
=
4
;
/** Flag for {@link #checkFileAccess}: Write access. */
public
static
final
int
W_OK
=
2
;
/** Flag for {@link #checkFileAccess}: Executable access. */
public
static
final
int
X_OK
=
1
;
/** Flag for {@link #checkFileAccess}: File/directory exists. */
public
static
final
int
F_OK
=
0
;
/**
* Checks whether the services accesses files directly (instead of using IPC).
*
* @return {@code true} in case direct access is possible.
*/
public
boolean
hasDirectFileAccess
()
{
return
false
;
}
/**
* Check whether a file is accessible. SELinux might enforce stricter checks.
*
* @param filename The absolute path of the file to check.
* @param mode The mode for POSIX's {@code access()} function.
* @return The result of the {@code access()} function.
*/
public
abstract
boolean
checkFileAccess
(
String
filename
,
int
mode
);
/**
* Check whether a file exists.
*
* @param filename The absolute path of the file to check.
* @return The result of the {@code access()} function.
*/
@SuppressWarnings
(
"BooleanMethodIsAlwaysInverted"
)
public
boolean
checkFileExists
(
String
filename
)
{
return
checkFileAccess
(
filename
,
F_OK
);
}
/**
* Determine the size and modification time of a file.
*
* @param filename The absolute path of the file to check.
* @return A {@link FileResult} object holding the result.
* @throws IOException In case an error occurred while retrieving the information.
*/
public
abstract
FileResult
statFile
(
String
filename
)
throws
IOException
;
/**
* Determine the size time of a file.
*
* @param filename The absolute path of the file to check.
* @return The file size.
* @throws IOException In case an error occurred while retrieving the information.
*/
public
long
getFileSize
(
String
filename
)
throws
IOException
{
return
statFile
(
filename
).
size
;
}
/**
* Determine the size time of a file.
*
* @param filename The absolute path of the file to check.
* @return The file modification time.
* @throws IOException In case an error occurred while retrieving the information.
*/
public
long
getFileModificationTime
(
String
filename
)
throws
IOException
{
return
statFile
(
filename
).
mtime
;
}
/**
* Read a file into memory.
*
* @param filename The absolute path of the file to read.
* @return A {@code byte} array with the file content.
* @throws IOException In case an error occurred while reading the file.
*/
public
abstract
byte
[]
readFile
(
String
filename
)
throws
IOException
;
/**
* Read a file into memory, but only if it has changed since the last time.
*
* @param filename The absolute path of the file to read.
* @param previousSize File size of last read.
* @param previousTime File modification time of last read.
* @return A {@link FileResult} object holding the result.
* <p>The {@link FileResult#content} field might be {@code null} if the file
* is unmodified ({@code previousSize} and {@code previousTime} are still valid).
* @throws IOException In case an error occurred while reading the file.
*/
public
abstract
FileResult
readFile
(
String
filename
,
long
previousSize
,
long
previousTime
)
throws
IOException
;
/**
* Read a file into memory, optionally only if it has changed since the last time.
*
* @param filename The absolute path of the file to read.
* @param offset Number of bytes to skip at the beginning of the file.
* @param length Number of bytes to read (0 means read to end of file).
* @param previousSize Optional: File size of last read.
* @param previousTime Optional: File modification time of last read.
* @return A {@link FileResult} object holding the result.
* <p>The {@link FileResult#content} field might be {@code null} if the file
* is unmodified ({@code previousSize} and {@code previousTime} are still valid).
* @throws IOException In case an error occurred while reading the file.
*/
public
abstract
FileResult
readFile
(
String
filename
,
int
offset
,
int
length
,
long
previousSize
,
long
previousTime
)
throws
IOException
;
/**
* Get a stream to the file content.
* Depending on the service, it may or may not be read completely into memory.
*
* @param filename The absolute path of the file to read.
* @return An {@link InputStream} to the file content.
* @throws IOException In case an error occurred while reading the file.
*/
public
InputStream
getFileInputStream
(
String
filename
)
throws
IOException
{
return
new
ByteArrayInputStream
(
readFile
(
filename
));
}
/**
* Get a stream to the file content, but only if it has changed since the last time.
* Depending on the service, it may or may not be read completely into memory.
*
* @param filename The absolute path of the file to read.
* @param previousSize Optional: File size of last read.
* @param previousTime Optional: File modification time of last read.
* @return A {@link FileResult} object holding the result.
* <p>The {@link FileResult#stream} field might be {@code null} if the file
* is unmodified ({@code previousSize} and {@code previousTime} are still valid).
* @throws IOException In case an error occurred while reading the file.
*/
public
FileResult
getFileInputStream
(
String
filename
,
long
previousSize
,
long
previousTime
)
throws
IOException
{
FileResult
result
=
readFile
(
filename
,
previousSize
,
previousTime
);
if
(
result
.
content
==
null
)
return
result
;
return
new
FileResult
(
new
ByteArrayInputStream
(
result
.
content
),
result
.
size
,
result
.
mtime
);
}
// ----------------------------------------------------------------------------
/*package*/
BaseService
()
{}
/*package*/
static
void
ensureAbsolutePath
(
String
filename
)
{
if
(!
filename
.
startsWith
(
"/"
))
{
throw
new
IllegalArgumentException
(
"Only absolute filenames are allowed: "
+
filename
);
}
}
/*package*/
static
void
throwCommonIOException
(
int
errno
,
String
errorMsg
,
String
filename
,
String
defaultText
)
throws
IOException
{
switch
(
errno
)
{
case
1
:
// EPERM
case
13
:
// EACCES
throw
new
FileNotFoundException
(
errorMsg
!=
null
?
errorMsg
:
"Permission denied: "
+
filename
);
case
2
:
// ENOENT
throw
new
FileNotFoundException
(
errorMsg
!=
null
?
errorMsg
:
"No such file or directory: "
+
filename
);
case
12
:
// ENOMEM
throw
new
OutOfMemoryError
(
errorMsg
);
case
21
:
// EISDIR
throw
new
FileNotFoundException
(
errorMsg
!=
null
?
errorMsg
:
"Is a directory: "
+
filename
);
default
:
throw
new
IOException
(
errorMsg
!=
null
?
errorMsg
:
"Error "
+
errno
+
defaultText
+
filename
);
}
}
}
xposedcompat/src/main/java/de/robv/android/xposed/services/DirectAccessService.java
0 → 100644
View file @
99c556c2
package
de
.
robv
.
android
.
xposed
.
services
;
import
java.io.BufferedInputStream
;
import
java.io.File
;
import
java.io.FileInputStream
;
import
java.io.IOException
;
import
java.io.InputStream
;
/** @hide */
public
final
class
DirectAccessService
extends
BaseService
{
@Override
public
boolean
hasDirectFileAccess
()
{
return
true
;
}
@SuppressWarnings
(
"RedundantIfStatement"
)
@Override
public
boolean
checkFileAccess
(
String
filename
,
int
mode
)
{
File
file
=
new
File
(
filename
);
if
(
mode
==
F_OK
&&
!
file
.
exists
())
return
false
;
if
((
mode
&
R_OK
)
!=
0
&&
!
file
.
canRead
())
return
false
;
if
((
mode
&
W_OK
)
!=
0
&&
!
file
.
canWrite
())
return
false
;
if
((
mode
&
X_OK
)
!=
0
&&
!
file
.
canExecute
())
return
false
;
return
true
;
}
@Override
public
boolean
checkFileExists
(
String
filename
)
{
return
new
File
(
filename
).
exists
();
}
@Override
public
FileResult
statFile
(
String
filename
)
throws
IOException
{
File
file
=
new
File
(
filename
);
return
new
FileResult
(
file
.
length
(),
file
.
lastModified
());
}
@Override
public
byte
[]
readFile
(
String
filename
)
throws
IOException
{
File
file
=
new
File
(
filename
);
byte
content
[]
=
new
byte
[(
int
)
file
.
length
()];
FileInputStream
fis
=
new
FileInputStream
(
file
);
fis
.
read
(
content
);
fis
.
close
();
return
content
;
}
@Override
public
FileResult
readFile
(
String
filename
,
long
previousSize
,
long
previousTime
)
throws
IOException
{
File
file
=
new
File
(
filename
);
long
size
=
file
.
length
();
long
time
=
file
.
lastModified
();
if
(
previousSize
==
size
&&
previousTime
==
time
)
return
new
FileResult
(
size
,
time
);
return
new
FileResult
(
readFile
(
filename
),
size
,
time
);
}
@Override
public
FileResult
readFile
(
String
filename
,
int
offset
,
int
length
,
long
previousSize
,
long
previousTime
)
throws
IOException
{
File
file
=
new
File
(
filename
);
long
size
=
file
.
length
();
long
time
=
file
.
lastModified
();
if
(
previousSize
==
size
&&
previousTime
==
time
)
return
new
FileResult
(
size
,
time
);
// Shortcut for the simple case
if
(
offset
<=
0
&&
length
<=
0
)
return
new
FileResult
(
readFile
(
filename
),
size
,
time
);
// Check range
if
(
offset
>
0
&&
offset
>=
size
)
{
throw
new
IllegalArgumentException
(
"Offset "
+
offset
+
" is out of range for "
+
filename
);
}
else
if
(
offset
<
0
)
{
offset
=
0
;
}
if
(
length
>
0
&&
(
offset
+
length
)
>
size
)
{
throw
new
IllegalArgumentException
(
"Length "
+
length
+
" is out of range for "
+
filename
);
}
else
if
(
length
<=
0
)
{
length
=
(
int
)
(
size
-
offset
);
}
byte
content
[]
=
new
byte
[
length
];
FileInputStream
fis
=
new
FileInputStream
(
file
);
fis
.
skip
(
offset
);
fis
.
read
(
content
);
fis
.
close
();
return
new
FileResult
(
content
,
size
,
time
);
}
/**
* {@inheritDoc}
* <p>This implementation returns a BufferedInputStream instead of loading the file into memory.
*/
@Override
public
InputStream
getFileInputStream
(
String
filename
)
throws
IOException
{
return
new
BufferedInputStream
(
new
FileInputStream
(
filename
),
16
*
1024
);
}
/**
* {@inheritDoc}
* <p>This implementation returns a BufferedInputStream instead of loading the file into memory.
*/
@Override
public
FileResult
getFileInputStream
(
String
filename
,
long
previousSize
,
long
previousTime
)
throws
IOException
{
File
file
=
new
File
(
filename
);
long
size
=
file
.
length
();
long
time
=
file
.
lastModified
();
if
(
previousSize
==
size
&&
previousTime
==
time
)
return
new
FileResult
(
size
,
time
);
return
new
FileResult
(
new
BufferedInputStream
(
new
FileInputStream
(
filename
),
16
*
1024
),
size
,
time
);
}
}
xposedcompat/src/main/java/de/robv/android/xposed/services/FileResult.java
0 → 100644
View file @
99c556c2
package
de
.
robv
.
android
.
xposed
.
services
;
import
java.io.InputStream
;
/**
* Holder for the result of a {@link BaseService#readFile} or {@link BaseService#statFile} call.
*/
public
final
class
FileResult
{
/** File content, might be {@code null} if the file wasn't read. */
public
final
byte
[]
content
;
/** File input stream, might be {@code null} if the file wasn't read. */
public
final
InputStream
stream
;
/** File size. */
public
final
long
size
;
/** File last modification time. */
public
final
long
mtime
;
/*package*/
public
FileResult
(
long
size
,
long
mtime
)
{
this
.
content
=
null
;
this
.
stream
=
null
;
this
.
size
=
size
;
this
.
mtime
=
mtime
;
}
/*package*/
public
FileResult
(
byte
[]
content
,
long
size
,
long
mtime
)
{
this
.
content
=
content
;
this
.
stream
=
null
;
this
.
size
=
size
;
this
.
mtime
=
mtime
;
}
/*package*/
public
FileResult
(
InputStream
stream
,
long
size
,
long
mtime
)
{
this
.
content
=
null
;
this
.
stream
=
stream
;
this
.
size
=
size
;
this
.
mtime
=
mtime
;
}
/** @hide */
@Override
public
String
toString
()
{
StringBuilder
sb
=
new
StringBuilder
(
"{"
);
if
(
content
!=
null
)
{
sb
.
append
(
"content.length: "
);
sb
.
append
(
content
.
length
);
sb
.
append
(
", "
);
}
if
(
stream
!=
null
)
{
sb
.
append
(
"stream: "
);
sb
.
append
(
stream
.
toString
());
sb
.
append
(
", "
);
}
sb
.
append
(
"size: "
);
sb
.
append
(
size
);
sb
.
append
(
", mtime: "
);
sb
.
append
(
mtime
);
sb
.
append
(
"}"
);
return
sb
.
toString
();
}
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment